diff --git a/.pipelines/templates/stages/testing_baremetal/baremetal-testing.yml b/.pipelines/templates/stages/testing_baremetal/baremetal-testing.yml index 561f4222c..2ca3532d4 100644 --- a/.pipelines/templates/stages/testing_baremetal/baremetal-testing.yml +++ b/.pipelines/templates/stages/testing_baremetal/baremetal-testing.yml @@ -349,17 +349,8 @@ stages: ${{ if ne(variables['Build.SourceBranchName'], 'main') }}: kustoTableName: dev - - template: ../testing_common/e2e-test-run.yml - parameters: - buildPurpose: ${{ parameters.buildPurpose }} - deploymentEnvironment: bareMetal - tridentConfigurationName: $(TRIDENT_CONFIGURATION_NAME) - hostIp: "$(baremetal_controller.oam_ip)" - tridentConfigPath: $(TRIDENT_CONFIG_PATH) - netlistenPort: ${{ variables.NETLAUNCH_PORT }} - runtimeEnv: ${{ parameters.runtimeEnv }} - netlistenConfigFile: $(TRIDENT_SOURCE_DIR)/baremetal-netlisten.yaml - httpsProxy: "$(baremetal_controller.https_proxy)" + # E2E tests are now run via the storm_e2e stage using storm-trident. + # See .pipelines/templates/stages/testing_e2e/storm_e2e.yml - template: ../common_tasks/remove-from-acr.yml parameters: diff --git a/.pipelines/templates/stages/testing_common/e2e-ab-update-stage-finalize-test-run.yml b/.pipelines/templates/stages/testing_common/e2e-ab-update-stage-finalize-test-run.yml deleted file mode 100644 index b0227441c..000000000 --- a/.pipelines/templates/stages/testing_common/e2e-ab-update-stage-finalize-test-run.yml +++ /dev/null @@ -1,248 +0,0 @@ -parameters: - - name: buildPurpose - type: string - default: "post_merge" - values: - - daily - - pullrequest - - post_merge - - validation - - weekly - - - name: tridentConfigurationName - type: string - default: "" - - - name: deploymentEnvironment - type: string - default: virtualMachine - values: - - virtualMachine - - bareMetal - - - name: runtimeEnv - displayName: "Runtime environment (host vs container)" - type: string - default: "host" - values: - - host - - container - - - name: hostIp - type: string - default: "" - - - name: tridentConfigPath - type: string - - - name: jUnitXMLIdentifier - type: string - default: trident_e2e_tests - - - name: sshKeyPath - type: string - default: $(Build.SourcesDirectory)/tests/e2e_tests/helpers/key - - - name: userName - type: string - default: "testing-user" - - - name: artifactsDirectory - type: string - default: artifacts/test-image - - - name: netlistenPort - type: number - default: 4000 - - - name: netlistenConfigFile - type: string - default: "" - - - name: httpsProxy - type: string - default: "" - -steps: - - bash: | - set -eux - # If there is a netlisten process, kill it so there is no port clash in the instance - if pgrep netlisten > /dev/null; then pkill netlisten; fi - - ./bin/netlisten -m $(Build.SourcesDirectory)/trident-stage-update-metrics.jsonl \ - -p ${{ parameters.netlistenPort }} \ - -s "${{ parameters.artifactsDirectory }}" > ./stage-ab-update-deployment.log 2>&1 & - - PROXY_ARG="" - if [ -n "${{ parameters.httpsProxy }}" ]; then - PROXY_ARG="--env-vars HTTPS_PROXY=${{ parameters.httpsProxy }}" - fi - - echo "Running script to stage A/B update..." - ./bin/storm-trident helper ab-update \ - "${{ parameters.sshKeyPath }}" \ - "${{ parameters.hostIp }}" \ - "${{ parameters.userName }}" \ - "${{ parameters.runtimeEnv }}" \ - --trident-config $(tridentConfigFile) \ - --version $(version) \ - --stage-ab-update \ - $PROXY_ARG - timeoutInMinutes: 5 - workingDirectory: $(Build.SourcesDirectory) - displayName: "๐Ÿ”„ Stage A/B update" - condition: and(succeeded(), ne(variables['abActiveVolume'], 'null')) - - - bash: | - set -eux - sudo ./bin/storm-trident helper display-logs -a \ - --skip-serial-log \ - --trident-log-file "$(Build.SourcesDirectory)/stage-ab-update-deployment.log" \ - --artifacts-folder "$(ob_outputDirectory)" - displayName: "๐Ÿ“„ Stage A/B update deployment logs" - condition: and(succeededOrFailed(), ne(variables['abActiveVolume'], 'null')) - - - bash: | - set -eux - python3 -u -m pytest \ - -m "ab_update_staged" \ - --capture=no \ - --junit-xml=${{ parameters.tridentConfigurationName }}_${{ parameters.jUnitXMLIdentifier }}_ab_update_A_$(System.JobAttempt).junit.xml \ - --host ${{ parameters.hostIp }} \ - --runtime-env ${{ parameters.runtimeEnv }} \ - --configuration ${{ parameters.tridentConfigPath }} \ - --ab-active-volume $(abActiveVolume) - timeoutInMinutes: 10 - workingDirectory: $(Build.SourcesDirectory)/tests/e2e_tests - displayName: "๐Ÿ”ฌ Run tests to validate staging of A/B update" - condition: and(succeeded(), ne(variables['abActiveVolume'], 'null')) - - - template: ../testing_common/trident-metrics.yml - parameters: - tridentSourceDirectory: $(Build.SourcesDirectory) - tridentConfigPath: ${{ parameters.tridentConfigPath }} - deploymentEnvironment: ${{ parameters.deploymentEnvironment }} - runtimeEnvironment: ${{ parameters.runtimeEnv }} - tridentConfigurationName: ${{ parameters.tridentConfigurationName }} - metricsFile: $(Build.SourcesDirectory)/trident-stage-update-metrics.jsonl - tridentOperation: ab-update - ${{ if eq(variables['Build.SourceBranchName'], 'main') }}: - kustoTableName: main - ${{ if ne(variables['Build.SourceBranchName'], 'main') }}: - kustoTableName: dev - - - bash: | - set -eux - - NETLISTEN_CONFIG_ARGS="" - if [ -n "${{ parameters.netlistenConfigFile }}" ]; then - NETLISTEN_CONFIG_ARGS="--config ${{ parameters.netlistenConfigFile }}" - fi - - # If there is a netlisten process, kill it so there is no port clash in the instance - if pgrep netlisten > /dev/null; then pkill netlisten; fi - ./bin/netlisten $NETLISTEN_CONFIG_ARGS \ - -m $(Build.SourcesDirectory)/trident-finalize-update-metrics.jsonl \ - -p ${{ parameters.netlistenPort }} \ - -s "${{ parameters.artifactsDirectory }}" > ./finalize-ab-update.log 2>&1 & - - PROXY_ARG="" - if [ -n "${{ parameters.httpsProxy }}" ]; then - PROXY_ARG="--env-vars HTTPS_PROXY=${{ parameters.httpsProxy }}" - fi - - echo "Running script to finalize A/B update..." - ./bin/storm-trident helper ab-update \ - "${{ parameters.sshKeyPath }}" \ - "${{ parameters.hostIp }}" \ - "${{ parameters.userName }}" \ - "${{ parameters.runtimeEnv }}" \ - --trident-config $(tridentConfigFile) \ - --version $(version) \ - --finalize-ab-update \ - $PROXY_ARG - - # Un-set the 'x' flag to avoid errors. - set +x - echo "##vso[task.setvariable variable=abActiveVolume]volume-b" - timeoutInMinutes: 15 - workingDirectory: $(Build.SourcesDirectory) - displayName: "๐Ÿ”„ Finalize A/B update into target OS" - condition: and(succeeded(), ne(variables['abActiveVolume'], 'null')) - - - ${{ if eq(parameters.deploymentEnvironment, 'virtualMachine') }}: - - bash: | - set -eux - ./bin/storm-trident script capture-screenshot \ - --screenshot-filename "finalize.png" \ - --artifacts-folder "$(ob_outputDirectory)" - displayName: "๐Ÿ“ท Capture Finalize A/B update screenshot" - condition: and(succeededOrFailed(), ne(variables['abActiveVolume'], 'null')) - - - bash: | - set -eux - sudo ./bin/storm-trident helper display-logs -a \ - --netlisten-config "${{ parameters.netlistenConfigFile }}" \ - --serial-log-artifact-file-name "finalize-ab-update-B-serial.log" \ - --trident-log-file "$(Build.SourcesDirectory)/finalize-ab-update.log" \ - --artifacts-folder "$(ob_outputDirectory)" - displayName: "๐Ÿ“„ Finalize A/B update deployment logs" - condition: and(succeededOrFailed(), ne(variables['abActiveVolume'], 'null')) - - - bash: | - set -eux - $(Build.SourcesDirectory)/bin/storm-trident helper check-ssh \ - "${{ parameters.sshKeyPath }}" \ - "${{ parameters.hostIp }}" \ - "${{ parameters.userName }}" \ - "${{ parameters.runtimeEnv }}" - displayName: "๐Ÿค Check SSH connection after booting into target OS B" - condition: and(succeeded(), ne(variables['abActiveVolume'], 'null')) - - - bash: | - set -eux - ./bin/storm-trident helper boot-metrics \ - "${{ parameters.sshKeyPath }}" \ - "${{ parameters.hostIp }}" \ - "${{ parameters.userName }}" \ - "${{ parameters.runtimeEnv }}" \ - --metrics-file $(Build.SourcesDirectory)/trident-finalize-update-metrics.jsonl \ - --metrics-operation update1 - displayName: "Create boot metrics for booting into target OS B" - condition: and(succeeded(), ne(variables['abActiveVolume'], 'null')) - - - template: ../testing_common/trident-metrics.yml - parameters: - tridentSourceDirectory: $(Build.SourcesDirectory) - tridentConfigPath: ${{ parameters.tridentConfigPath }} - deploymentEnvironment: ${{ parameters.deploymentEnvironment }} - runtimeEnvironment: ${{ parameters.runtimeEnv }} - tridentConfigurationName: ${{ parameters.tridentConfigurationName }} - metricsFile: $(Build.SourcesDirectory)/trident-finalize-update-metrics.jsonl - tridentOperation: ab-update - ${{ if eq(variables['Build.SourceBranchName'], 'main') }}: - kustoTableName: main - ${{ if ne(variables['Build.SourceBranchName'], 'main') }}: - kustoTableName: dev - - - bash: | - set -eux - python3 -u -m pytest \ - -m "${{ parameters.buildPurpose }}" \ - --capture=no \ - --junit-xml=${{ parameters.deploymentEnvironment }}_${{ parameters.runtimeEnv }}_${{ parameters.tridentConfigurationName }}_${{ parameters.jUnitXMLIdentifier }}_stage_finalize_ab_update_B_$(System.JobAttempt).junit.xml \ - --host ${{ parameters.hostIp }} \ - --runtime-env ${{ parameters.runtimeEnv }} \ - --configuration ${{ parameters.tridentConfigPath }} \ - --ab-active-volume $(abActiveVolume) - timeoutInMinutes: 5 - workingDirectory: $(Build.SourcesDirectory)/tests/e2e_tests - displayName: "๐Ÿ”ฌ Run Trident E2E tests after A/B update into target OS B" - condition: and(succeeded(), ne(variables['abActiveVolume'], 'null')) - - - template: ../junit/handle-junit-test-results.yml - parameters: - testRunName: "${{ parameters.deploymentEnvironment }}_${{ parameters.runtimeEnv }}_${{ parameters.tridentConfigurationName }}_${{ parameters.jUnitXMLIdentifier }}_stage_finalize_ab_update_B_$(System.JobAttempt)" - junitTestFile: "./tests/e2e_tests/${{ parameters.deploymentEnvironment }}_${{ parameters.runtimeEnv }}_${{ parameters.tridentConfigurationName }}_${{ parameters.jUnitXMLIdentifier }}_stage_finalize_ab_update_B_$(System.JobAttempt).junit.xml" - displayNameSpecifier: "A/B update into target OS A that was staged and finalized independently" - artifactName: "junit_for_trident_ab_update_stage" diff --git a/.pipelines/templates/stages/testing_common/e2e-test-abupdate-scenario.yml b/.pipelines/templates/stages/testing_common/e2e-test-abupdate-scenario.yml deleted file mode 100644 index fbc11e2c8..000000000 --- a/.pipelines/templates/stages/testing_common/e2e-test-abupdate-scenario.yml +++ /dev/null @@ -1,232 +0,0 @@ -parameters: - # Scenario name (like 'auto-rollback') will be appended to task display names and log file names - - name: updateScenarioName - type: string - - # Scenario target OS (like 'A' or 'B') will be used in task display names and log file names - - name: updateScenarioTargetOs - type: string - - # Flag to be passed to the storm ab-update command for this scenario - - name: updateScenarioFlag - type: string - - # Whether to increment the COSI version after the update - - name: incrementUpdateVersion - type: boolean - - # Test name to be passed to pytest for selecting scenario tests - - name: updateScenarioTestName - type: string - - # Whether the storm ab-update helper should expect the target OS 'trident commit' to fail - - name: expectCommitFailure - type: boolean - - # Expected final active volume after the scenario finishes - - name: expectActiveVolume - type: string - - # Emoji to use in task display names for this scenario - - name: updateEmoji - type: string - default: "" - - # Designate configurations to skip this scenario for, space-separated - - name: skipConfigurations - type: string - default: "" - - # - # Support all of the e2e-test-run.yml parameters - # - - name: buildPurpose - type: string - - - name: tridentConfigurationName - type: string - - - name: tridentConfigFile - type: string - - - name: deploymentEnvironment - type: string - - - name: runtimeEnv - type: string - - - name: hostIp - type: string - - - name: tridentConfigPath - type: string - - - name: jUnitXMLIdentifier - type: string - - - name: sshKeyPath - type: string - - - name: userName - type: string - - - name: artifactsDirectory - type: string - - - name: netlistenPort - type: number - - - name: netlistenConfigFile - type: string - - - name: httpsProxy - type: string - -steps: - # Enable scenario tests for: - # - Tests that are not called out in skipConfigurations - # - All virtual machine pipeline tests that do A/B update - # - Bare metal deployments that do A/B update for 'weekly' pipelines - # - Bare metal 'combined' tests for non-weekly pipelines - - bash: | - set -eux - existingAbActiveVolume=$(abActiveVolume) - internalSetting=$existingAbActiveVolume - if echo "${{ parameters.skipConfigurations }}" | grep -wq "${{ parameters.tridentConfigurationName }}"; then - echo "Skipping ${{ parameters.updateScenarioName }} as it is in skipConfigurations list [${{ parameters.skipConfigurations }}]" - internalSetting='null' - elif [ "${{ parameters.deploymentEnvironment }}" == "virtualMachine" ]; then - echo "Test ${{ parameters.updateScenarioName }} for all VM tests that do A/B Update" - internalSetting=$existingAbActiveVolume - elif [ "${{ parameters.deploymentEnvironment }}" == "bareMetal" ] ]; then - if [ "${{ parameters.buildPurpose }}" == "weekly" ]; then - echo "Test ${{ parameters.updateScenarioName }} for BM tests that do A/B Update for Full Validation" - internalSetting=$existingAbActiveVolume - elif [ "${{ parameters.tridentConfigurationName }}" == "combined" ]; then - echo "Test ${{ parameters.updateScenarioName }} for BM tests that do A/B Update for combined configuration" - internalSetting=$existingAbActiveVolume - fi - else - echo "Do not test ${{ parameters.updateScenarioName }}" - internalSetting='null' - fi - set +x - echo "Cache existing abActiveVolume value: $existingAbActiveVolume" - echo "##vso[task.setvariable variable=existingAbActiveVolume]$existingAbActiveVolume" - echo "Set abActiveVolume value for this template: $internalSetting" - echo "##vso[task.setvariable variable=abActiveVolume]$internalSetting" - displayName: "Determine whether to test ${{ parameters.updateScenarioName }}" - - - bash: | - set -eux - # If there is a netlisten process, kill it so there is no port clash in the instance - if pgrep netlisten > /dev/null; then pkill netlisten; fi - - NETLISTEN_CONFIG_ARGS="" - if [ -n "${{ parameters.netlistenConfigFile }}" ]; then - NETLISTEN_CONFIG_ARGS="--config ${{ parameters.netlistenConfigFile }}" - fi - - ./bin/netlisten --force-color $NETLISTEN_CONFIG_ARGS \ - -m $(Build.SourcesDirectory)/trident-ab-update-metrics-target-os-${{ parameters.updateScenarioTargetOs }}-${{ parameters.updateScenarioName }}.jsonl \ - --full-logstream ./logstream-full.log \ - -s "${{ parameters.artifactsDirectory }}" \ - -p ${{ parameters.netlistenPort }} > ./stage-finalize-ab-update-target-os-${{ parameters.updateScenarioTargetOs }}-${{ parameters.updateScenarioName }}.log 2>&1 & - - PROXY_ARG="" - if [ -n "${{ parameters.httpsProxy }}" ]; then - PROXY_ARG="--env-vars HTTPS_PROXY=${{ parameters.httpsProxy }}" - fi - - SSH_TIMEOUT_ARG="" - EXPECT_FAILURE_ARG="" - if [ "${{ parameters.expectCommitFailure }}" == "True" ]; then - EXPECT_FAILURE_ARG="--expect-failed-commit" - # For bare metal, a commit failure that results in rollback can take longer - if [ "${{ parameters.deploymentEnvironment }}" == "bareMetal" ]; then - SSH_TIMEOUT_ARG="-t 1200" - fi - fi - - echo "Running script to stage and finalize A/B update..." - ./bin/storm-trident helper ab-update \ - "${{ parameters.sshKeyPath }}" \ - "${{ parameters.hostIp }}" \ - $SSH_TIMEOUT_ARG \ - "${{ parameters.userName }}" \ - "${{ parameters.runtimeEnv }}" \ - --trident-config ${{ parameters.tridentConfigFile }} \ - --version $(version) \ - --stage-ab-update \ - --finalize-ab-update \ - ${{ parameters.updateScenarioFlag }} \ - $EXPECT_FAILURE_ARG \ - $PROXY_ARG - - # Un-set the 'x' flag to avoid errors. - set +x - echo "##vso[task.setvariable variable=abActiveVolume]${{ parameters.expectActiveVolume }}" - - if [ "${{ parameters.incrementUpdateVersion }}" == "True" ]; then - current_version=$(echo $(version)) - new_version=$((current_version + 1)) - echo "##vso[task.setvariable variable=version]$new_version" - fi - - workingDirectory: $(Build.SourcesDirectory) - displayName: "๐Ÿ”„${{ parameters.updateEmoji }} Stage and finalize A/B update into target OS ${{ parameters.updateScenarioTargetOs }} testing ${{ parameters.updateScenarioName }}" - condition: and(succeeded(), ne(variables['abActiveVolume'], 'null')) - ${{ if eq(parameters.deploymentEnvironment, 'bareMetal') }}: - timeoutInMinutes: 22 - ${{ else }}: - timeoutInMinutes: 12 - - - ${{ if eq(parameters.deploymentEnvironment, 'virtualMachine') }}: - - bash: | - set -eux - ./bin/storm-trident script capture-screenshot \ - --screenshot-filename "update-${{ parameters.updateScenarioName }}.png" \ - --artifacts-folder "$(ob_outputDirectory)" - displayName: "๐Ÿ“ท Capture ${{ parameters.updateScenarioName }} update screenshot" - condition: and(succeededOrFailed(), ne(variables['abActiveVolume'], 'null')) - - - bash: | - set -eux - # sudo is needed to read qemu VM serial logs, `-w` is needed with sudo - sudo ./bin/storm-trident helper display-logs -a \ - --netlisten-config "${{ parameters.netlistenConfigFile }}" \ - --serial-log-artifact-file-name "stage-finalize-ab-update-target-os-${{ parameters.updateScenarioTargetOs }}-${{ parameters.updateScenarioName }}-serial.log" \ - --trident-log-file "$(Build.SourcesDirectory)/stage-finalize-ab-update-target-os-${{ parameters.updateScenarioTargetOs }}-${{ parameters.updateScenarioName }}.log" \ - --trident-trace-log-file "$(Build.SourcesDirectory)/logstream-full.log" \ - --artifacts-folder "$(ob_outputDirectory)" - displayName: "๐Ÿ“„ ${{ parameters.updateEmoji }} A/B update deployment logs for target OS ${{ parameters.updateScenarioTargetOs }}" - condition: and(succeededOrFailed(), ne(variables['abActiveVolume'], 'null')) - - - bash: | - set -eux - python3 -u -m pytest \ - -m ${{ parameters.updateScenarioTestName }} \ - --capture=no \ - --junit-xml=${{ parameters.deploymentEnvironment }}_${{ parameters.runtimeEnv }}_${{ parameters.tridentConfigurationName }}_${{ parameters.jUnitXMLIdentifier }}_ab_update_${{ parameters.updateScenarioTargetOs }}_${{ parameters.updateScenarioName }}_$(System.JobAttempt).junit.xml \ - --host ${{ parameters.hostIp }} \ - --runtime-env ${{ parameters.runtimeEnv }} \ - --configuration ${{ parameters.tridentConfigPath }} \ - --ab-active-volume ${{ parameters.expectActiveVolume }} - timeoutInMinutes: 5 - workingDirectory: $(Build.SourcesDirectory)/tests/e2e_tests - displayName: "๐Ÿ”ฌ${{ parameters.updateEmoji }} Run Trident E2E tests after A/B update into target OS ${{ parameters.updateScenarioTargetOs }} testing ${{ parameters.updateScenarioName }}" - condition: and(succeeded(), ne(variables['abActiveVolume'], 'null')) - - - bash: | - if [[ "$(existingAbActiveVolume)" != 'null' ]]; then - # If abActiveVolume was non-null before this template, set it to - # the expected volume parameter - echo "Reset abActiveVolume value to cached value: ${{ parameters.expectActiveVolume }}" - echo "##vso[task.setvariable variable=abActiveVolume]${{ parameters.expectActiveVolume }}" - else - # If abActiveVolume was null before this template, keep it as null - echo "Reset abActiveVolume value to cached value: null" - echo "##vso[task.setvariable variable=abActiveVolume]null" - fi - displayName: "Reset abActiveVolume variable" - condition: succeededOrFailed() diff --git a/.pipelines/templates/stages/testing_common/e2e-test-run.yml b/.pipelines/templates/stages/testing_common/e2e-test-run.yml deleted file mode 100644 index 081bf2824..000000000 --- a/.pipelines/templates/stages/testing_common/e2e-test-run.yml +++ /dev/null @@ -1,502 +0,0 @@ -parameters: - - name: buildPurpose - type: string - default: "post_merge" - values: - - daily - - pullrequest - - post_merge - - validation - - weekly - - - name: tridentConfigurationName - type: string - default: "" - - - name: deploymentEnvironment - type: string - default: virtualMachine - values: - - virtualMachine - - bareMetal - - - name: runtimeEnv - displayName: "Runtime environment (host vs container)" - type: string - default: "host" - values: - - host - - container - - - name: hostIp - type: string - default: "" - - - name: tridentConfigPath - type: string - - - name: jUnitXMLIdentifier - type: string - default: trident_e2e_tests - - - name: sshKeyPath - type: string - default: $(Build.SourcesDirectory)/tests/e2e_tests/helpers/key - - - name: userName - type: string - default: "testing-user" - - - name: artifactsDirectory - type: string - default: artifacts/test-image - - - name: netlistenPort - type: number - default: 4000 - - - name: netlistenConfigFile - type: string - default: "" - - - name: httpsProxy - type: string - default: "" - -steps: - - bash: | - set -eux - sudo pip3 install pytest - sudo pip3 install fabric - displayName: "Installing dependencies for E2E tests" - - - bash: | - set -eux - EXPECT_FAILURE_ARG="" - if [ "${{ parameters.tridentConfigurationName }}" == "health-checks-install" ]; then - # In health-checks-install, the Trident service will not return - # success. This is expected. - EXPECT_FAILURE_ARG="--expect-failed-commit" - fi - $(Build.SourcesDirectory)/bin/storm-trident helper check-ssh \ - "${{ parameters.sshKeyPath }}" \ - "${{ parameters.hostIp }}" \ - "${{ parameters.userName }}" \ - "${{ parameters.runtimeEnv }}" \ - $EXPECT_FAILURE_ARG - displayName: "๐Ÿค Check SSH connection" - - - bash: | - set -eux - EXPECTED_HOST_STATUS_STATE_ARG="" - if [ "${{ parameters.tridentConfigurationName }}" == "health-checks-install" ]; then - EXPECTED_HOST_STATUS_STATE_ARG="--expected-host-status-state not-provisioned" - fi - python3 -u -m pytest \ - -m "${{ parameters.buildPurpose }}" \ - --capture=no \ - --junit-xml=${{ parameters.deploymentEnvironment }}_${{ parameters.runtimeEnv }}_${{ parameters.tridentConfigurationName }}_${{ parameters.jUnitXMLIdentifier }}_clean_install_$(System.JobAttempt).junit.xml \ - --host ${{ parameters.hostIp }} \ - --runtime-env ${{ parameters.runtimeEnv }} \ - --configuration ${{ parameters.tridentConfigPath }} \ - $EXPECTED_HOST_STATUS_STATE_ARG - timeoutInMinutes: 5 - workingDirectory: $(Build.SourcesDirectory)/tests/e2e_tests - displayName: "๐Ÿ”ฌ Run Trident E2E tests after clean install of target OS" - - - template: ../junit/handle-junit-test-results.yml - parameters: - testRunName: "${{ parameters.deploymentEnvironment }}_${{ parameters.runtimeEnv }}_trident_e2e_tests_${{ parameters.tridentConfigurationName }}_clean_install_$(System.JobAttempt)" - junitTestFile: "./tests/e2e_tests/${{ parameters.deploymentEnvironment }}_${{ parameters.runtimeEnv }}_${{ parameters.tridentConfigurationName }}_${{ parameters.jUnitXMLIdentifier }}_clean_install_$(System.JobAttempt).junit.xml" - displayNameSpecifier: "clean install of target OS" - artifactName: "junit_for_trident_clean_install" - - # Check if Trident config requires A/B update testing. - # If it does, initialize the variables for active volume and image version. - - bash: | - set -eu - abUpdateExists=$(sudo yq e '.storage.abUpdate != null' "${{ parameters.tridentConfigPath }}/trident-config.yaml") - if [ "$abUpdateExists" == "true" ]; then - echo "Trident config requires A/B update testing" - echo "##vso[task.setvariable variable=abActiveVolume]volume-a" - echo "##vso[task.setvariable variable=version]2" - echo "##vso[task.setvariable variable=tridentConfigFile]/var/lib/trident/config.yaml" - else - echo "Trident config does not require A/B update testing" - echo "##vso[task.setvariable variable=abActiveVolume]null" - echo "##vso[task.setvariable variable=skipJunitHandling]true" - fi - workingDirectory: $(Build.SourcesDirectory)/tests/e2e_tests - displayName: "Check if Trident config requires A/B update testing" - - # If current config requires A/B update testing, execute script to ssh into the host, update - # images in the custom Trident config, and re-run Trident to both stage and finalize A/B update. - - bash: | - set -eux - - NETLISTEN_CONFIG_ARGS="" - if [ -n "${{ parameters.netlistenConfigFile }}" ]; then - NETLISTEN_CONFIG_ARGS="--config ${{ parameters.netlistenConfigFile }}" - fi - - ./bin/netlisten --force-color $NETLISTEN_CONFIG_ARGS \ - -m $(Build.SourcesDirectory)/trident-ab-update-metrics-target-os-B.jsonl \ - --full-logstream ./logstream-full.log \ - -s "${{ parameters.artifactsDirectory }}" \ - -p ${{ parameters.netlistenPort }} > ./stage-finalize-ab-update-target-os-B.log 2>&1 & - - PROXY_ARG="" - if [ -n "${{ parameters.httpsProxy }}" ]; then - PROXY_ARG="--env-vars HTTPS_PROXY=${{ parameters.httpsProxy }}" - fi - - echo "Running script to stage and finalize A/B update..." - ./bin/storm-trident helper ab-update \ - "${{ parameters.sshKeyPath }}" \ - "${{ parameters.hostIp }}" \ - "${{ parameters.userName }}" \ - "${{ parameters.runtimeEnv }}" \ - --trident-config $(tridentConfigFile) \ - --version $(version) \ - --stage-ab-update \ - --finalize-ab-update \ - $PROXY_ARG - - current_version=$(echo $(version)) - new_version=$((current_version + 1)) - # Un-set the 'x' flag to avoid errors. - set +x - echo "##vso[task.setvariable variable=version]$new_version" - echo "##vso[task.setvariable variable=abActiveVolume]volume-b" - timeoutInMinutes: 15 - workingDirectory: $(Build.SourcesDirectory) - displayName: "๐Ÿ…ฑ๏ธ๐Ÿ”„ Stage and finalize A/B update into target OS B" - condition: and(succeeded(), ne(variables['abActiveVolume'], 'null')) - - - ${{ if eq(parameters.deploymentEnvironment, 'virtualMachine') }}: - - bash: | - set -eux - ./bin/storm-trident script capture-screenshot \ - --screenshot-filename "update-b.png" \ - --artifacts-folder "$(ob_outputDirectory)" - displayName: "๐Ÿ“ท Capture screenshot: stage and finalize A/B update into target OS B" - condition: and(succeededOrFailed(), ne(variables['abActiveVolume'], 'null')) - - - bash: | - set -eux - # sudo is needed to read qemu VM serial logs, `-w` is needed with sudo - sudo ./bin/storm-trident helper display-logs -a \ - --netlisten-config "${{ parameters.netlistenConfigFile }}" \ - --serial-log-artifact-file-name "stage-finalize-ab-update-target-os-B-serial.log" \ - --trident-log-file "$(Build.SourcesDirectory)/stage-finalize-ab-update-target-os-B.log" \ - --trident-trace-log-file "$(Build.SourcesDirectory)/logstream-full.log" \ - --artifacts-folder "$(ob_outputDirectory)" - displayName: "๐Ÿ“„ A/B update deployment logs for target OS B" - condition: and(succeededOrFailed(), ne(variables['abActiveVolume'], 'null')) - - - bash: | - set -eux - ./bin/storm-trident helper boot-metrics \ - "${{ parameters.sshKeyPath }}" \ - "${{ parameters.hostIp }}" \ - "${{ parameters.userName }}" \ - "${{ parameters.runtimeEnv }}" \ - --metrics-file $(Build.SourcesDirectory)/trident-ab-update-metrics-target-os-B.jsonl \ - --metrics-operation update1 - displayName: "Create boot metrics for booting into target OS B" - condition: and(succeeded(), ne(variables['abActiveVolume'], 'null')) - - - template: ../testing_common/trident-metrics.yml - parameters: - tridentSourceDirectory: $(Build.SourcesDirectory) - tridentConfigPath: ${{ parameters.tridentConfigPath }} - deploymentEnvironment: ${{ parameters.deploymentEnvironment }} - runtimeEnvironment: ${{ parameters.runtimeEnv }} - tridentConfigurationName: ${{ parameters.tridentConfigurationName }} - metricsFile: $(Build.SourcesDirectory)/trident-ab-update-metrics-target-os-B.jsonl - tridentOperation: ab-update - ${{ if eq(variables['Build.SourceBranchName'], 'main') }}: - kustoTableName: main - ${{ if ne(variables['Build.SourceBranchName'], 'main') }}: - kustoTableName: dev - - # Re-run E2E tests after A/B update into target OS B. - - bash: | - set -eux - python3 -u -m pytest \ - -m "${{ parameters.buildPurpose }}" \ - --capture=no \ - --junit-xml=${{ parameters.deploymentEnvironment }}_${{ parameters.runtimeEnv }}_${{ parameters.tridentConfigurationName }}_${{ parameters.jUnitXMLIdentifier }}_ab_update_B_$(System.JobAttempt).junit.xml \ - --host ${{ parameters.hostIp }} \ - --runtime-env ${{ parameters.runtimeEnv }} \ - --configuration ${{ parameters.tridentConfigPath }} \ - --ab-active-volume $(abActiveVolume) - timeoutInMinutes: 5 - workingDirectory: $(Build.SourcesDirectory)/tests/e2e_tests - displayName: "๐Ÿ”ฌ Run Trident E2E tests after A/B update into target OS B" - condition: and(succeeded(), ne(variables['abActiveVolume'], 'null')) - - - template: ../junit/handle-junit-test-results.yml - parameters: - testRunName: "${{ parameters.deploymentEnvironment }}_${{ parameters.runtimeEnv }}_trident_e2e_tests_${{ parameters.tridentConfigurationName }}_ab_update_B_$(System.JobAttempt)" - junitTestFile: "./tests/e2e_tests/${{ parameters.deploymentEnvironment }}_${{ parameters.runtimeEnv }}_${{ parameters.tridentConfigurationName }}_${{ parameters.jUnitXMLIdentifier }}_ab_update_B_$(System.JobAttempt).junit.xml" - displayNameSpecifier: "A/B update into target OS B" - artifactName: "junit_for_trident_ab_update_B" - - # Test auto-rollback scenario after A/B update into target OS B. - # This scenario will configure an update into target OS A that - # will execute failing health checks, triggering an auto rollback - # to target OS B. - # - # This scenario is configured to run for all VM e2e tests that - # do A/B update, and for BM e2e tests that do A/B update in the - # weekly build purpose. - - template: ./e2e-test-abupdate-scenario.yml - parameters: - # Rollback-specific parameters - updateScenarioName: "auto-rollback" - updateScenarioTargetOs: "A" - updateScenarioFlag: "--forced-rollback" - expectActiveVolume: volume-b - incrementUpdateVersion: false - updateScenarioTestName: "rollback" - expectCommitFailure: true - updateEmoji: "โŽŒ" - # Pass all other parameters through - buildPurpose: ${{ parameters.buildPurpose }} - tridentConfigurationName: ${{ parameters.tridentConfigurationName }} - tridentConfigFile: $(tridentConfigFile) - deploymentEnvironment: ${{ parameters.deploymentEnvironment }} - runtimeEnv: ${{ parameters.runtimeEnv }} - hostIp: ${{ parameters.hostIp }} - tridentConfigPath: ${{ parameters.tridentConfigPath }} - jUnitXMLIdentifier: ${{ parameters.jUnitXMLIdentifier }} - sshKeyPath: ${{ parameters.sshKeyPath }} - userName: ${{ parameters.userName }} - artifactsDirectory: ${{ parameters.artifactsDirectory }} - netlistenPort: ${{ parameters.netlistenPort }} - netlistenConfigFile: ${{ parameters.netlistenConfigFile }} - httpsProxy: ${{ parameters.httpsProxy }} - - # Run A/B update from servicing OS B into target OS A. - - bash: | - set -eux - # If there is a netlisten process, kill it so there is no port clash in the instance - if pgrep netlisten > /dev/null; then pkill netlisten; fi - - NETLISTEN_CONFIG_ARGS="" - if [ -n "${{ parameters.netlistenConfigFile }}" ]; then - NETLISTEN_CONFIG_ARGS="--config ${{ parameters.netlistenConfigFile }}" - fi - - ./bin/netlisten --force-color $NETLISTEN_CONFIG_ARGS \ - -m $(Build.SourcesDirectory)/trident-ab-update-metrics-target-os-A.jsonl \ - --full-logstream ./logstream-full.log \ - -s "${{ parameters.artifactsDirectory }}" \ - -p ${{ parameters.netlistenPort }} > ./stage-finalize-ab-update-target-os-A.log 2>&1 & - - PROXY_ARG="" - if [ -n "${{ parameters.httpsProxy }}" ]; then - PROXY_ARG="--env-vars HTTPS_PROXY=${{ parameters.httpsProxy }}" - fi - - echo "Running script to stage and finalize A/B update..." - ./bin/storm-trident helper ab-update \ - "${{ parameters.sshKeyPath }}" \ - "${{ parameters.hostIp }}" \ - "${{ parameters.userName }}" \ - "${{ parameters.runtimeEnv }}" \ - --trident-config $(tridentConfigFile) \ - --version $(version) \ - --stage-ab-update \ - --finalize-ab-update \ - $PROXY_ARG - - current_version=$(echo $(version)) - new_version=$((current_version + 1)) - # Un-set the 'x' flag to avoid errors. - set +x - echo "##vso[task.setvariable variable=version]$new_version" - echo "##vso[task.setvariable variable=abActiveVolume]volume-a" - timeoutInMinutes: 15 - workingDirectory: $(Build.SourcesDirectory) - displayName: "๐Ÿ…ฐ๏ธ๐Ÿ”„ Stage and finalize A/B update into target OS A" - condition: and(succeeded(), ne(variables['abActiveVolume'], 'null')) - - - ${{ if eq(parameters.deploymentEnvironment, 'virtualMachine') }}: - - bash: | - set -eux - ./bin/storm-trident script capture-screenshot \ - --screenshot-filename "update-a.png" \ - --artifacts-folder "$(ob_outputDirectory)" - displayName: "๐Ÿ“ท Capture screenshot: stage and finalize A/B update into target OS A" - condition: and(succeededOrFailed(), ne(variables['abActiveVolume'], 'null')) - - - bash: | - set -eux - # sudo is needed to read qemu VM serial logs, `-w` is needed with sudo - sudo ./bin/storm-trident helper display-logs -a \ - --netlisten-config "${{ parameters.netlistenConfigFile }}" \ - --serial-log-artifact-file-name "stage-finalize-ab-update-target-os-A-serial.log" \ - --trident-log-file "$(Build.SourcesDirectory)/stage-finalize-ab-update-target-os-A.log" \ - --trident-trace-log-file "$(Build.SourcesDirectory)/logstream-full.log" \ - --artifacts-folder "$(ob_outputDirectory)" - displayName: "๐Ÿ“„ A/B update deployment logs for target OS A" - condition: and(succeededOrFailed(), ne(variables['abActiveVolume'], 'null')) - - - bash: | - set -eux - ./bin/storm-trident helper boot-metrics \ - "${{ parameters.sshKeyPath }}" \ - "${{ parameters.hostIp }}" \ - "${{ parameters.userName }}" \ - "${{ parameters.runtimeEnv }}" \ - --metrics-file $(Build.SourcesDirectory)/trident-ab-update-metrics-target-os-A.jsonl \ - --metrics-operation update2 - displayName: "Create boot metrics for booting into target OS A" - condition: and(succeeded(), ne(variables['abActiveVolume'], 'null')) - - - template: ../testing_common/trident-metrics.yml - parameters: - tridentSourceDirectory: $(Build.SourcesDirectory) - tridentConfigPath: ${{ parameters.tridentConfigPath }} - deploymentEnvironment: ${{ parameters.deploymentEnvironment }} - runtimeEnvironment: ${{ parameters.runtimeEnv }} - tridentConfigurationName: ${{ parameters.tridentConfigurationName }} - metricsFile: $(Build.SourcesDirectory)/trident-ab-update-metrics-target-os-A.jsonl - tridentOperation: ab-update - ${{ if eq(variables['Build.SourceBranchName'], 'main') }}: - kustoTableName: main - ${{ if ne(variables['Build.SourceBranchName'], 'main') }}: - kustoTableName: dev - - - bash: | - set -eux - python3 -u -m pytest \ - -m "${{ parameters.buildPurpose }}" \ - --capture=no \ - --junit-xml=${{ parameters.deploymentEnvironment }}_${{ parameters.runtimeEnv }}_${{ parameters.tridentConfigurationName }}_${{ parameters.jUnitXMLIdentifier }}_ab_update_A_$(System.JobAttempt).junit.xml \ - --host ${{ parameters.hostIp }} \ - --runtime-env ${{ parameters.runtimeEnv }} \ - --configuration ${{ parameters.tridentConfigPath }} \ - --ab-active-volume $(abActiveVolume) - timeoutInMinutes: 5 - workingDirectory: $(Build.SourcesDirectory)/tests/e2e_tests - displayName: "๐Ÿ”ฌ Run Trident E2E tests after A/B update into target OS A" - condition: and(succeeded(), ne(variables['abActiveVolume'], 'null')) - - - template: ../junit/handle-junit-test-results.yml - parameters: - testRunName: "${{ parameters.deploymentEnvironment }}_${{ parameters.runtimeEnv }}_trident_e2e_tests_${{ parameters.tridentConfigurationName }}_ab_update_A_$(System.JobAttempt)" - junitTestFile: "./tests/e2e_tests/${{ parameters.deploymentEnvironment }}_${{ parameters.runtimeEnv }}_${{ parameters.tridentConfigurationName }}_${{ parameters.jUnitXMLIdentifier }}_ab_update_A_$(System.JobAttempt).junit.xml" - displayNameSpecifier: "A/B update into target OS A" - artifactName: "junit_for_trident_ab_update_A" - - # Check the value of 'buildPurpose', to determine if we need to execute the - # test scenario where A/B update is staged and finalized independently. For - # lower frequency, this test is to be run only when 'buildPurpose' is: - # - daily - # - validation - # - weekly - # - post_merge - - ${{ if or(eq(parameters.buildPurpose, 'daily'), eq(parameters.buildPurpose, 'validation'), eq(parameters.buildPurpose, 'weekly'), eq(parameters.buildPurpose, 'post_merge')) }}: - - template: e2e-ab-update-stage-finalize-test-run.yml - parameters: - buildPurpose: ${{ parameters.buildPurpose }} - tridentConfigurationName: ${{ parameters.tridentConfigurationName }} - hostIp: ${{ parameters.hostIp }} - runtimeEnv: ${{ parameters.runtimeEnv }} - tridentConfigPath: ${{ parameters.tridentConfigPath }} - sshKeyPath: ${{ parameters.sshKeyPath }} - userName: ${{ parameters.userName }} - artifactsDirectory: ${{ parameters.artifactsDirectory }} - deploymentEnvironment: ${{ parameters.deploymentEnvironment }} - netlistenPort: ${{ parameters.netlistenPort }} - netlistenConfigFile: ${{ parameters.netlistenConfigFile }} - httpsProxy: ${{ parameters.httpsProxy }} - - - bash: | - set -eux - # To allow storm to access VM serial logs, need sudo. - sudo $(Build.SourcesDirectory)/bin/storm-trident helper rebuild-raid -a \ - --log-dir "$(ob_outputDirectory)" \ - "${{ parameters.sshKeyPath }}" \ - "${{ parameters.hostIp }}" \ - "${{ parameters.userName }}" \ - "${{ parameters.runtimeEnv }}" \ - --trident-config-path "${{ parameters.tridentConfigPath }}/trident-config.yaml" \ - --deployment-environment "${{ parameters.deploymentEnvironment }}" - displayName: "๐Ÿ”ฌ Test Trident rebuild-raid" - timeoutInMinutes: 15 - - - ${{ if eq(parameters.deploymentEnvironment, 'virtualMachine') }}: - # Trigger a rollback and test encryption - - bash: | - set -eux - # If there is a netlisten process, kill it so there is no port clash in the instance - if pgrep netlisten > /dev/null; then pkill netlisten; fi - NETLISTEN_CONFIG_ARGS="" - if [ -n "${{ parameters.netlistenConfigFile }}" ]; then - NETLISTEN_CONFIG_ARGS="--config ${{ parameters.netlistenConfigFile }}" - fi - ./bin/netlisten --force-color $NETLISTEN_CONFIG_ARGS \ - -m $(Build.SourcesDirectory)/trident-rollback-metrics.jsonl \ - -p ${{ parameters.netlistenPort }} \ - -s "${{ parameters.artifactsDirectory }}" > ./rollback.log 2>&1 & - PROXY_ARG="" - if [ -n "${{ parameters.httpsProxy }}" ]; then - PROXY_ARG="--env-vars HTTPS_PROXY=${{ parameters.httpsProxy }}" - fi - # To allow storm to access VM serial logs, need sudo. - sudo $(Build.SourcesDirectory)/bin/storm-trident helper manual-rollback -a \ - --log-dir "$(ob_outputDirectory)" \ - "${{ parameters.sshKeyPath }}" \ - "${{ parameters.hostIp }}" \ - "${{ parameters.userName }}" \ - "${{ parameters.runtimeEnv }}" \ - $PROXY_ARG - displayName: "๐Ÿ”„ Stage and finalize manual rollback" - timeoutInMinutes: 5 - condition: and(succeeded(), ne(variables['abActiveVolume'], 'null')) - - - bash: | - set -eux - # sudo is needed to read qemu VM serial logs, `-a` is needed with sudo - sudo ./bin/storm-trident helper display-logs -a \ - --netlisten-config "${{ parameters.netlistenConfigFile }}" \ - --serial-log-artifact-file-name "rollback-target-os-${TARGET_OS}-serial.log" \ - --artifacts-folder "$(ob_outputDirectory)" - env: - ${{ if or(eq(parameters.buildPurpose, 'daily'), eq(parameters.buildPurpose, 'validation'), eq(parameters.buildPurpose, 'weekly'), eq(parameters.buildPurpose, 'post_merge')) }}: - TARGET_OS: "A" - ${{ else }}: - TARGET_OS: "B" - displayName: "๐Ÿ“„ Manual rollback deployment logs" - condition: and(succeededOrFailed(), ne(variables['abActiveVolume'], 'null')) - - - bash: | - set -eux - SPECIFIC_TESTS='base' - encryptionExists=$(sudo yq e '.storage.encryption != null' "${{ parameters.tridentConfigPath }}/trident-config.yaml") - if [ "$encryptionExists" == "true" ]; then - SPECIFIC_TESTS="$SPECIFIC_TESTS or encryption" - fi - python3 -u -m pytest \ - -m "$SPECIFIC_TESTS" \ - --capture=no \ - --junit-xml=${{ parameters.deploymentEnvironment }}_${{ parameters.runtimeEnv }}_${{ parameters.tridentConfigurationName }}_${{ parameters.jUnitXMLIdentifier }}_ab_rollback_${TARGET_OS}_$(System.JobAttempt).junit.xml \ - --host ${{ parameters.hostIp }} \ - --runtime-env ${{ parameters.runtimeEnv }} \ - --configuration ${{ parameters.tridentConfigPath }} \ - --ab-active-volume ${EXPECTED_VOLUME} - env: - ${{ if or(eq(parameters.buildPurpose, 'daily'), eq(parameters.buildPurpose, 'validation'), eq(parameters.buildPurpose, 'weekly'), eq(parameters.buildPurpose, 'post_merge')) }}: - EXPECTED_VOLUME: "volume-a" - TARGET_OS: "A" - ${{ else }}: - EXPECTED_VOLUME: "volume-b" - TARGET_OS: "B" - timeoutInMinutes: 5 - workingDirectory: $(Build.SourcesDirectory)/tests/e2e_tests - displayName: "๐Ÿ”ฌ Run Trident E2E tests after manual rollback" - condition: and(succeeded(), ne(variables['abActiveVolume'], 'null')) diff --git a/.pipelines/templates/stages/testing_e2e/storm_e2e.yml b/.pipelines/templates/stages/testing_e2e/storm_e2e.yml index 1f8388928..d7424349e 100644 --- a/.pipelines/templates/stages/testing_e2e/storm_e2e.yml +++ b/.pipelines/templates/stages/testing_e2e/storm_e2e.yml @@ -34,12 +34,18 @@ stages: name: matrices displayName: Matrix of Trident configurations for E2E Tests - # TODO: enable once storm tests are ready + # Storm E2E Tests: VM HOST + # All 18 VM host configurations are enabled and run via single storm-trident + # invocations. Each scenario handles setup, install, validation, A/B updates, + # metrics collection, and log publishing internally. - template: test_execution_template.yml parameters: hardwareType: "VM" runtimeType: "HOST" + # VM CONTAINER and BM configurations are not yet supported by storm-trident. + # BM requires BMC credential management and external provisioning (not implemented). + # Container runtime support is pending (see setup.go, invert.py). # - template: test_execution_template.yml # parameters: # hardwareType: "VM" diff --git a/.pipelines/templates/stages/testing_e2e/test_execution_template.yml b/.pipelines/templates/stages/testing_e2e/test_execution_template.yml index 1baa9eae2..015562733 100644 --- a/.pipelines/templates/stages/testing_e2e/test_execution_template.yml +++ b/.pipelines/templates/stages/testing_e2e/test_execution_template.yml @@ -110,7 +110,15 @@ stages: ./bin/storm-trident run "$(SCENARIO)" \ -o $(ob_outputDirectory) \ + -j $(ob_outputDirectory)/$(SCENARIO)_$(System.JobAttempt).junit.xml \ -- \ --pipeline-run \ --iso "./artifacts/iso/$(installerISOName).iso" displayName: "๐Ÿงช Run E2E Test" + + - template: ../junit/handle-junit-test-results.yml + parameters: + testRunName: "$(SCENARIO)_$(System.JobAttempt)" + junitTestFile: "$(ob_outputDirectory)/$(SCENARIO)_$(System.JobAttempt).junit.xml" + displayNameSpecifier: "$(SCENARIO)" + artifactName: "junit_$(SCENARIO)_$(System.JobAttempt)" diff --git a/.pipelines/templates/stages/testing_vm/netlaunch-testing.yml b/.pipelines/templates/stages/testing_vm/netlaunch-testing.yml index edf8afb60..254b2ebf1 100644 --- a/.pipelines/templates/stages/testing_vm/netlaunch-testing.yml +++ b/.pipelines/templates/stages/testing_vm/netlaunch-testing.yml @@ -321,18 +321,8 @@ stages: ${{ if ne(variables['Build.SourceBranchName'], 'main') }}: kustoTableName: dev - - template: ../testing_common/e2e-test-run.yml - parameters: - buildPurpose: ${{ parameters.buildPurpose }} - deploymentEnvironment: virtualMachine - tridentConfigurationName: $(tridentConfigurationName) - hostIp: $(jq -r '.virtualmachines[0].ip' $(tridentSourceDirectory)/tools/virt-deploy-metadata.json) - runtimeEnv: ${{ parameters.runtimeEnv }} - tridentConfigPath: $(tridentConfigPath) - sshKeyPath: $(tridentSourceDirectory)/tests/e2e_tests/helpers/key - userName: testing-user - artifactsDirectory: artifacts/test-image - netlistenPort: ${{variables.netlaunchPort}} + # E2E tests are now run via the storm_e2e stage using storm-trident. + # See .pipelines/templates/stages/testing_e2e/storm_e2e.yml - template: ../common_tasks/remove-from-acr.yml parameters: diff --git a/tests/e2e_tests/.gitignore b/tests/e2e_tests/.gitignore index d55489b31..f2b998761 100644 --- a/tests/e2e_tests/.gitignore +++ b/tests/e2e_tests/.gitignore @@ -1,5 +1,3 @@ -__pycache__ -.pytest_cache **/key # Json file generated with the explicit set of tests selected in test-selection.yaml diff --git a/tests/e2e_tests/ab_update_staged_test.py b/tests/e2e_tests/ab_update_staged_test.py deleted file mode 100644 index aa55ed621..000000000 --- a/tests/e2e_tests/ab_update_staged_test.py +++ /dev/null @@ -1,16 +0,0 @@ -import pytest - -from base_test import get_host_status - -pytestmark = [pytest.mark.ab_update_staged] - - -def test_ab_update_staged(connection, tridentCommand, abActiveVolume): - # Check Host Status - host_status = get_host_status(connection, tridentCommand) - - # Assert that servicing state is correct - assert host_status["servicingState"] == "ab-update-staged" - - # Assert that the active volume has not changed - assert host_status["abActiveVolume"] == abActiveVolume diff --git a/tests/e2e_tests/base_test.py b/tests/e2e_tests/base_test.py deleted file mode 100644 index 3e6094805..000000000 --- a/tests/e2e_tests/base_test.py +++ /dev/null @@ -1,509 +0,0 @@ -import fabric -import json -import math -import pytest -import re -import yaml -from enum import Enum - -pytestmark = [pytest.mark.base] - - -# Size units -class SizeUnit(Enum): - B = 1 - K = math.pow(1024, 1) - M = math.pow(1024, 2) - G = math.pow(1024, 3) - T = math.pow(1024, 4) - P = math.pow(1024, 5) - - -def test_connection(connection): - # Check ssh connection - result = connection.run("sudo echo 'Successful connection'") - output = result.stdout.strip() - - assert output == "Successful connection" - - -def test_partitions(connection, hostConfiguration, tridentCommand, abActiveVolume): - # Structure hostConfiguration information - expected_partitions = dict() - - for disk_elements in hostConfiguration["storage"]["disks"]: - for partition in disk_elements["partitions"]: - # Extract size in bytes - size_number = partition["size"][:-1] - unit = partition["size"][-1] if partition["size"][-1].isalpha() else "B" - size = float(size_number) * SizeUnit[unit].value - # Update the expected partitions dictionary - expected_partitions[partition["id"]] = partition - expected_partitions[partition["id"]]["size"] = size - - # Check partitions type - result = connection.run("sudo blkid") - # Expected output example: - # /dev/sr0: BLOCK_SIZE="2048" UUID="2023-12-16-00-55-13-99" LABEL="TRIDENT_CDROM" TYPE="iso9660" - # /dev/sda4: LABEL="3e9cecef-5a01-4" UUID="37a7b4fa-87f0-4887-895b-393f46c345a0" TYPE="swap" PARTLABEL="swap" PARTUUID="3e9cecef-5a01-43d6-a1ae-58bf24f42521" - # /dev/sda2: UUID="04267584-7e18-4612-a649-c71e1811bd82" BLOCK_SIZE="4096" TYPE="ext4" PARTLABEL="root-a" PARTUUID="f1be3a27-36e2-4d4b-b8ec-5b0b5909cbf9" - # /dev/sda5: LABEL="f3fd8061-ef42-4f" UUID="806ce1d1-44fb-4fb7-8f8d-6f2b21243984" BLOCK_SIZE="4096" TYPE="ext4" PARTLABEL="home" PARTUUID="f3fd8061-ef42-4fa9-8a9a-2903b0bcd1f8" - # /dev/sda1: SEC_TYPE="msdos" UUID="D920-8BA4" BLOCK_SIZE="512" TYPE="vfat" PARTLABEL="esp" PARTUUID="6fcc7c57-b21c-46e5-bc79-041c7fc53f34" - # /dev/sda6: LABEL="e87b4510-08b1-4f" UUID="0bb847ed-26cb-496a-b098-1714ca2082a9" BLOCK_SIZE="4096" TYPE="ext4" PARTLABEL="trident" PARTUUID="e87b4510-08b1-4f84-8049-40a69882779b" - # /dev/sda3: PARTLABEL="root-b" PARTUUID="573fdf4c-9133-4a9f-8cf5-aff7b74d1aeb" - - # Structure output - partitions_blkid = dict() - blkid_info = result.stdout.strip().splitlines() - - for partition in blkid_info: - partition_dict = dict() - # Extract partition's name (ex: sda1) - name_info = partition.split(": ") - name = name_info[0].split("/")[-1] - # By line structure output into a dictionary with the partition information - for info in name_info[1].split(): - field_value = info.split("=") - if len(field_value) == 2: - partition_dict[field_value[0]] = field_value[1].replace('"', "") - # Adding information to a dictionary for each partition - partitions_blkid[name] = partition_dict - - # Check partitions size - partitions_system_info = dict() - result = connection.run("lsblk -J -b") - lsblk_info = json.loads(result.stdout) - # Expected output example: - # { - # "blockdevices": [ - # { - # "name": "sda", - # "maj:min": "8:0", - # "rm": false, - # "size": "32G", - # "ro": false, - # "type": "disk", - # "mountpoints": [null], - # "children": [ - # { - # "name": "sda1", - # "maj:min": "8:1", - # "rm": false, - # "size": "1G", - # "ro": false, - # "type": "part", - # "mountpoints": ["/boot/efi"], - # }, - # { - # "name": "sda2", - # "maj:min": "8:2", - # "rm": false, - # "size": "8G", - # "ro": false, - # "type": "part", - # "mountpoints": ["/"], - # }, - # ], - # }, - # { - # "name": "sdb", - # "maj:min": "8:16", - # "rm": false, - # "size": "32G", - # "ro": false, - # "type": "disk", - # "mountpoints": [null], - # "children": [ - # { - # "name": "sdb1", - # "maj:min": "8:17", - # "rm": false, - # "size": "10M", - # "ro": false, - # "type": "part", - # "mountpoints": [null], - # } - # ], - # }, - # { - # "name": "sr0", - # "maj:min": "11:0", - # "rm": true, - # "size": "477.9M", - # "ro": false, - # "type": "rom", - # "mountpoints": [null], - # }, - # ] - # } - - # Gather all partitions from all disks, blockdevices with no children are partitions - lsblk_partitions = [ - partition - for block_device in lsblk_info["blockdevices"] - for partition in ( - block_device["children"] if "children" in block_device else [block_device] - ) - ] - - # Join lsblk and blkid information to compare with Host Configuration - for partition in lsblk_partitions: - # Update information - system_name = partition["name"] - if not system_name in partitions_blkid: - partitions_blkid[system_name] = dict() - partitions_blkid[system_name].update(partition) - # Add information to partitions_system_info which uses PARTLABEL as key - if "PARTLABEL" in partitions_blkid[system_name]: - partitions_system_info[partitions_blkid[system_name]["PARTLABEL"]] = ( - partitions_blkid[system_name] - ) - - # Check Host Status - host_status = get_host_status(connection, tridentCommand) - - # Check that servicing state is as expected - assert host_status["servicingState"] == "provisioned" - - # Check partitions size and type - for partition_id in expected_partitions: - # Partition present - assert partition_id in host_status["partitionPaths"] - assert partition_id in partitions_system_info - - # Fetch path of block device mounted at / - root_device_path_canonicalized = get_root_device_path_from_mount(connection) - - # Perform checks for A/B update only - if "abUpdate" in host_status["spec"]["storage"] and abActiveVolume is not None: - # Extract the ID of the mount point with path "/" - root_mount_id = None - # Look for it in filesystems - for fs in host_status["spec"]["storage"]["filesystems"]: - mp = fs.get("mountPoint") - if not mp: - continue - if mp["path"] == "/": - root_mount_id = fs.get("deviceId") - break - - # If no mount point with path / found, raise an exception - if root_mount_id is None: - raise Exception("Root mount point not found") - - print(f"Root mount point ID: {root_mount_id}") - - verity_device_name = None - verity_data_device_id = None - for verity_dev in host_status["spec"]["storage"].get("verity", []): - print("Inspecting verity device:", verity_dev) - if verity_dev.get("id") == root_mount_id: - print(f"Found verity device with matching ID '{root_mount_id}'") - verity_device_name = verity_dev.get("name") - verity_data_device_id = verity_dev.get("dataDeviceId") - break - - print(f"Verity device name: {verity_device_name}") - print(f"Verity data device ID: {verity_data_device_id}") - - # Find the ID of the AB volume pair. If verity_data_device_id is set, - # the root filesystem is on a verity device. This device MUST be on an A/B - # volume pair. The volume pair ID is the ID of the verity device. - # If verity_data_device_id is not set, the root filesystem is on a non-verity - # device. In this case, the ID of the AB volume pair is the device the filesystem is on. - ab_volume_id = ( - verity_data_device_id - if verity_data_device_id is not None - else root_mount_id - ) - - print(f"Root A/B volume ID: {ab_volume_id}") - - # Check the block device mounted at /. For verity devices, root and - # root-hash A/B volume pairs are tested in verity_test.py. In this - # test, we focus on configurations where abUpdate is enabled, ensuring - # that root is part of an A/B volume pair. This test identifies the - # active volume ID for the root mount point. - if verity_device_name is None: - active_volume_id = None - for volume_pair in host_status["spec"]["storage"]["abUpdate"][ - "volumePairs" - ]: - if volume_pair["id"] == ab_volume_id: - print(f"Found volume pair: {ab_volume_id}") - if abActiveVolume == "volume-a": - active_volume_id = volume_pair["volumeAId"] - else: - active_volume_id = volume_pair["volumeBId"] - print(f"Active volume ID: {active_volume_id}") - break - - assert active_volume_id is not None - - active_volume_is_partition = is_partition(host_status, active_volume_id) - active_volume_is_raid = is_raid(host_status, active_volume_id) - # active_volume_id should be either a partition or a software RAID array - assert (active_volume_is_partition and not active_volume_is_raid) or ( - not active_volume_is_partition and active_volume_is_raid - ) - - # 1. If active_volume_id is a partition, get full PARTUUID based on blkid output and create - # root_device_path, non-canonicalized - root_device_path = None - - if active_volume_is_partition: - for partition_name, partition_info in partitions_blkid.items(): - if partition_name == root_device_path_canonicalized.split("/")[-1]: - root_device_path = ( - f"/dev/disk/by-partuuid/{partition_info['PARTUUID']}" - ) - # 2. If active_volume_id is a software RAID array, run 'ls -l /dev/md' to fetch full name - # of RAID array mounted at root / - elif active_volume_is_raid: - root_device_path = get_raid_name_from_device_name( - connection, root_device_path_canonicalized - ) - - # Iterate through block devices and confirm that path of active volume corresponds to - # non-canonicalized root device path - for block_device_id, block_device_path in host_status[ - "partitionPaths" - ].items(): - if block_device_id == active_volume_id: - assert block_device_path == root_device_path - - # Verify abActiveVolume - assert host_status["abActiveVolume"] == abActiveVolume - - -# Returns true if block device with block_device_id is a partition; otherwise, returns false -def is_partition(host_status, block_device_id): - for disk in host_status["spec"]["storage"]["disks"]: - for partition in disk.get("partitions", []): - if partition["id"] == block_device_id: - return True - return False - - -# Returns true if block device with target_id is a software RAID array; otherwise, returns false -def is_raid(host_status, block_device_id): - for raid in host_status["spec"]["storage"].get("raid", {}).get("software", []): - if raid["id"] == block_device_id: - return True - return False - - -def get_host_status(connection: fabric.Connection, tridentCommand: str) -> dict: - """ - Get the Host Status by running `trident get` on the given connection, - and return the parsed YAML output. - """ - - cmd = f"{tridentCommand} get" - result = connection.run(cmd) - - # Structure output - output = result.stdout.strip() - - yaml.add_multi_constructor( - "!", lambda loader, _, node: loader.construct_mapping(node) - ) - return yaml.load(output, Loader=yaml.FullLoader) - - -# Runs 'mount' and returns the name of the block device mounted at root / -def get_root_device_path_from_mount(connection): - # Expected output example: - # /dev/sda3 on / type ext4 (rw,relatime) - # devtmpfs on /dev type devtmpfs (rw,nosuid,size=4096k,nr_inodes=721913,mode=755) - # tmpfs on /dev/shm type tmpfs (rw,nosuid,nodev) - # devpts on /dev/pts type devpts (rw,nosuid,noexec,relatime,gid=5,mode=620,ptmxmode=000) - # sysfs on /sys type sysfs (rw,nosuid,nodev,noexec,relatime) - # securityfs on /sys/kernel/security type securityfs (rw,nosuid,nodev,noexec,relatime) - # tmpfs on /sys/fs/cgroup type tmpfs (ro,nosuid,nodev,noexec,size=4096k,nr_inodes=1024,mode=755) - # cgroup on /sys/fs/cgroup/systemd type cgroup (rw,nosuid,nodev,noexec,relatime,xattr,release_agent=/usr/lib/systemd/systemd-cgroups-agent,name=systemd) - # cgroup on /sys/fs/cgroup/freezer type cgroup (rw,nosuid,nodev,noexec,relatime,freezer) - # cgroup on /sys/fs/cgroup/net_cls,net_prio type cgroup (rw,nosuid,nodev,noexec,relatime,net_cls,net_prio) - # cgroup on /sys/fs/cgroup/hugetlb type cgroup (rw,nosuid,nodev,noexec,relatime,hugetlb) - # cgroup on /sys/fs/cgroup/cpu,cpuacct type cgroup (rw,nosuid,nodev,noexec,relatime,cpu,cpuacct) - # cgroup on /sys/fs/cgroup/memory type cgroup (rw,nosuid,nodev,noexec,relatime,memory) - # cgroup on /sys/fs/cgroup/perf_event type cgroup (rw,nosuid,nodev,noexec,relatime,perf_event) - # cgroup on /sys/fs/cgroup/cpuset type cgroup (rw,nosuid,nodev,noexec,relatime,cpuset) - # cgroup on /sys/fs/cgroup/devices type cgroup (rw,nosuid,nodev,noexec,relatime,devices) - # cgroup on /sys/fs/cgroup/blkio type cgroup (rw,nosuid,nodev,noexec,relatime,blkio) - # cgroup on /sys/fs/cgroup/pids type cgroup (rw,nosuid,nodev,noexec,relatime,pids) - # cgroup on /sys/fs/cgroup/rdma type cgroup (rw,nosuid,nodev,noexec,relatime,rdma) - # pstore on /sys/fs/pstore type pstore (rw,nosuid,nodev,noexec,relatime) - # efivarfs on /sys/firmware/efi/efivars type efivarfs (rw,nosuid,nodev,noexec,relatime) - # bpf on /sys/fs/bpf type bpf (rw,nosuid,nodev,noexec,relatime,mode=700) - # proc on /proc type proc (rw,nosuid,nodev,noexec,relatime) - # tmpfs on /run type tmpfs (rw,nosuid,nodev,size=1159040k,nr_inodes=819200,mode=755) - # systemd-1 on /proc/sys/fs/binfmt_misc type autofs (rw,relatime,fd=27,pgrp=1,timeout=0,minproto=5,maxproto=5,direct,pipe_ino=17284) - # hugetlbfs on /dev/hugepages type hugetlbfs (rw,nosuid,nodev,relatime,pagesize=2M) - # mqueue on /dev/mqueue type mqueue (rw,nosuid,nodev,noexec,relatime) - # debugfs on /sys/kernel/debug type debugfs (rw,nosuid,nodev,noexec,relatime) - # tracefs on /sys/kernel/tracing type tracefs (rw,nosuid,nodev,noexec,relatime) - # fusectl on /sys/fs/fuse/connections type fusectl (rw,nosuid,nodev,noexec,relatime) - # configfs on /sys/kernel/config type configfs (rw,nosuid,nodev,noexec,relatime) - # tmpfs on /tmp type tmpfs (rw,nosuid,nodev,size=2897596k,nr_inodes=1048576) - # /dev/sda5 on /home type ext4 (rw,relatime) - # /dev/sda1 on /boot/efi type vfat (rw,relatime,fmask=0077,dmask=0077,codepage=437,iocharset=ascii,shortname=mixed,errors=remount-ro) - # /dev/sda6 on /var/lib/trident type ext4 (rw,relatime) - # tmpfs on /run/user/1001 type tmpfs (rw,nosuid,nodev,relatime,size=579516k,nr_inodes=144879,mode=700,uid=1001,gid=1001) - try: - mount_result = connection.run("mount") - mount_info = mount_result.stdout.strip().splitlines() - - partitions_mount_info = dict() - for line in mount_info: - # Assuming the format is 'device on mount_point type fs_type (options)' - parts = line.split() - if len(parts) >= 3: - device_name = parts[0] - mount_point = parts[2] - fs_type = parts[4] if len(parts) > 4 else "unknown" - partitions_mount_info[device_name] = { - "mount_point": mount_point, - "fs_type": fs_type, - } - - # Find name of block device mounted at root / - for device_name, info in partitions_mount_info.items(): - if info["mount_point"] == "/": - return device_name - except Exception as e: - print(f"An error occurred: {e}") - return None - - return None - - -# Runs 'ls -l /dev/md' and returns the name of RAID array that corresponds to device_name. E.g. if -# device_name is /dev/md127 then func returns /dev/md/root-a. -def get_raid_name_from_device_name(connection, device_name): - # Expected output example: - # lrwxrwxrwx 1 root root 8 Apr 1 22:42 home -> ../md124 - # lrwxrwxrwx 1 root root 8 Apr 1 22:42 root-a -> ../md127 - # lrwxrwxrwx 1 root root 8 Apr 1 22:42 root-b -> ../md125 - # lrwxrwxrwx 1 root root 8 Apr 1 22:42 trident -> ../md126 - try: - md_device_number = ( - device_name.split("/")[-1] if "/" in device_name else device_name - ) - - # Execute command to get RAID names and corresponding devices - command_output = connection.run("ls -l /dev/md || true", warn=True) - raid_output = command_output.stdout.strip().splitlines() - - # If there is no output, return None - if not raid_output or "No such file or directory" in command_output.stderr: - print("'/dev/md' directory does not exist or is empty") - return None - - for line in raid_output: - if md_device_number in line: - # Extract the RAID name - match = re.search( - r"(\S+)\s+-> \.\./" + re.escape(md_device_number), line - ) - if match: - return f"/dev/md/{match.group(1)}" - - return None - - except Exception as e: - print(f"An error occurred: {e}") - return None - - -def test_users(connection, hostConfiguration): - # Structure hostConfiguration information - expected_users = list() - expected_groups = dict() - - for user_info in hostConfiguration["os"]["users"]: - expected_users.append(user_info["name"]) - if "groups" in user_info: - for group in user_info["groups"]: - if not group in expected_groups: - expected_groups[group] = [user_info["name"]] - else: - expected_groups[group].append(user_info["name"]) - - # Check users - result = connection.run("cat /etc/passwd") - # Expected output example: - # root:x:0:0:root:/root:/bin/bash - # bin:x:1:1:bin:/dev/null:/bin/false - # daemon:x:6:6:Daemon User:/dev/null:/bin/false - # messagebus:x:18:18:D-Bus Message Daemon User:/var/run/dbus:/bin/false - # testing-user:x:1001:1001::/home/testing-user:/bin/bash - - # Structure output - users_system = set() - users_info = result.stdout.strip().splitlines() - - for user_info in users_info: - users_system.add(user_info.split(":")[0]) - - for user in expected_users: - assert user in users_system - - # Check groups - result = connection.run("cat /etc/group ") - # Expected output example: - # root:x:0: - # bin:x:1:daemon - # sys:x:2: - # kmem:x:3: - # tape:x:4: - # tty:x:5: - - # Structure output - users_by_group = dict() - groups_info = result.stdout.strip().splitlines() - - for group_info in groups_info: - group_info_elements = group_info.split(":") - users_by_group[group_info_elements[0]] = set(group_info_elements[-1].split(",")) - - for group in expected_groups: - assert group in users_by_group - for user in expected_groups[group]: - assert user in users_by_group[group] - - -def test_uefi_fallback(connection, hostConfiguration): - mode = "conservative" # Default mode if not set - if "os" in hostConfiguration and "uefiFallback" in hostConfiguration["os"]: - mode = hostConfiguration["os"]["uefiFallback"] - - if mode not in ["disabled", "conservative", "optimistic"]: - raise Exception(f"Unknown uefiFallback mode: {mode}") - - if mode == "disabled": - # Check that /efi/boot/EFI/BOOT is empty - connection.run("sudo find /efi/boot/EFI/BOOT/* && exit 1 || exit 0") - return - - # Check that /efi/boot/EFI/BOOT/* is same as /efi/azl/EFI//* - result = connection.run("sudo efibootmgr") - efi_output = result.stdout.strip().splitlines() - - current_boot_entry = "" - for line in efi_output: - if "BootCurrent" in line: - current_boot_entry = line.split(":")[1].strip() - break - assert current_boot_entry != "" - - current_boot_name = "" - for line in efi_output: - if f"Boot{current_boot_entry}" in line: - current_boot_name = line.split()[1] - break - assert current_boot_name != "" - - connection.run( - f"sudo diff /efi/boot/EFI/BOOT/* /efi/azl/EFI/{current_boot_name}/* && exit 1 || exit 0" - ) diff --git a/tests/e2e_tests/conftest.py b/tests/e2e_tests/conftest.py deleted file mode 100644 index c0df2586c..000000000 --- a/tests/e2e_tests/conftest.py +++ /dev/null @@ -1,231 +0,0 @@ -from fabric import Connection, Config -import json -import os -import pytest -import yaml - -# A key in the following path and the user name in the hostConfiguration are expected -file_directory_path = os.path.dirname(os.path.realpath(__file__)) -key_path = os.path.join(file_directory_path, "helpers/key") -USERNAME = "testing-user" -TRIDENT_EXECUTABLE_PATH = "/usr/bin/trident" -# Expected location of Docker image: -DOCKER_IMAGE_PATH = "/var/lib/trident/trident-container.tar.gz" -EXECUTE_TRIDENT_CONTAINER = ( - "docker run --pull=never --rm --privileged " - "-v /etc/trident:/etc/trident -v /var/lib/trident:/var/lib/trident " - "-v /:/host -v /dev:/dev -v /run:/run -v /sys:/sys -v /var/log:/var/log " - "--pid host --ipc host trident/trident:latest" -) - - -def pytest_addoption(parser): - parser.addoption( - "-H", - "--host", - action="store", - type=str, - required=True, - help="Specify the IP address or hostname of the target machine.", - ) - parser.addoption( - "-C", - "--configuration", - action="store", - type=str, - required=True, - help="Provide the path to the directory with the Host Configuration and compatible tests.", - ) - parser.addoption( - "-A", - "--ab-active-volume", - action="store", - type=str, - default="volume-a", - help="Active A/B volume on the host.", - ) - parser.addoption( - "-K", - "--keypath", - action="store", - type=str, - default=key_path, - help="Path to the rsa key needed for SSH connection, default path to ./keys/key.", - ) - parser.addoption( - "-R", - "--runtime-env", - action="store", - type=str, - choices=["host", "container"], - default="host", - help="Runtime environment for trident: 'host' or 'container'. Default is 'host'.", - ) - parser.addoption( - "-S", - "--expected-host-status-state", - action="store", - type=str, - default="provisioned", - help="Expected host status state.", - ) - - -@pytest.fixture(scope="session") -def connection(request): - host = request.config.getoption("--host") - rsa_key = os.path.expanduser(request.config.getoption("--keypath")) - runtime_env = request.config.getoption("--runtime-env") - - config = Config(overrides={"connect_kwargs": {"key_filename": rsa_key}}) - ssh_connection = Connection(host=host, user=USERNAME, config=config) - - # Ensure that we can connect - ssh_connection.open() - ssh_connection.run("hostname") - - if runtime_env == "container": - getenforce_result = ssh_connection.run("sudo getenforce") - # The getenforce command returns Enforcing, Permissive, or Disabled ... - # disable if selinux is not already. - if not "Disabled" in getenforce_result.stdout: - # Disable SELinux - disable_selinux_enforcement_command = "setenforce 0" - ssh_connection.run(f"sudo {disable_selinux_enforcement_command}") - # Load Docker Image - load_container = f"docker load --input {DOCKER_IMAGE_PATH}" - ssh_connection.run(f"sudo {load_container}") - - yield ssh_connection - ssh_connection.close() - - -@pytest.fixture -def tridentCommand(request): - runtime_env = request.config.getoption("--runtime-env") - - trident_command = ( - f"sudo {EXECUTE_TRIDENT_CONTAINER} " - if runtime_env == "container" - else f"sudo {TRIDENT_EXECUTABLE_PATH} " - ) - - return trident_command - - -@pytest.fixture -def hostConfiguration(request): - file_path = request.config.getoption("--configuration") - tridentconfig_path = os.path.join(file_path, "trident-config.yaml") - with open(tridentconfig_path, "r") as stream: - try: - trident_Configuration = yaml.safe_load(stream) - except yaml.YAMLError as exc: - print(exc) - return {} - - return trident_Configuration - - -@pytest.fixture -def isUki(request): - file_path = request.config.getoption("--configuration") - testselection_path = os.path.join(file_path, "test-selection.yaml") - with open(testselection_path, "r") as stream: - try: - test_Selection = yaml.safe_load(stream) - except yaml.YAMLError as exc: - print(exc) - return {} - - return "uki" in test_Selection.get("compatible", []) - - -@pytest.fixture -def abActiveVolume(request): - return request.config.getoption("--ab-active-volume") - - -@pytest.fixture -def expectedHostStatusState(request): - return request.config.getoption("--expected-host-status-state") - - -def define_tests(file_path): - with open(file_path, "r") as stream: - try: - test_markers = yaml.safe_load(stream) - except yaml.YAMLError as exc: - print(exc) - return - - # Define tests: - special_markers = [ - "compatible", - "weekly", - "daily", - "post_merge", - "pullrequest", - "validation", - ] # The order matters here; each element depends on the previous ones. - actions = ["add", "remove"] - types = ["modules", "markers"] - # Structure: - tests_selected = { - special_marker: {action: {tp: set() for tp in types} for action in actions} - for special_marker in special_markers - } - # Add information - test_markers["compatible"] = {"add": test_markers.get("compatible", list())} - for test_marker, test_marker_value in test_markers.items(): - for action, action_value in test_marker_value.items(): - for element in action_value: - if "::" in element: - tests_selected[test_marker][action]["modules"].add(element) - else: - tests_selected[test_marker][action]["markers"].add(element) - return tests_selected - - -def pytest_collection_modifyitems(config, items): - configuration_path = config.getoption("--configuration") - tests_path = os.path.join(configuration_path, "test-selection.yaml") - test_markers = define_tests(tests_path) - - # Add special markers to functions (tests) - modules_per_marker = {marker: set() for marker in test_markers} - current_definition = set() - for marker_name, marker_tests in test_markers.items(): - special_marker = getattr(pytest.mark, marker_name) - for item in items: - item_markers = set(item.keywords) - if item.nodeid in marker_tests["add"]["modules"]: - item.add_marker(special_marker) - modules_per_marker[marker_name].add(item.nodeid) - continue - if item.nodeid in marker_tests["remove"]["modules"]: - continue - if item_markers & marker_tests["remove"]["markers"]: - continue - if item_markers & marker_tests["add"]["markers"]: - item.add_marker(special_marker) - modules_per_marker[marker_name].add(item.nodeid) - continue - if item.nodeid in current_definition: - item.add_marker(special_marker) - modules_per_marker[marker_name].add(item.nodeid) - continue - current_definition = modules_per_marker[marker_name] - - # Save the tests selected by each special marker - marker_file_test = dict() - for marker, modules in modules_per_marker.items(): - marker_file_test[marker] = dict() - for module in modules: - module_file, module_function = module.split("::", 1) - if not module_file in marker_file_test[marker]: - marker_file_test[marker][module_file] = list() - marker_file_test[marker][module_file].append(module_function) - mft_file = os.path.join(configuration_path, "ts.json") - with open(mft_file, "w") as ts: - json.dump(marker_file_test, ts, indent=4) diff --git a/tests/e2e_tests/encryption_test.py b/tests/e2e_tests/encryption_test.py deleted file mode 100644 index 2a14d4f70..000000000 --- a/tests/e2e_tests/encryption_test.py +++ /dev/null @@ -1,731 +0,0 @@ -import json -import typing -import fabric -import pytest - -from base_test import get_host_status - -pytestmark = [pytest.mark.encryption] - - -def get_filesystem(hostConfiguration: dict, fsId: str) -> typing.Optional[dict]: - """ - Get the filesystem for the given filesystem ID in the Trident - configuration, or None if no such filesystem exists. - """ - - for fs in hostConfiguration["storage"]["filesystems"]: - if fs["deviceId"] == fsId: - return fs - - return None - - -def get_swap(hostConfiguration: dict, devId: str) -> typing.Optional[dict]: - """Gets the swap device associated with the provided device id, if any.""" - - for swap in hostConfiguration["storage"].get("swap", []): - if isinstance(swap, str) and swap == devId: - return {"deviceId": devId} - elif isinstance(swap, dict) and swap["deviceId"] == devId: - return swap - - return None - - -def get_active_swaps(connection: fabric.Connection) -> typing.Set[str]: - active = sudo( - connection, - "swapon --show=NAME --raw --bytes --noheadings | xargs -I @ readlink -f @", - ) - - return set(active.splitlines()) - - -def get_child_ab_update_volume_pair( - hostConfiguration: dict, cryptId: str -) -> typing.Tuple[typing.Optional[dict], bool]: - if "abUpdate" not in hostConfiguration["storage"]: - return None, False - - for abUpdateVolumePair in hostConfiguration["storage"]["abUpdate"]["volumePairs"]: - if abUpdateVolumePair["volumeAId"] == cryptId: - return abUpdateVolumePair, True - - if abUpdateVolumePair["volumeBId"] == cryptId: - return abUpdateVolumePair, False - - return None, False - - -def get_raid_software_array_name( - hostConfiguration: dict, aId: str -) -> typing.Optional[str]: - """ - Get the name of the RAID software array with the given ID in the - Trident configuration, or None if no such array exists. - """ - - for a in hostConfiguration["storage"]["raid"]["software"]: - if a["id"] == aId: - return a["name"] - - return None - - -def get_disk_partition(hostConfiguration: dict, pId: str) -> typing.Optional[dict]: - """ - Check if a disk partition with the given ID exists in the Trident - configuration. - """ - - for d in hostConfiguration["storage"]["disks"]: - for p in d["partitions"]: - if p["id"] == pId: - return p - - return None - - -def read_dict_from_lines(lines: list[str]) -> dict: - """ - Read a dictionary from a list of lines in the format "key: value". - """ - - d = {} - for line in lines: - k, v = line.split(":", 1) - d[k.strip()] = v.strip() - - return d - - -def read_table_from_stdout(stdout: str) -> list[dict]: - """ - Read a table from the given stdout string. The first line is expected - to contain the column headers, and the following lines are expected to - contain the rows. The columns are separated by whitespace. - """ - - lines = stdout.splitlines() - header = [c.strip() for c in lines[0].split()] - rows = [[c.strip() for c in line.split()] for line in lines[1:]] - return [dict(zip(header, r)) for r in rows] - - -def sudo(connection: fabric.Connection, cmd: str) -> str: - """ - Run the given command with sudo on the given connection and return the - stripped stdout. - """ - res = connection.run(f"sudo {cmd}") - return res.stdout.strip() - - -def get_blkid_output(connection: fabric.Connection) -> dict: - """ - Get the output of `blkid --output export` and return a dictionary - mapping device names to their properties. - - Example output: - - # blkid --output export - DEVNAME=/dev/md127 - UUID=475f0351-4bb7-49bb-b9af-1f53f94b91cb - TYPE=crypto_LUKS - - DEVNAME=/dev/sr0 - BLOCK_SIZE=2048 - UUID=2024-10-30-22-05-47-00 - LABEL=CDROM - TYPE=iso9660 - ... - """ - cmd = "blkid --output export" - stdout = sudo(connection, cmd) - - devs: dict[str, dict] = {} - name = None - for line in stdout.splitlines(): - if line == "": - continue - - k, v = line.split("=", 1) - if k == "DEVNAME": - name = v - devs[name] = {} - elif name is not None: - devs[name][k] = v - else: - raise ValueError(f"Unexpected line: {line}") - - return devs - - -def check_exists(connection: fabric.Connection, path: str) -> None: - """ - Check if the given path exists by running `ls` on it. - """ - cmd = f"ls {path}" - _ = sudo(connection, cmd) - - -def check_cryptsetup_status( - connection: fabric.Connection, name: str, isInUse: bool -) -> dict: - """ - Check the output of `cryptsetup status` for the given device name. - - Example output: - - # cryptsetup status web - /dev/mapper/web is active and is in use. - type: n/a - cipher: aes-xts-plain64 - keysize: 512 bits - key location: keyring - device: /dev/md127 - sector size: 512 - offset: 16384 sectors - size: 2080640 sectors - mode: read/write - """ - - cmd = f"cryptsetup status {name}" - stdout = sudo(connection, cmd) - lines = stdout.splitlines() - - # LUKS2-encrypted volumes are always opened and therefore always - # active according to cryptsetup. When a volume is a member of an AB - # update pair, but is inactive, it won't be mounted, and so cryptsetup - # will not report it as being used. - if isInUse: - expected_first_line = f"/dev/mapper/{name} is active and is in use." - assert ( - lines[0] == expected_first_line - ), f"Expected first line to be {expected_first_line!r}, got {lines[0]!r}" - else: - expected_first_line = f"/dev/mapper/{name} is active." - assert ( - lines[0] == expected_first_line - ), f"Expected first line to be {expected_first_line!r}, got {lines[0]!r}" - - status = read_dict_from_lines(lines[1:]) - - expected_cipher = "aes-xts-plain64" - assert ( - status["cipher"] == expected_cipher - ), f"Expected cipher to be {expected_cipher!r}, got {status['cipher']!r}" - - expected_keysize = "512 bits" - assert ( - status["keysize"] == expected_keysize - ), f"Expected keysize to be {expected_keysize!r}, got {status['keysize']!r}" - - return status - - -def check_dmsetup_info(connection: fabric.Connection, name: str, swap: bool) -> None: - """ - Check the output of `dmsetup info` for the given device name. - - Example output: - - # dmsetup info /dev/mapper/web - Name: web - State: ACTIVE - Read Ahead: 256 - Tables present: LIVE - Open count: 0 - Event number: 0 - Major, minor: 254, 0 - Number of targets: 1 - UUID: CRYPT-LUKS2-475f03514bb749bbb9af1f53f94b91cb-web - """ - cmd = f"dmsetup info {name}" - stdout = sudo(connection, cmd) - info = read_dict_from_lines(stdout.splitlines()) - - assert "Name" in info, f"Expected Name to be in {info!r}" - assert info["Name"] == name, f"Expected Name to be {name!r}, got {info['Name']!r}" - - expected_state = "ACTIVE" - assert ( - info["State"] == expected_state - ), f"Expected State to be {expected_state!r}, got {info['State']!r}" - - expected_tables_present = "LIVE" - assert ( - info["Tables present"] == expected_tables_present - ), f"Expected Tables present to be {expected_tables_present!r}, got {info['Tables present']!r}" - - crypt_kind = "PLAIN" if swap else "LUKS2" - expected_uuid_prefix = f"CRYPT-{crypt_kind}-" - assert info["UUID"].startswith( - expected_uuid_prefix - ), f"Expected UUID to start with {expected_uuid_prefix!r}, got {info['UUID']!r}" - - expected_uuid_suffix = f"-{name}" - assert info["UUID"].endswith( - f"-{name}" - ), f"Expected UUID to end with {expected_uuid_suffix!r}, got {info['UUID']!r}" - - -def check_findmnt( - connection: fabric.Connection, target: str, source: str, isActive: bool -) -> None: - """ - Check the output of `findmnt` for the given path and encrypted device. - - Example output: - - # findmnt /mnt/web - TARGET SOURCE FSTYPE OPTIONS - /mnt/web /dev/mapper/web ext4 rw,relatime - """ - cmd = f"findmnt {target}" - stdout = sudo(connection, cmd) - table = read_table_from_stdout(stdout) - - assert ( - table[0]["TARGET"] == target - ), f"Expected TARGET to be {target!r}, got {table[0]['TARGET']!r}" - - expected_fstype = "ext4" - - if isActive: - assert ( - table[0]["SOURCE"] == source - ), f"Expected SOURCE to be {source!r} when it is active, got {table[0]['SOURCE']!r}" - assert ( - table[0]["FSTYPE"] == expected_fstype - ), f"Expected FSTYPE to be {expected_fstype!r} when {source!r} is active, got {table[0]['FSTYPE']!r}" - else: - assert ( - table[0]["SOURCE"] != source - ), f"Expected SOURCE to be different from {source!r} when it is not active." - assert ( - table[0]["FSTYPE"] == expected_fstype - ), f"Expected FSTYPE to be {expected_fstype!r} even when {source!r} is not active, got {table[0]['FSTYPE']!r}" - - assert len(table) == 1, f"Expected one row, got {len(table)}. Table: {table}" - - -def get_block_dev_path_by_partlabel( - blockDevs: dict, label: str -) -> typing.Optional[str]: - """ - Get the device path for the device with the given PARTLABEL, or None - if no such device exists. - """ - - for devId, dev in blockDevs.items(): - if "PARTLABEL" in dev and dev["PARTLABEL"] == label: - return devId - - return None - - -def check_crypsetup_luks_dump( - connection: fabric.Connection, - tridentCommand: str, - cryptDevPath: str, - isUki: bool, -) -> None: - """ - Check the output of `cryptsetup luksDump --dump-json-metadata` for the - given device path. The output will differ depending on whether the - encryption is based on a pcrlock policy or not. - - Example output for a testing flow using a UKI target OS image, where a - pcrlock policy is used: - - { - "keyslots":{ - "1":{ - "type":"luks2", - "key_size":64, - "af":{ - "type":"luks1", - "stripes":4000, - "hash":"sha512" - }, - "area":{ - "type":"raw", - "offset":"290816", - "size":"258048", - "encryption":"aes-xts-plain64", - "key_size":64 - }, - "kdf":{ - "type":"pbkdf2", - "hash":"sha512", - "iterations":1000, - "salt":"FHJf95bq+nk/WkCCCOIyPDwLbzpwkkiTgs2vjFZgLU0=" - } - } - }, - "tokens":{ - "0":{ - "type":"systemd-tpm2", - "keyslots":[ - "1" - ], - "tpm2-blob":"AJ4AIOxkvkN7ubF8IL0kItGHh411aCZdcha75buXgoErsv7XABC6LxxewHtkhfHuoZKCtWza4dBEAcfsAGJPCQfEsbBQKM4jTa5DfK2hhKw8IAw0diThe5e1zuXwtq1CLrglQV9G/rylRh3R4O8E0obRBMCk8925+FEtguQNIghRGDKQG1T+mU8UxKz/dWC1kekW861ynqZ/Qqwg+6KVowBOAAgACwAAABIAIBT0BjJdNmRaCrVDJcOjeJKAt9hhmGBJXDGstAMoyuyOABAAIFFuPmr9Q4dSL20RBcldk4EWqztfr1rFSwR7W6vC6uCC", - "tpm2-pcrs":[], - "tpm2-policy-hash":"14f406325d36645a0ab54325c3a3789280b7d8619860495c31acb40328caec8e", - "tpm2-pin":false, - "tpm2_pcrlock":true, - "tpm2_srk":"gQAAAQAiAAv5sLge1GM24g8z8nGVwj63AzM3lF2hByQxbh7A0TfsJwAAAAEAWgAjAAsAAwRyAAAABgCAAEMAEAADABAAIFsbfkcs48O3R29GrqlI9KOrqZPkoXQQb6WcYwwN4NibACDM5iwqa8lnLk89PJ10t0O6cpBaKn3nEayvLDm/8KVV8w==" - } - }, - "segments":{ - "0":{ - "type":"crypt", - "offset":"16777216", - "size":"dynamic", - "iv_tweak":"0", - "encryption":"aes-xts-plain64", - "sector_size":512 - } - }, - "digests":{ - "0":{ - "type":"pbkdf2", - "keyslots":[ - "1" - ], - "segments":[ - "0" - ], - "hash":"sha512", - "iterations":161022, - "salt":"oIpU6dVsGn3ulUz39RNpRpxgZ2TejXH55h+RP9VtO40=", - "digest":"oGaQSK3jH+mGsEVDHfbks1Xkqk6OpZkK3fo428wSLInHnJ3sLPz58CESLue6g9nE795eCvbuyWPih6AGs3jVNA==" - } - }, - "config":{ - "json_size":"12288", - "keyslots_size":"16744448" - } - } - - Example output for a grub target OS image, where pcrlock policy is NOT - used, and instead, the volume is enrolled to the value of PCR 7: - - { - "keyslots":{ - "1":{ - "type":"luks2", - "key_size":64, - "af":{ - "type":"luks1", - "stripes":4000, - "hash":"sha512" - }, - "area":{ - "type":"raw", - "offset":"290816", - "size":"258048", - "encryption":"aes-xts-plain64", - "key_size":64 - }, - "kdf":{ - "type":"pbkdf2", - "hash":"sha512", - "iterations":1000, - "salt":"V78EvaQMSroXSvwmIlaQ7QgJEAYdmykbr/U580bibK4=" - } - } - }, - "tokens":{ - "0":{ - "type":"systemd-tpm2", - "keyslots":[ - "1" - ], - "tpm2-blob":"AJ4AIA6FIxPCpzLJIrPYM+xkjHd01LZAQQjcoiK3fWJNy0zHABBErLSo75LGafmcHEIOhx7PtNoO3x4hW86gT0Jkf1drvjnULqHQBV13iJnDz1w+lbK+GnfumBntmj12LLeUIr/6SAVCU+KNu/owZCyOl1+p1eLFNt9LwpXQLCUa4rfPYUsLHYG/TcNc9kzQcpw49TEFRJwADQUiZlD1XgBOAAgACwAAABIAINyyj1eDDYXFHzMdKs/hxGS6hMdir2JqwbiulxUWj5RIABAAIJaw5Gip7m2PDETPSC/HOEZGosLuCpDGQ6sms6RwVdGh", - "tpm2-pcrs":[], - "tpm2-policy-hash":"dcb28f57830d85c51f331d2acfe1c464ba84c762af626ac1b8ae9715168f9448", - "tpm2-pin":false, - "tpm2_pcrlock":true, - "tpm2_srk":"gQAAAQAiAAuRnxBWvxRchDQNQyi/ryIVqTKLSmwcmfXCqzpmf3Ls7QAAAAEAWgAjAAsAAwRyAAAABgCAAEMAEAADABAAILIu6HvU3U/n+AclA9T/nOQ8gVGaNIgAGWScI5CThurRACCEWbjxEE50DKczUwuOXAd0/iCEid83UE10zB6ncOzYJA==" - } - }, - "segments":{ - "0":{ - "type":"crypt", - "offset":"16777216", - "size":"dynamic", - "iv_tweak":"0", - "encryption":"aes-xts-plain64", - "sector_size":512 - } - }, - "digests":{ - "0":{ - "type":"pbkdf2", - "keyslots":[ - "1" - ], - "segments":[ - "0" - ], - "hash":"sha512", - "iterations":158875, - "salt":"OsbDAAnbzyWuugQsSF1E+EphOH/Oxw+IhsPd7rw7dFA=", - "digest":"gVqfej2XffVQR3FEMgSA19WZgKtcfETrfAThRlao86TdjaU/vUyGRoMrshL8zEULAwSORd9qiuZ2gPPN4fu1XA==" - } - }, - "config":{ - "json_size":"12288", - "keyslots_size":"16744448" - } - } - - """ - # Running this command requires additional SELinux permission for lvm_t: - # allow lvm_t initrc_runtime_t:dir { read }. - # This is a quirk of the testing infra, and this perm shouldn't be part of - # the Trident policy. So, temporarily switch to Permissive mode. - enforcing = sudo(connection, "getenforce").strip() == "Enforcing" - if enforcing: - sudo(connection, "setenforce 0") - - stdout = sudo( - connection, f"cryptsetup luksDump --dump-json-metadata {cryptDevPath}" - ) - dump = json.loads(stdout) - - # Revert to Enforcing mode - if enforcing: - sudo(connection, "setenforce 1") - - # Validate type of digest to be pbkdf2 - actual = dump["digests"]["0"]["type"] - expected = "pbkdf2" - assert ( - actual == expected - ), f"Expected digest type to be {expected!r}, got {actual!r}" - - # Validate hash type to be sha512 - actual = dump["digests"]["0"]["hash"] - expected = "sha512" - assert ( - actual == expected - ), f"Expected digest hash to be {expected!r}, got {actual!r}" - - # Check Host Status to see if image is UKI or not - host_status = get_host_status(connection, tridentCommand) - - # For both UKI and grub target OS images, we expect to see a single token 1 - assert ( - "0" in dump["tokens"] - ), f"Expected token 0 to be in {dump['tokens']!r}, got {dump['tokens']!r}" - assert ( - "1" in dump["tokens"]["0"]["keyslots"] - ), f"Expected key slot 1 to be in {dump['tokens']['0']['keyslots']!r}, got {dump['tokens']['0']['keyslots']!r}" - assert ( - len(dump["tokens"]) == 1 - ), f"Expected one token, got {len(dump['tokens'])}. Tokens: {dump['tokens']}" - assert ( - len(dump["tokens"]["0"]["keyslots"]) == 1 - ), f"Expected one key slot for the token, got {len(dump['tokens']['0']['keyslots'])}. Key slots: {dump['tokens']['0']['keyslots']}" - - # Validate token type to be systemd-tpm2 - actual = dump["tokens"]["0"]["type"] - expected = "systemd-tpm2" - assert actual == expected, f"Expected token type to be {expected!r}, got {actual!r}" - - # Validate that for UKI images, tpm2_pcrlock is true and tpm2-pcrs is an - # empty vector, while for non-UKI images, tpm2_pcrlock is false and - # tpm2-pcrs is a vector with PCR 7. - if isUki: - assert ( - dump["tokens"]["0"]["tpm2_pcrlock"] is True - ), f"Expected tpm2_pcrlock to be True for UKI image, got {dump['tokens']['0']['tpm2_pcrlock']!r}" - assert ( - dump["tokens"]["0"]["tpm2-pcrs"] == [] - ), f"Expected tpm2-pcrs to be an empty vector for UKI image, got {dump['tokens']['0']['tpm2-pcrs']!r}" - else: - assert ( - dump["tokens"]["0"]["tpm2_pcrlock"] is False - ), f"Expected tpm2_pcrlock to be False for non-UKI image, got {dump['tokens']['0']['tpm2_pcrlock']!r}" - # Expect PCR 7 - assert dump["tokens"]["0"]["tpm2-pcrs"] == [ - 7 - ], f"Expected tpm2-pcrs to be [7] for non-UKI image, got {dump['tokens']['0']['tpm2-pcrs']!r}" - - # Validate that each image has a single keyslot, 1 - assert ( - len(dump["keyslots"]) == 1 - ), f"Expected one key slot, got {len(dump['keyslots'])}. Key slots: {dump['keyslots']}" - assert ( - "1" in dump["keyslots"] - ), f"Expected key slot 1 to be in {dump['keyslots']!r}, got {dump['keyslots']!r}" - - # Validate key slot type and other properties - expected = "luks2" - actual = dump["keyslots"]["1"]["type"] - assert ( - actual == expected - ), f"Expected keyslot 1 type to be {expected!r}, got {actual!r}" - - # Validate key slot KDF type - expected = "pbkdf2" - actual = dump["keyslots"]["1"]["kdf"]["type"] - assert ( - actual == expected - ), f"Expected keyslot 1 KDF type to be {expected!r}, got {actual!r}" - - # Validate key slot KDF hash - expected = "sha512" - actual = dump["keyslots"]["1"]["kdf"]["hash"] - assert ( - actual == expected - ), f"Expected keyslot 1 KDF hash to be {expected!r}, got {actual!r}" - - # Validate key slot area type - expected = "aes-xts-plain64" - actual = dump["keyslots"]["1"]["area"]["encryption"] - assert ( - actual == expected - ), f"Expected keyslot 1 area encryption to be {expected!r}, got {actual!r}" - - -def check_parent_devices( - connection: fabric.Connection, - hostConfiguration: dict, - isUki: bool, - tridentCommand: str, - blockDevs: dict, - cryptDevId: str, -) -> None: - """ - Check the backing device type for the given crypt device ID. - It can be either a disk partition or a RAID array. If a RAID - """ - - part = get_disk_partition(hostConfiguration, cryptDevId) - if part is not None: - cryptDevPath = get_block_dev_path_by_partlabel(blockDevs, cryptDevId) - assert ( - cryptDevPath is not None - ), f"Expected device with PARTLABEL {cryptDevId} to be in {blockDevs}" - else: - cryptDevName = get_raid_software_array_name(hostConfiguration, cryptDevId) - assert ( - cryptDevName is not None - ), f"Expected {cryptDevId} to be a disk partition or RAID array" - cryptDevPath = sudo(connection, f"readlink -f /dev/md/{cryptDevName}") - - expectedType = "crypto_LUKS" - actualType = blockDevs[cryptDevPath]["TYPE"] - assert ( - actualType == expectedType - ), f"Expected TYPE to be {expectedType!r}, got {actualType!r}" - - check_crypsetup_luks_dump(connection, tridentCommand, cryptDevPath, isUki) - - -def check_crypt_device( - connection: fabric.Connection, - hostConfiguration: dict, - isUki: bool, - tridentCommand: str, - abActiveVolume: str, - blockDevs: dict, - cryptId: str, - cryptDevName: str, - cryptDevId: str, -) -> None: - cryptDevicePath = f"/dev/mapper/{cryptDevName}" - - check_parent_devices( - connection, hostConfiguration, isUki, tridentCommand, blockDevs, cryptDevId - ) - - swap = False - isInUse = True - - childAbUpdateVolumePair, isVolumeA = get_child_ab_update_volume_pair( - hostConfiguration, cryptId - ) - if childAbUpdateVolumePair is not None: - assert abActiveVolume in [ - "volume-a", - "volume-b", - ], f"Expected active volume to be either 'volume-a' or 'volume-b', got {abActiveVolume!r}" - isInUse = (abActiveVolume == "volume-a" and isVolumeA) or ( - abActiveVolume == "volume-b" and not isVolumeA - ) - - fs = get_filesystem(hostConfiguration, childAbUpdateVolumePair["id"]) - assert ( - fs is not None - ), f"Expected filesystem for child ab update volume pair {childAbUpdateVolumePair['id']}" - assert ( - "mountPoint" in fs - ), f"Expected mount point for child ab update volume pair {childAbUpdateVolumePair['id']}" - mpPath = ( - fs["mountPoint"] - if isinstance(fs["mountPoint"], str) - else fs["mountPoint"]["path"] - ) - check_exists(connection, mpPath) - check_findmnt(connection, mpPath, cryptDevicePath, isInUse) - elif swap := get_swap(hostConfiguration, cryptId) is not None: - swaps = get_active_swaps(connection) - real_path = sudo(connection, f"readlink -f {cryptDevicePath}") - assert ( - real_path in swaps, - f"Expected '{real_path}' to be in active swaps: {swaps}", - ) - else: - fs = get_filesystem(hostConfiguration, cryptId) - assert ( - fs is not None - ), f"Expected filesystem for encryption volume {cryptId} when it has no child ab update volume pair" - - assert ( - "mountPoint" in fs, - f"Expected filesystem of encryption volume {cryptId} to be mounted", - ) - - mpPath = ( - fs["mountPoint"] - if isinstance(fs["mountPoint"], str) - else fs["mountPoint"]["path"] - ) - - check_exists(connection, mpPath) - check_findmnt(connection, mpPath, cryptDevicePath, isInUse) - - check_exists(connection, cryptDevicePath) - check_cryptsetup_status(connection, cryptDevName, isInUse) - check_dmsetup_info(connection, cryptDevName, swap) - - -def test_encryption( - connection: fabric.Connection, - hostConfiguration: dict, - isUki: bool, - tridentCommand: str, - abActiveVolume: str, -) -> None: - blockDevs = get_blkid_output(connection) - - storageConfig = hostConfiguration["storage"] - encryptionConfig = storageConfig["encryption"] - for crypt in encryptionConfig["volumes"]: - check_crypt_device( - connection, - hostConfiguration, - isUki, - tridentCommand, - abActiveVolume, - blockDevs, - crypt["id"], - crypt["deviceName"], - crypt["deviceId"], - ) diff --git a/tests/e2e_tests/extensions_test.py b/tests/e2e_tests/extensions_test.py deleted file mode 100644 index 7bd530ad8..000000000 --- a/tests/e2e_tests/extensions_test.py +++ /dev/null @@ -1,49 +0,0 @@ -import fabric -import pytest -import json - -from base_test import get_host_status -from pathlib import Path - -pytestmark = [pytest.mark.extensions] - - -def test_extensions( - connection: fabric.Connection, - tridentCommand: str, -) -> None: - hostStatus = get_host_status(connection, tridentCommand) - hostConfig = hostStatus["spec"] - osConfig = hostConfig["os"] - - for extType in ["sysext", "confext"]: - configExtType = f"{extType}s" - if configExtType in osConfig: - extConfig = osConfig[configExtType] - result = connection.run( - f"sudo systemd-{extType} status --json=pretty --no-pager", warn=True - ) - assert ( - result.ok - ), f"failed to run 'systemd-{extType} status': {result.stderr}" - status_output = json.loads(result.stdout) - - # Extract all active extension names from all hierarchies (/opt and - # /usr for sysexts, or /etc for confexts) - active_exts = [] - for hierarchy in status_output: - extensions = hierarchy.get("extensions") - if isinstance(extensions, list): - active_exts.extend(extensions) - - for ext in extConfig: - # Verify that the path exists on the target OS - path = Path(ext["path"]) - result = connection.run(f"test -e {path}", warn=True) - assert result.ok, f"{extType} path does not exist: {path}" - - # Extract extension name from path - ext_name = path.stem - assert ( - ext_name in active_exts - ), f"{extType} '{ext_name}' not found in 'systemd-{extType} status'" diff --git a/tests/e2e_tests/pytest.ini b/tests/e2e_tests/pytest.ini deleted file mode 100644 index 1301865bf..000000000 --- a/tests/e2e_tests/pytest.ini +++ /dev/null @@ -1,18 +0,0 @@ -[pytest] -junit_suite_name = trident_e2e_tests -markers = - base: Tests designed to verify the fundamental operations of Trident. - root_verity: Tests designed to verify the root-verity feature ops in Trident. - usr_verity: Tests designed to verify the usr-verity feature ops in Trident. - encryption: Tests designed to verify the encryption feature ops in Trident. - ab_update_staged: Tests designed to verify that A/B update was staged correctly. - extensions: Tests designed to verify that the servicing of sysexts and confexts succeeded. - - # Special markers, do not use for tests, specify by Trident configuration: - compatible: Tests that are compatible with a Trident configuration. - weekly: Tests that run weekly in the pipeline. - daily: Tests that run nightly in the pipeline. - post_merge: Tests that run when the pipeline is triggered by a change in the resources. - pullrequest: Tests that run in a pipeline for a pull request. - validation: Tests that run when the pipeline was manually triggered. - diff --git a/tests/e2e_tests/rollback_test.py b/tests/e2e_tests/rollback_test.py deleted file mode 100644 index 885aa456f..000000000 --- a/tests/e2e_tests/rollback_test.py +++ /dev/null @@ -1,54 +0,0 @@ -import fabric -import pytest -import yaml - -from base_test import get_host_status - -pytestmark = [pytest.mark.rollback] - - -def test_rollback( - connection: fabric.Connection, - tridentCommand: str, - abActiveVolume: str, - expectedHostStatusState: str, -) -> None: - print("Starting rollback test...") - # Check Host Status - host_status = get_host_status(connection, tridentCommand) - # Assert that the servicing state is as expected - assert host_status["servicingState"] == expectedHostStatusState - # Assert that the last error reflects health.checks failure - serializedLastError = yaml.dump(host_status["lastError"], default_flow_style=False) - assert "Failed health check(s)" in serializedLastError - - if expectedHostStatusState == "not-provisioned": - # Assert that the active volume has not been set - assert "abActiveVolume" not in host_status - else: - # Assert that the active volume has not changed - assert host_status["abActiveVolume"] == abActiveVolume - - # Check log files for expected failure messages - listLogsResult = connection.run( - "sudo ls /var/lib/trident/trident-health-check-failure-*.log" - ) - print(f"Log files: {listLogsResult.stdout.strip()}") - # There should be 1 log file - assert len(listLogsResult.stdout.strip().splitlines()) == 1 - # Get log file contents - logResultContentResult = connection.run(f"sudo cat {listLogsResult.stdout.strip()}") - print(f"Log file contents:\n{logResultContentResult.stdout}") - # Verify that script failure message is in log file - assert ( - "Script 'invoke-rollback-from-script' failed" in logResultContentResult.stdout - ) - # Verify that systemd 2 failure messages are in log file - assert ( - "Unit non-existent-service1.service could not be found" - in logResultContentResult.stdout - ) - assert ( - "Unit non-existent-service2.service could not be found" - in logResultContentResult.stdout - ) diff --git a/tests/e2e_tests/verity_test.py b/tests/e2e_tests/verity_test.py deleted file mode 100644 index 08b524ff3..000000000 --- a/tests/e2e_tests/verity_test.py +++ /dev/null @@ -1,301 +0,0 @@ -import os -import pytest -import yaml -import re -import logging - -from base_test import get_raid_name_from_device_name, get_host_status - -pytestmark = [pytest.mark.verity] - -log = logging.getLogger(__name__) - - -def test_verity_root(connection, hostConfiguration, tridentCommand, abActiveVolume): - # Print out result of blkid for asserting verity root device mapper. - res_blkid = connection.run("sudo blkid") - # Expected output example: - # /dev/sdb: PTUUID="a8dbca6f-77a6-485c-8c67-b653758a8928" PTTYPE="gpt" - # /dev/sr0: BLOCK_SIZE="2048" UUID="2024-04-08-04-36-44-16" LABEL="AZLPROV" TYPE="iso9660" - # /dev/mapper/root: UUID="aeca4bee-73f3-4ae0-aaa3-57ae0a29ee4b" BLOCK_SIZE="4096" TYPE="ext4" - # /dev/sda4: UUID="63d564e5-c020-4fff-9b95-5c6553d0b78b" TYPE="DM_verity_hash" PARTLABEL="root-hash" PARTUUID="a9bca9dc-6b36-49be-85e6-46d9e33dbb4a" - # /dev/sda2: UUID="b00cf2fe-75b4-4aba-8d24-dae00492cf14" BLOCK_SIZE="4096" TYPE="ext4" PARTLABEL="boot" PARTUUID="d33de5a7-f048-4e8f-9334-96d025d8f897" - # /dev/sda9: UUID="1f99c9cb-13bb-4f33-a20a-557644beb7a7" BLOCK_SIZE="4096" TYPE="ext4" PARTLABEL="run" PARTUUID="da273fd3-d147-4877-a6ea-ab43a827bc1e" - # /dev/sda7: UUID="a80b9286-31a2-4821-ba1f-0c14cb60c44c" BLOCK_SIZE="1024" TYPE="ext4" PARTLABEL="home" PARTUUID="460ff9f3-e89d-467e-98b5-1b3db0e54a52" - # /dev/sda5: UUID="059efe02-f439-4fe3-a994-604ec78f047a" BLOCK_SIZE="1024" TYPE="ext4" PARTLABEL="trident" PARTUUID="766418d0-71dc-48d8-919c-7b6ffd88db34" - # /dev/sda3: UUID="aeca4bee-73f3-4ae0-aaa3-57ae0a29ee4b" BLOCK_SIZE="4096" TYPE="ext4" PARTLABEL="root" PARTUUID="08c2abe7-4d4e-4b8e-b1a2-427e6769a261" - # /dev/sda1: UUID="74AE-D771" BLOCK_SIZE="512" TYPE="vfat" PARTLABEL="esp" PARTUUID="34beecb1-ae30-4c88-9d8b-df822b927bbc" - # /dev/sda8: UUID="b056745c-0633-4b3f-a846-923105840c2e" BLOCK_SIZE="4096" TYPE="ext4" PARTLABEL="var" PARTUUID="7f089a07-c487-4a4e-9a18-542f7753ef05" - # /dev/sda6: UUID="3eaa3f16-6e33-415d-ade8-7cb296f61a42" BLOCK_SIZE="1024" TYPE="ext4" PARTLABEL="trident-overlay" PARTUUID="3407f514-2b2f-4016-8bdb-2f56285d497b" - - # Structure blkid output. - output_blkid = res_blkid.stdout.strip().splitlines() - - part_path_set = set() - for part in output_blkid: - # Extract partition path (example: /dev/sda1). - part_path = part.split(": ") - part_path_set.add(part_path[0]) - - # Assert if /dev/mapper/root has been generated properly. - assert "/dev/mapper/root" in part_path_set - - partitions_blkid = dict() - for partition in output_blkid: - partition_dict = dict() - # Extract partition's name (ex: sda1) - name_info = partition.split(": ") - name = name_info[0].split("/")[-1] - # By line structure output into a dictionary with the partition information - for info in name_info[1].split(): - field_value = info.split("=") - if len(field_value) == 2: - partition_dict[field_value[0]] = field_value[1].replace('"', "") - # Adding information to a dictionary for each partition - partitions_blkid[name] = partition_dict - - # Collect expected verity info from host config for the later testing usage. - expected_verity_config = dict() - - items = hostConfiguration["storage"].get("verity", []) - - for verity in items: - expected_verity_config[verity["name"]] = verity - - # Collect veritysetup status output. - veritysetup_status = connection.run("sudo veritysetup status root") - # veritysetup status expected output example: - # /dev/mapper/root is active and is in use. - # type: VERITY - # status: verified - # hash type: 1 - # data block: 4096 - # hash block: 4096 - # hash name: sha256 - # salt: 95c671631e5202431ead38146e1af8342100ff03bc2a89f2590dcb3454cc6e31 - # data device: /dev/sda3 - # size: 1377128 sectors - # mode: readonly - # hash device: /dev/sda4 - # hash offset: 8 sectors - # root hash: a8c34ed685f365352231db21aa36ff23bf8b658e001afa8e498f57d1755e9a19 - # flags: panic_on_corruption - - # Structure veritysetup status output. - output_veritysetup_status = veritysetup_status.stdout.strip().splitlines() - - # Assert if verity target is active. - assert "/dev/mapper/root is active and is in use." == output_veritysetup_status[0] - - # Organize dict for the veritysetup output for the following assert. - veritysetup_status_dict = dict() - for status in output_veritysetup_status[1:]: - key, value = [ - item.strip() for item in status.split(":", 1) - ] # Strip spaces from both key and value - if key and value: - veritysetup_status_dict[key] = value - else: - raise ValueError( - f"Invalid key or value from status: key='{key}', value='{value}'" - ) - - # Validate key properties of the veritysetup output to ensure the verity - # device is configured correctly. - assert "VERITY" == veritysetup_status_dict["type"] - assert "verified" == veritysetup_status_dict["status"] - assert "readonly" == veritysetup_status_dict["mode"] - - # Check Host Status. - host_status = get_host_status(connection, tridentCommand) - - # Host status expected output example: - # root: - # path: /dev/disk/by-partuuid/f69514c7-d20a-42fd-8c4e-49df24d2ce40 - # size: 8589934592 - # contents: !image - # sha256: 764292ca5261af4d68217381d5e2520f453ca22d2af38c081dfc93aeda075d0b - # length: 705090048 - # url: http://myblob/files/verity_root.rawzst - # root-hash: - # path: /dev/disk/by-partuuid/290ddc62-c339-457c-989d-5551153fcb9c - # size: 1073741824 - # contents: !image - # sha256: b63a60a5c6d172cf11d0aec785f50414d2d46206a64e95639804b85c8fa0f3e5 - # length: 25321984 - # url: http://myblob/files/verity_roothash.rawzst - # verity-0: - # path: /dev/mapper/root - # size: 0 - # contents: initialized - - # Assert verity data device and hash device. Refer to logic from base test - # to extract the ID of the mount point with path "/". - root_mount_id = None - # Look for it in filesystems - for fs in host_status["spec"]["storage"]["filesystems"]: - mp = fs.get("mountPoint") - if not mp: - continue - if mp["path"] == "/": - root_mount_id = fs.get("deviceId") - break - - # If no mount point with path / found, raise an exception - if root_mount_id is None: - raise Exception("Root mount point not found") - - # If root_mount_id is still None, look in verity - verity_device_name = None - data_device_id = None - hash_device_id = None - items = host_status["spec"]["storage"].get("verity", []) - for verity_dev in items: - if verity_dev.get("id") == root_mount_id: - verity_device_name = verity_dev.get("name") - data_device_id = verity_dev.get("dataDeviceId") - hash_device_id = verity_dev.get("hashDeviceId") - break - - # If root is not a verity device, no more testing to do here - if verity_device_name is None or hash_device_id is None: - raise Exception("No verity configuration found for the provided root mount ID") - - if "abUpdate" in host_status["spec"]["storage"] and abActiveVolume is not None: - active_data_id, active_hash_id = None, None - # Identify block devices we expect to be in use, given the value of abActiveVolume. - for volume_pair in host_status["spec"]["storage"]["abUpdate"]["volumePairs"]: - if volume_pair["id"] == data_device_id: - if abActiveVolume == "volume-a": - active_data_id = volume_pair["volumeAId"] - else: - active_data_id = volume_pair["volumeBId"] - - if volume_pair["id"] == hash_device_id: - if abActiveVolume == "volume-a": - active_hash_id = volume_pair["volumeAId"] - else: - active_hash_id = volume_pair["volumeBId"] - assert active_data_id is not None and active_hash_id is not None - - # Run and process `veritysetup status` - data_block_device, hash_block_device = get_data_hash_from_veritysetup( - connection, verity_device_name - ) - - # Check if data_block_device, hash_block_device correspond to partitions or RAID arrays - data_is_raid = get_raid_name_from_device_name(connection, data_block_device) - hash_is_raid = get_raid_name_from_device_name(connection, hash_block_device) - # Assert that both data_is_raid are either both None or both not None - assert (data_is_raid is None) == ( - hash_is_raid is None - ), f"Assertion failed: data_is_raid={data_is_raid}, hash_is_raid={hash_is_raid}" - - # If get_raid_name_from_device_name() returned a non-null value, block device is a RAID - # array. - if data_is_raid: - # Convert /dev/md/root-a into root-a; /dev/sda1 into sda1 - extracted_data_block_device = data_is_raid.split("/")[-1] - extracted_hash_block_device = hash_is_raid.split("/")[-1] - - assert active_data_id == extracted_data_block_device - assert active_hash_id == extracted_hash_block_device - else: - # If get_raid_name_from_device_name() returned None, block device is a partition. - # NOTE: This check assumes that PARTLABEL in blkid is same as device ID in Host Status. - extracted_data_block_device = data_block_device.split("/")[-1] - extracted_hash_block_device = hash_block_device.split("/")[-1] - - assert ( - extracted_data_block_device in partitions_blkid - and extracted_hash_block_device in partitions_blkid - ) - assert ( - partitions_blkid[extracted_data_block_device]["PARTLABEL"] - == active_data_id - ) - assert ( - partitions_blkid[extracted_hash_block_device]["PARTLABEL"] - == active_hash_id - ) - else: - # Retrieve data device and hash device from veritysetup status. - data_block_device = veritysetup_status_dict["data device"] - hash_block_device = veritysetup_status_dict["hash device"] - - data_is_raid = get_raid_name_from_device_name(connection, data_block_device) - hash_is_raid = get_raid_name_from_device_name(connection, hash_block_device) - assert (data_is_raid is None) == ( - hash_is_raid is None - ), f"Assertion failed: data_is_raid={data_is_raid}, hash_is_raid={hash_is_raid}" - - if data_is_raid: - # Convert for example /dev/md/root into root. - extracted_data_block_device = os.path.basename(data_is_raid) - extracted_hash_block_device = os.path.basename(hash_is_raid) - - assert data_device_id == extracted_data_block_device - assert hash_device_id == extracted_hash_block_device - else: - extracted_data_block_device = data_block_device.split("/")[-1] - extracted_hash_block_device = hash_block_device.split("/")[-1] - - assert ( - extracted_data_block_device in partitions_blkid - and extracted_hash_block_device in partitions_blkid - ) - - -# Runs 'verity setup' and returns the block device paths of root data device and hash device. E.g. -# with the sample output below, func returns a tuple (/dev/sda3, /dev/sda4). -def get_data_hash_from_veritysetup(connection, device_name): - # Expected output example: - # /dev/mapper/root is active and is in use. - # type: VERITY - # status: verified - # hash type: 1 - # data block: 4096 - # hash block: 4096 - # hash name: sha256 - # salt: 1edfc828a4d3116dc42a8457489db9e9024657382c7e6e27fb16a23b8ad68e56 - # data device: /dev/sda3 - # size: 1377048 sectors - # mode: readonly - # hash device: /dev/sda4 - # hash offset: 8 sectors - # root hash: d446a67f7521af5bbeb2144b85b0859780d75960475e3dde291236054c59d97a - # flags: panic_on_corruption - # OR - # /dev/mapper/root is active and is in use. - # type: VERITY - # status: verified - # hash type: 1 - # data block: 4096 - # hash block: 4096 - # hash name: sha256 - # salt: d16ba3427abe5c98dcb320d484672cdbce159476ada1831bfdf05be0a7072a50 - # data device: /dev/md126 - # size: 1377024 sectors - # mode: readonly - # hash device: /dev/md127 - # hash offset: 8 sectors - # root hash: a35ecce908f6a29fc0dbd56f6cd2216c9c9c883d503252b92f97092b540df9d7 - # flags: panic_on_corruption - - try: - # Run 'veritysetup status' command - command_output = connection.run(f"sudo veritysetup status {device_name}") - status_output = command_output.stdout.strip() - - # Parse the output - data_device_match = re.search(r"data device: (/dev/\S+)", status_output) - hash_device_match = re.search(r"hash device: (/dev/\S+)", status_output) - - if data_device_match and hash_device_match: - root_data_device = data_device_match.group(1) - root_hash_device = hash_device_match.group(1) - return (root_data_device, root_hash_device) - except Exception as e: - raise Exception(f"Unexpected error") from e - - return None diff --git a/tools/storm/e2e/README.md b/tools/storm/e2e/README.md index 6177b120c..26c8cfe23 100644 --- a/tools/storm/e2e/README.md +++ b/tools/storm/e2e/README.md @@ -10,6 +10,8 @@ - [How This Works](#how-this-works) - [Test Rings](#test-rings) - [Discovery](#discovery) + - [Test Selection](#test-selection) + - [Validation Test Cases](#validation-test-cases) - [Matrix Generation in Pipelines](#matrix-generation-in-pipelines) - [Pipeline Execution](#pipeline-execution) - [E2E Test Code](#e2e-test-code) @@ -97,20 +99,19 @@ Test rings are formally defined in The first step is discovery of all configured E2E scenarios. In short, this means looking at all existing Host Configurations and when they are supposed to -run. To ultimate goal is to produce instances of the struct `TridentE2EScenario` +run. The ultimate goal is to produce instances of the struct `TridentE2EScenario` (from [`scenario/trident.go`](scenario/trident.go)) representing each combination of parameters. All test discovery happens in [`discover.go`](discover.go). The key function is -`DiscoverTridentE2EScenarios`, which returns a list of all discovered +`DiscoverTridentScenarios`, which returns a list of all discovered `TridentE2EScenario` instances. -[NOTE: IN DEVELOPMENT: Update once this changes.] While in development, all -configurations are defined in `tests/e2e_tests/trident_configurations/`, and the -configuration for when each is supposed to run is defined in -`tests/e2e_tests/target-configurations.yaml`. To port these over into go, we use -a combination of Go's generate directive, Go's embed package, and some custom -code in [`invert.py`](invert.py): +All configurations are defined in `tests/e2e_tests/trident_configurations/`, and +the configuration for when each is supposed to run is defined in +`tests/e2e_tests/target-configurations.yaml`. These are embedded into the Go +binary at compile time using Go's `generate` directive, `embed` package, and +[`invert.py`](invert.py): ```go //go:generate cp -r ../../../tests/e2e_tests/trident_configurations configurations @@ -119,9 +120,9 @@ code in [`invert.py`](invert.py): var content embed.FS ``` -The python script includes more thorough documentation on itself, but in short -it will produce the yaml file `configurations/configurations.yaml`, which has -this structure: +The `invert.py` script reads `target-configurations.yaml` and produces +`configurations/configurations.yaml`, which maps each configuration to its +pipeline ring assignments: ```yaml : @@ -137,17 +138,17 @@ base: host: pr-e2e ``` -The function `DiscoverTridentE2EScenarios` will go over this data structure and +The function `DiscoverTridentScenarios` iterates over this data structure and all the Host Configuration files to produce instances of `TridentE2EScenario`. Each is configured to contain the Host Configuration, the target hardware type (`vm`/`bm`), the target runtime (`host`/`container`), and the lowest test ring it should be run in. If the specific combination is not configured to run in any ring, the instance is not created. -The type type `configs` in [`discover.go`](discover.go) contains the expected +The type `configs` in [`discover.go`](discover.go) contains the expected structure of the YAML configuration data. -Some configuration have special behaviors, such as expected failures. For those +Some configurations have special behaviors, such as expected failures. For those special cases, the config can be further customized with the YAML keys defined in the struct `TridentE2EHostConfigParams` in [`scenario/trident.go`](scenario/trident.go). These are keys directly under the @@ -160,6 +161,138 @@ base: host: pr-e2e ``` +### Test Selection + +Each configuration has a `test-selection.yaml` file +(in `tests/e2e_tests/trident_configurations//test-selection.yaml`) that +controls which validation test cases run for that configuration. The file is +parsed by [`testselection.go`](testselection.go). + +#### Format + +```yaml +# Base markers that this configuration supports. +compatible: + - base + - encryption + +# Optional ring-level overrides. Each ring can add or remove markers +# relative to the compatible set. +weekly: + add: + - slow_validation + remove: [] +daily: + add: [] + remove: [] +post_merge: + add: [] + remove: [] +pullrequest: + add: [] + remove: [] +validation: + add: [] + remove: [] +``` + +The `compatible` list is the base set of test markers. Ring-level overrides +(keyed by ring name: `weekly`, `daily`, `post_merge`, `pullrequest`, +`validation`) can `add` or `remove` markers for specific pipeline stages. + +#### Tag Mapping + +Each compatible marker is prefixed with `test:` to form a storm scenario tag. +For example, a marker `encryption` becomes the tag `test:encryption`. These tags +determine which validation test cases are registered for the scenario (see +[Validation Test Cases](#validation-test-cases)). + +#### All 19 Configurations + +| Configuration | Compatible Markers | +|---|---| +| `base` | `base` | +| `simple` | `base` | +| `misc` | `base` | +| `split` | `base` | +| `raid-big` | `base` | +| `raid-mirrored` | `base` | +| `raid-resync-small` | `base` | +| `raid-small` | `base` | +| `combined` | `base`, `usr_verity`, `encryption`, `uki` | +| `encrypted-partition` | `base`, `encryption` | +| `encrypted-raid` | `base`, `encryption` | +| `encrypted-swap` | `base`, `encryption` | +| `extensions` | `base`, `extensions` | +| `health-checks-install` | `rollback` | +| `memory-constraint-combined` | `base`, `usr_verity`, `encryption`, `uki` | +| `rerun` | `base`, `usr_verity`, `encryption`, `uki` | +| `root-verity` | `base`, `root_verity`, `extensions` | +| `usr-verity` | `base`, `usr_verity`, `uki` | +| `usr-verity-raid` | `base`, `usr_verity`, `uki` | + +### Validation Test Cases + +All E2E validation is implemented in Go under [`scenario/`](scenario/). +Test cases are conditionally registered based on the test tags derived from +`test-selection.yaml`. The registration logic is in +[`scenario/trident.go`](scenario/trident.go) (`RegisterTestCases`). + +#### Core Test Cases (always registered) + +These run for every scenario: + +| Test Case | Description | +|---|---| +| `install-vm-deps` | Install VM dependencies (VM scenarios only) | +| `prepare-hc` | Prepare the host configuration | +| `setup-test-host` | Set up the test host (VM or bare metal) | +| `install-os` | Install the OS via Trident | +| `check-trident-ssh` | Verify Trident via SSH after install | +| `collect-install-boot-metrics` | Collect boot metrics after initial install | +| `publish-logs` | Publish logs and artifacts at scenario end | + +#### Tag-Gated Validation Test Cases + +These are registered only when the scenario has the corresponding test tag: + +| Test Tag | Test Case | Source File | Description | +|---|---|---|---| +| `test:base` | `validate-partitions` | `validate_base.go` | Validate disk partitions match host config | +| `test:base` | `validate-users` | `validate_base.go` | Validate user accounts are created correctly | +| `test:base` | `validate-uefi-fallback` | `validate_base.go` | Validate UEFI fallback boot entry | +| `test:encryption` | `validate-encryption` | `validate_encryption.go` | Validate LUKS2/TPM2 disk encryption | +| `test:root_verity` / `test:usr_verity` | `validate-verity` | `validate_verity.go` | Validate dm-verity configuration | +| `test:extensions` | `validate-extensions` | `validate_extensions.go` | Validate systemd-sysext/confext | +| `test:rollback` | `validate-rollback` | `validate_rollback.go` | Validate health-check rollback behavior | + +#### A/B Update Test Cases + +For configurations that have A/B update support (`HasABUpdate()`), two sets of +update tests are registered: + +**Standard A/B Update** (`ab-update-1-*`): + +| Test Case | Description | +|---|---| +| `ab-update-1-sync-hc` | Sync host configuration | +| `ab-update-1-update-hc` | Update host configuration for A/B update | +| `ab-update-1-upload-new-hc` | Upload updated config to test host | +| `ab-update-1-ab-update` | Perform A/B update and reboot | +| `ab-update-1-collect-boot-metrics` | Collect boot metrics after A/B update | + +**Split A/B Update** (`ab-update-split-*`, runs on `pre` ring and above): + +| Test Case | Description | +|---|---| +| `ab-update-split-sync-hc` | Sync host configuration | +| `ab-update-split-update-hc` | Update host configuration for split update | +| `ab-update-split-upload-new-hc` | Upload updated config to test host | +| `ab-update-split-stage` | Stage the A/B update (without reboot) | +| `ab-update-split-validate-staged` | Validate staging state | +| `ab-update-split-finalize` | Finalize the staged update (reboot) | +| `ab-update-split-collect-boot-metrics` | Collect boot metrics after finalize | + ### Matrix Generation in Pipelines The E2E testing framework includes functionality to generate ADO job matrixes @@ -207,8 +340,32 @@ To run this in pipelines, we depend on two YAML templates: configuration combination. It consumes the matrix variable produced by the previous template and runs each scenario in it. +#### JUnit XML Output + +The pipeline uses Storm's built-in `-j` flag to produce JUnit XML results for +each scenario run. The JUnit XML file is written to the output directory as +`_.junit.xml` and published to ADO via the +`handle-junit-test-results.yml` template. This enables test result visibility +in the ADO test tab for each pipeline run. + ### E2E Test Code All actual E2E test code lives under [`scenario/`](scenario/). The main entry point is the file [`trident.go`](scenario/trident.go), which contains the -`TridentE2EScenario` struct which implements the storm Scenario interface. \ No newline at end of file +`TridentE2EScenario` struct which implements the storm Scenario interface. + +#### Metrics and Log Collection + +The scenario automatically collects boot metrics and publishes log artifacts, +eliminating the need for separate YAML pipeline steps: + +- **Boot metrics** (`metrics.go`): After the initial OS install and each A/B + update reboot, the scenario collects `systemd-analyze` boot timing data + (firmware, loader, kernel, initrd, userspace) via SSH and writes it to + `boot-metrics.jsonl`. +- **Log publishing** (`logs.go`): At the end of the scenario, all generated log + and metrics files are published as artifacts via the storm ArtifactBroker: + - `logstream-full.log` (trident deployment log stream) + - `trident-clean-install-metrics.jsonl` (netlisten tracestream from install) + - `boot-metrics.jsonl` (systemd-analyze boot timings) + - `metrics-*.jsonl` (netlisten tracestream from A/B updates) \ No newline at end of file diff --git a/tools/storm/e2e/configurations/configurations.yaml b/tools/storm/e2e/configurations/configurations.yaml new file mode 100644 index 000000000..19eef8284 --- /dev/null +++ b/tools/storm/e2e/configurations/configurations.yaml @@ -0,0 +1,82 @@ +# This file is autogenerated by invert.py from: tests/e2e_tests/target-configurations.yaml +# Do not edit this file directly. +# +# The file is structured as: +# +# ```yaml +# : +# : +# : +# ``` +# +# Where: +# - is the name of the test configuration. +# - is either "bm" (bare metal) or "vm" (virtual machine). +# - is either "host" or "container". +# - is the first test ring where this scenario should be run. +# +# The pipeline rings are ordered as follows (from earliest to latest in the validation process): +# pr-e2e, ci, pre, full-validation +# +# A scenario marked to run in a given ring will also run in all later rings. +# For example, if a scenario is marked as "pre", it will run in the "pre" ring and all higher rings +# ("full-validation"). +# Scenarios not listed in this file will not be run in any ring. + +base: + vm: + host: pr-e2e +combined: + vm: + host: pr-e2e +encrypted-partition: + vm: + host: ci +encrypted-raid: + vm: + host: ci +encrypted-swap: + vm: + host: ci +extensions: + vm: + host: pr-e2e +health-checks-install: + vm: + host: pr-e2e + ignorePhonehomeFailures: true +memory-constraint-combined: + vm: + host: ci + maxExpectedFailures: 1 +misc: + vm: + host: pr-e2e +raid-mirrored: + vm: + host: pr-e2e +raid-resync-small: + vm: + host: pr-e2e +raid-small: + vm: + host: ci +rerun: + vm: + host: pr-e2e + maxExpectedFailures: 1 +root-verity: + vm: + host: pr-e2e +simple: + vm: + host: pr-e2e +split: + vm: + host: pr-e2e +usr-verity: + vm: + host: pr-e2e +usr-verity-raid: + vm: + host: pr-e2e diff --git a/tools/storm/e2e/discover.go b/tools/storm/e2e/discover.go index ed641a37c..dbb1e34e4 100644 --- a/tools/storm/e2e/discover.go +++ b/tools/storm/e2e/discover.go @@ -53,8 +53,20 @@ func DiscoverTridentScenarios(log *logrus.Logger) ([]scenario.TridentE2EScenario return nil, fmt.Errorf("failed to unmarshal configuration file for '%s': %v", name, err) } + // Read the test-selection.yaml file for this configuration. + testSelectionPath := getTestSelectionPath(name) + testSelectionYaml, err := content.ReadFile(testSelectionPath) + if err != nil { + return nil, fmt.Errorf("failed to read test-selection file for '%s': %w", name, err) + } + + testSelection, err := ParseTestSelection(testSelectionYaml) + if err != nil { + return nil, fmt.Errorf("failed to parse test-selection file for '%s': %w", name, err) + } + // Produce scenarios from this configuration. - scenarios, err := produceScenariosFromConfig(name, conf, hostConfig) + scenarios, err := produceScenariosFromConfig(name, conf, hostConfig, testSelection) if err != nil { return nil, fmt.Errorf("failed to produce scenarios for '%s': %v", name, err) } @@ -70,8 +82,13 @@ func getConfigPath(scenarioName string) string { return "configurations/trident_configurations/" + scenarioName + "/trident-config.yaml" } +// Returns the path to the test-selection.yaml file for the given scenario name. +func getTestSelectionPath(scenarioName string) string { + return "configurations/trident_configurations/" + scenarioName + "/test-selection.yaml" +} + // Produces all scenarios from a given configuration. -func produceScenariosFromConfig(name string, conf scenarioConfig, hostConfig hostconfig.HostConfig) ([]scenario.TridentE2EScenario, error) { +func produceScenariosFromConfig(name string, conf scenarioConfig, hostConfig hostconfig.HostConfig, testSelection *TestSelection) ([]scenario.TridentE2EScenario, error) { var scenarios []scenario.TridentE2EScenario // Iterate over all hardware types @@ -97,7 +114,7 @@ func produceScenariosFromConfig(name string, conf scenarioConfig, hostConfig hos } // Produce the scenario for this hardware/runtime/ring combination - scenario, err := produceScenario(name, hostConfig, conf.Parameters, hw, rt, ring) + scenario, err := produceScenario(name, hostConfig, conf.Parameters, hw, rt, ring, testSelection.TestTags()) if err != nil { return nil, err } @@ -127,6 +144,7 @@ func produceScenario( hardware scenario.HardwareType, runtime trident.RuntimeType, lowestRing testrings.TestRing, + testTags []string, ) (*scenario.TridentE2EScenario, error) { // Get the list of all target rings for this scenario. This is the list of // rings from the lowest declared ring up to the highest existing ring. @@ -151,6 +169,7 @@ func produceScenario( for _, ring := range rings { tags = append(tags, string(ring)) } + tags = append(tags, testTags...) newScenario, err := scenario.NewTridentE2EScenario( fmt.Sprintf("%s_%s-%s", name, hardware, runtime), @@ -160,6 +179,7 @@ func produceScenario( hardware, runtime, rings, + testTags, ) if err != nil { return nil, err diff --git a/tools/storm/e2e/discover_test.go b/tools/storm/e2e/discover_test.go new file mode 100644 index 000000000..69988cc53 --- /dev/null +++ b/tools/storm/e2e/discover_test.go @@ -0,0 +1,267 @@ +package e2e + +import ( + "testing" + "tridenttools/storm/e2e/testrings" + + "github.com/sirupsen/logrus" +) + +func TestGetTestSelectionPath(t *testing.T) { + got := getTestSelectionPath("base") + expected := "configurations/trident_configurations/base/test-selection.yaml" + if got != expected { + t.Fatalf("expected %q, got %q", expected, got) + } +} + +func TestGetTestSelectionPath_Hyphenated(t *testing.T) { + got := getTestSelectionPath("encrypted-partition") + expected := "configurations/trident_configurations/encrypted-partition/test-selection.yaml" + if got != expected { + t.Fatalf("expected %q, got %q", expected, got) + } +} + +// TestDiscoverTridentScenarios_TestTagsPresent verifies that discovered +// scenarios contain test tags from their test-selection.yaml. This exercises +// the full discovery path including embedded file reading. +func TestDiscoverTridentScenarios_TestTagsPresent(t *testing.T) { + log := logrus.New() + scenarios, err := DiscoverTridentScenarios(log) + if err != nil { + t.Fatalf("DiscoverTridentScenarios failed: %v", err) + } + + if len(scenarios) == 0 { + t.Fatal("expected at least one scenario") + } + + // Every scenario should have at least one test tag. + for _, s := range scenarios { + testTags := s.TestTags() + if len(testTags) == 0 { + t.Errorf("scenario %q has no test tags", s.Name()) + } + + // All test tags should have the "test:" prefix. + for _, tag := range testTags { + if len(tag) <= len(TestTagPrefix) || tag[:len(TestTagPrefix)] != TestTagPrefix { + t.Errorf("scenario %q: test tag %q missing %q prefix", s.Name(), tag, TestTagPrefix) + } + } + + // Test tags should also be present in the scenario's Tags() list. + allTags := s.Tags() + tagSet := make(map[string]bool) + for _, tag := range allTags { + tagSet[tag] = true + } + for _, testTag := range testTags { + if !tagSet[testTag] { + t.Errorf("scenario %q: test tag %q not found in Tags() list", s.Name(), testTag) + } + } + } +} + +// TestDiscoverTridentScenarios_KnownConfigTestTags checks specific +// configurations have expected test tags. +func TestDiscoverTridentScenarios_KnownConfigTestTags(t *testing.T) { + log := logrus.New() + scenarios, err := DiscoverTridentScenarios(log) + if err != nil { + t.Fatalf("DiscoverTridentScenarios failed: %v", err) + } + + // Build a map of scenario name โ†’ test tags for lookup. + scenarioMap := make(map[string][]string) + for _, s := range scenarios { + scenarioMap[s.Name()] = s.TestTags() + } + + // The "base" config is always allowed. Verify its test tags. + baseTags, ok := scenarioMap["base_vm-host"] + if !ok { + t.Fatal("expected base_vm-host scenario to be discovered") + } + + if len(baseTags) != 1 || baseTags[0] != "test:base" { + t.Errorf("base_vm-host: expected [test:base], got %v", baseTags) + } + + // Verify that HasTestTag works through the scenario accessor. + for _, s := range scenarios { + if s.Name() == "base_vm-host" { + if !s.HasTestTag("test:base") { + t.Error("base_vm-host: HasTestTag('test:base') should be true") + } + if s.HasTestTag("test:encryption") { + t.Error("base_vm-host: HasTestTag('test:encryption') should be false") + } + break + } + } +} + +// TestDiscoverTridentScenarios_All19Configs verifies that all 19 trident +// configuration directories are represented in discovered scenarios. +// 18 configs run as VM/HOST; raid-big is BM-only. +func TestDiscoverTridentScenarios_All19Configs(t *testing.T) { + log := logrus.New() + scenarios, err := DiscoverTridentScenarios(log) + if err != nil { + t.Fatalf("DiscoverTridentScenarios failed: %v", err) + } + + // All 19 configuration names that must be represented. + allConfigs := []string{ + "base", "simple", "combined", + "encrypted-partition", "encrypted-raid", "encrypted-swap", + "extensions", "health-checks-install", "memory-constraint-combined", + "misc", "raid-big", "raid-mirrored", "raid-resync-small", "raid-small", + "rerun", "root-verity", "split", "usr-verity", "usr-verity-raid", + } + + // 18 configs that must appear as VM/HOST scenarios. + vmHostConfigs := []string{ + "base", "simple", "combined", + "encrypted-partition", "encrypted-raid", "encrypted-swap", + "extensions", "health-checks-install", "memory-constraint-combined", + "misc", "raid-mirrored", "raid-resync-small", "raid-small", + "rerun", "root-verity", "split", "usr-verity", "usr-verity-raid", + } + + // Build a set of discovered scenario names. + scenarioNames := make(map[string]bool) + for _, s := range scenarios { + scenarioNames[s.Name()] = true + } + + // Verify each VM/HOST config produces a scenario. + for _, name := range vmHostConfigs { + expected := name + "_vm-host" + if !scenarioNames[expected] { + t.Errorf("missing VM/HOST scenario for config %q (expected %q)", name, expected) + } + } + + // Verify raid-big is NOT in the VM/HOST set (it's BM-only). + if scenarioNames["raid-big_vm-host"] { + t.Error("raid-big_vm-host should not be discovered (raid-big is BM-only)") + } + + // Verify all 19 config directories exist in embedded data. + for _, name := range allConfigs { + configPath := getConfigPath(name) + if _, err := content.ReadFile(configPath); err != nil { + t.Errorf("config directory missing for %q: %v", name, err) + } + } +} + +// TestDiscoverTridentScenarios_FullValidationRingCoverage verifies that the +// full-validation ring includes all 18 VM/HOST configurations. +func TestDiscoverTridentScenarios_FullValidationRingCoverage(t *testing.T) { + log := logrus.New() + scenarios, err := DiscoverTridentScenarios(log) + if err != nil { + t.Fatalf("DiscoverTridentScenarios failed: %v", err) + } + + expectedVMHost := map[string]bool{ + "base_vm-host": true, "simple_vm-host": true, "combined_vm-host": true, + "encrypted-partition_vm-host": true, "encrypted-raid_vm-host": true, "encrypted-swap_vm-host": true, + "extensions_vm-host": true, "health-checks-install_vm-host": true, "memory-constraint-combined_vm-host": true, + "misc_vm-host": true, "raid-mirrored_vm-host": true, "raid-resync-small_vm-host": true, + "raid-small_vm-host": true, "rerun_vm-host": true, "root-verity_vm-host": true, + "split_vm-host": true, "usr-verity_vm-host": true, "usr-verity-raid_vm-host": true, + } + + // Count VM/HOST scenarios that include the full-validation ring. + fullValidationCount := 0 + for _, s := range scenarios { + if !expectedVMHost[s.Name()] { + continue + } + if s.TestRings().Contains(testrings.TestRingFullValidation) { + fullValidationCount++ + } else { + t.Errorf("scenario %q should include full-validation ring", s.Name()) + } + } + + if fullValidationCount != 18 { + t.Errorf("expected 18 VM/HOST scenarios in full-validation ring, got %d", fullValidationCount) + } +} + +// TestDiscoverTridentScenarios_Phase3TestTags verifies that Phase 3 test tags +// (verity, extensions, rollback) are correctly discovered for configs present +// in the generated configurations.yaml. Note: ALLOWED_CONFIGS in invert.py +// controls which configs are available; full coverage is verified in the +// unit-level TestRegisterTestCases_AllPhase3_FeatureParity test. +func TestDiscoverTridentScenarios_Phase3TestTags(t *testing.T) { + log := logrus.New() + scenarios, err := DiscoverTridentScenarios(log) + if err != nil { + t.Fatalf("DiscoverTridentScenarios failed: %v", err) + } + + // Build a map of scenario name โ†’ test tags. + scenarioMap := make(map[string][]string) + for _, s := range scenarios { + scenarioMap[s.Name()] = s.TestTags() + } + + hasTag := func(tags []string, tag string) bool { + for _, t := range tags { + if t == tag { + return true + } + } + return false + } + + // Verify Phase 3 tags on discovered scenarios. The base config should NOT + // have verity, extensions, or rollback tags. + if tags, ok := scenarioMap["base_vm-host"]; ok { + if hasTag(tags, "test:root_verity") { + t.Error("base_vm-host: should NOT have test:root_verity") + } + if hasTag(tags, "test:usr_verity") { + t.Error("base_vm-host: should NOT have test:usr_verity") + } + if hasTag(tags, "test:extensions") { + t.Error("base_vm-host: should NOT have test:extensions") + } + if hasTag(tags, "test:rollback") { + t.Error("base_vm-host: should NOT have test:rollback") + } + } + + // If additional configs are discovered (when ALLOWED_CONFIGS is expanded), + // verify their Phase 3 tags. + phase3Checks := []struct { + scenario string + tag string + expected bool + }{ + {"root-verity_vm-host", "test:root_verity", true}, + {"usr-verity_vm-host", "test:usr_verity", true}, + {"extensions_vm-host", "test:extensions", true}, + {"health-checks-install_vm-host", "test:rollback", true}, + } + + for _, check := range phase3Checks { + tags, ok := scenarioMap[check.scenario] + if !ok { + // Config not yet in ALLOWED_CONFIGS; skip. + continue + } + if got := hasTag(tags, check.tag); got != check.expected { + t.Errorf("%s: HasTag(%q)=%v, want %v (tags=%v)", + check.scenario, check.tag, got, check.expected, tags) + } + } +} diff --git a/tools/storm/e2e/invert.py b/tools/storm/e2e/invert.py index 69b4976ab..853f5b839 100644 --- a/tools/storm/e2e/invert.py +++ b/tools/storm/e2e/invert.py @@ -35,8 +35,9 @@ # Scenarios not listed in this file will not be run in any ring. """ -# While on development, only allow these configurations: -ALLOWED_CONFIGS = ["base"] +# VM host configurations are fully supported by storm-trident. +# BM and container support is pending implementation (see setup.go). +ALLOWED_CONFIGS = [] # Empty list means all configs are allowed ALLOWED_HARDWARES = ["vm"] ALLOWED_RUNTIMES = ["host"] @@ -100,7 +101,7 @@ def main(): ) continue - if name not in ALLOWED_CONFIGS: + if ALLOWED_CONFIGS and name not in ALLOWED_CONFIGS: continue if hw_rename not in ALLOWED_HARDWARES: continue diff --git a/tools/storm/e2e/pipeline_validation_test.go b/tools/storm/e2e/pipeline_validation_test.go new file mode 100644 index 000000000..59660353b --- /dev/null +++ b/tools/storm/e2e/pipeline_validation_test.go @@ -0,0 +1,149 @@ +package e2e + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "testing" + "tridenttools/storm/e2e/scenario" + "tridenttools/storm/e2e/testrings" + "tridenttools/storm/utils/trident" + + "github.com/microsoft/storm/pkg/storm/core" + "github.com/sirupsen/logrus" +) + +// testSuiteContext is a minimal implementation of core.SuiteContext for tests. +type testSuiteContext struct { + scenarios []core.Scenario + log *logrus.Logger +} + +func (t *testSuiteContext) Name() string { return "test" } +func (t *testSuiteContext) Logger() *logrus.Logger { return t.log } +func (t *testSuiteContext) Scenarios() []core.Scenario { return t.scenarios } +func (t *testSuiteContext) Helpers() []core.Helper { return nil } +func (t *testSuiteContext) AzureDevops() bool { return false } +func (t *testSuiteContext) Context() context.Context { return context.Background() } +func (t *testSuiteContext) Scenario(name string) core.Scenario { + for _, s := range t.scenarios { + if s.Name() == name { + return s + } + } + return nil +} +func (t *testSuiteContext) Helper(name string) core.Helper { return nil } + +// TestPipelineMatrix_PRE2ERingProducesVMHostScenarios validates that the +// pr-e2e test ring generates a non-empty matrix for VM/HOST โ€” the only +// hardware/runtime combination currently enabled in the ADO pipeline +// (storm_e2e.yml). +func TestPipelineMatrix_PRE2ERingProducesVMHostScenarios(t *testing.T) { + suite := newTestSuiteContext(t) + scenarios := GetScenariosByHardwareAndRuntime(suite, scenario.HardwareTypeVM, trident.RuntimeTypeHost, testrings.TestRingPrE2e) + + if len(scenarios) == 0 { + t.Fatal("pr-e2e ring must produce at least one VM/HOST scenario") + } + + // PR-E2E ring should have a meaningful subset for quick validation. + if len(scenarios) < 5 { + t.Errorf("pr-e2e ring has only %d VM/HOST scenarios; expected at least 5", len(scenarios)) + } +} + +// TestPipelineMatrix_GenerateMatrixFormat validates that the matrix JSON +// produced by GenerateMatrix matches the format expected by the ADO pipeline +// template (test_execution_template.yml). Each entry must have uppercase +// SCENARIO, HARDWARE, RUNTIME, and TEST_RING fields. +func TestPipelineMatrix_GenerateMatrixFormat(t *testing.T) { + suite := newTestSuiteContext(t) + scenarios := GetScenariosByHardwareAndRuntime(suite, scenario.HardwareTypeVM, trident.RuntimeTypeHost, testrings.TestRingPrE2e) + if len(scenarios) == 0 { + t.Fatal("no scenarios to test matrix generation") + } + + gen := &TridentE2EScenarioMatrix{TestRing: testrings.TestRingPrE2e} + matrixJSON, err := gen.GenerateMatrix(scenarios, scenario.HardwareTypeVM, trident.RuntimeTypeHost, testrings.TestRingPrE2e) + if err != nil { + t.Fatalf("GenerateMatrix failed: %v", err) + } + + // Parse the JSON into a generic map to validate structure. + var matrix map[string]map[string]string + if err := json.Unmarshal([]byte(matrixJSON), &matrix); err != nil { + t.Fatalf("matrix JSON is not valid: %v", err) + } + + requiredFields := []string{"SCENARIO", "HARDWARE", "RUNTIME", "TEST_RING"} + for name, entry := range matrix { + for _, field := range requiredFields { + val, ok := entry[field] + if !ok || val == "" { + t.Errorf("matrix entry %q missing required field %q", name, field) + } + } + // The key should match the SCENARIO field value. + if entry["SCENARIO"] != name { + t.Errorf("matrix key %q does not match SCENARIO field %q", name, entry["SCENARIO"]) + } + } +} + +// TestPipelineMatrix_VariableNamingConvention validates that the ADO output +// variable name format (TEST_MATRIX_E2E_{HW}_{RT}) matches across the matrix +// script and the pipeline YAML templates. +func TestPipelineMatrix_VariableNamingConvention(t *testing.T) { + for _, hw := range scenario.HardwareTypes() { + for _, rt := range trident.RuntimeTypes() { + varName := fmt.Sprintf("TEST_MATRIX_E2E_%s_%s", + strings.ToUpper(hw.ToString()), + strings.ToUpper(rt.ToString())) + + // Variable name must only contain uppercase letters, digits, and underscores + // to be a valid ADO output variable. + for _, ch := range varName { + if !((ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch == '_') { + t.Errorf("variable name %q contains invalid character %q", varName, string(ch)) + } + } + + // Must start with the expected prefix. + if !strings.HasPrefix(varName, "TEST_MATRIX_E2E_") { + t.Errorf("variable name %q missing expected prefix", varName) + } + } + } +} + +// TestPipelineMatrix_FullValidationCoversAll18VMHost validates that the +// full-validation ring generates all 18 VM/HOST scenarios needed by the +// pipeline. +func TestPipelineMatrix_FullValidationCoversAll18VMHost(t *testing.T) { + suite := newTestSuiteContext(t) + scenarios := GetScenariosByHardwareAndRuntime(suite, scenario.HardwareTypeVM, trident.RuntimeTypeHost, testrings.TestRingFullValidation) + + if len(scenarios) != 18 { + t.Errorf("full-validation ring should produce exactly 18 VM/HOST scenarios, got %d: %v", + len(scenarios), scenarios) + } +} + +// newTestSuiteContext creates a minimal suite context for tests that need +// to call GetScenariosByHardwareAndRuntime. +func newTestSuiteContext(t *testing.T) *testSuiteContext { + t.Helper() + log := logrus.New() + discovered, err := DiscoverTridentScenarios(log) + if err != nil { + t.Fatalf("DiscoverTridentScenarios failed: %v", err) + } + // Convert to []core.Scenario + coreScenarios := make([]core.Scenario, len(discovered)) + for i := range discovered { + coreScenarios[i] = &discovered[i] + } + return &testSuiteContext{scenarios: coreScenarios, log: log} +} diff --git a/tools/storm/e2e/scenario/ab_update.go b/tools/storm/e2e/scenario/ab_update.go index c44d039c2..c14965060 100644 --- a/tools/storm/e2e/scenario/ab_update.go +++ b/tools/storm/e2e/scenario/ab_update.go @@ -34,6 +34,9 @@ func (s *TridentE2EScenario) addAbUpdateTests(r storm.TestRegistrar, prefix stri r.RegisterTestCase(prefix+"-ab-update", func(tc storm.TestCase) error { return s.abUpdateOs(tc, false) }) + r.RegisterTestCase(prefix+"-collect-boot-metrics", func(tc storm.TestCase) error { + return s.collectBootMetrics(tc, prefix) + }) } // addSplitABUpdateTests adds the split A/B update test cases to the provided test registrar @@ -56,9 +59,18 @@ func (s *TridentE2EScenario) addSplitABUpdateTests(r storm.TestRegistrar, prefix r.RegisterTestCase(prefix+"-upload-new-hc", func(tc storm.TestCase) error { return filterSplitTestForCurrentRing(s, tc, s.uploadNewConfig) }) - r.RegisterTestCase(prefix+"-ab-update", func(tc storm.TestCase) error { + r.RegisterTestCase(prefix+"-stage", func(tc storm.TestCase) error { + return filterSplitTestForCurrentRing(s, tc, s.abStageOs) + }) + r.RegisterTestCase(prefix+"-validate-staged", func(tc storm.TestCase) error { + return filterSplitTestForCurrentRing(s, tc, s.validateAbStaged) + }) + r.RegisterTestCase(prefix+"-finalize", func(tc storm.TestCase) error { + return filterSplitTestForCurrentRing(s, tc, s.abFinalizeOs) + }) + r.RegisterTestCase(prefix+"-collect-boot-metrics", func(tc storm.TestCase) error { return filterSplitTestForCurrentRing(s, tc, func(tc storm.TestCase) error { - return s.abUpdateOs(tc, true) + return s.collectBootMetrics(tc, prefix) }) }) } @@ -304,6 +316,118 @@ func (s *TridentE2EScenario) abUpdateOs(tc storm.TestCase, split bool) error { return nil } +// abStageOs stages an A/B update on the test host without finalizing. +// This is used in the split A/B update flow where staging and finalization +// are separate test steps with a validation check in between. +func (s *TridentE2EScenario) abStageOs(tc storm.TestCase) error { + args := fmt.Sprintf( + "update -v trace %s", + path.Join(s.runtime.HostPath(), hostConfigRemotePath), + ) + + // Get the Host Config file to be used for the update, for debugging purposes + file, err := sshutils.CommandOutput(s.sshClient, fmt.Sprintf("sudo cat %s", hostConfigRemotePath)) + if err != nil { + return fmt.Errorf("failed to read new Host Config file: %w", err) + } + + logrus.Debugf("Trident HC file @ %s:\n%s", hostConfigRemotePath, file) + + go netlisten.RunNetlisten(tc.Context(), &netlaunch.NetListenConfig{ + NetCommonConfig: netlaunch.NetCommonConfig{ + ListenPort: defaultNetlaunchListenPort, + LogstreamFile: s.args.LogstreamFile, + TracestreamFile: fmt.Sprintf("metrics-%s.jsonl", tc.Name()), + ServeDirectory: s.args.TestImageDir, + MaxPhonehomeFailures: s.configParams.MaxExpectedFailures, + }, + }) + + logrus.Infof("Running split Trident A/B update (stage)...") + err = runTridentUpdate(tc, s.runtime, s.sshClient, args+" --allowed-operations stage") + if err != nil { + return fmt.Errorf("failed to run Trident A/B update (stage): %w", err) + } + + return nil +} + +// abFinalizeOs finalizes a previously staged A/B update, handling reboot +// and SSH reconnection. This is used in the split A/B update flow. +func (s *TridentE2EScenario) abFinalizeOs(tc storm.TestCase) error { + args := fmt.Sprintf( + "update -v trace %s", + path.Join(s.runtime.HostPath(), hostConfigRemotePath), + ) + + go netlisten.RunNetlisten(tc.Context(), &netlaunch.NetListenConfig{ + NetCommonConfig: netlaunch.NetCommonConfig{ + ListenPort: defaultNetlaunchListenPort, + LogstreamFile: s.args.LogstreamFile, + TracestreamFile: fmt.Sprintf("metrics-%s.jsonl", tc.Name()), + ServeDirectory: s.args.TestImageDir, + MaxPhonehomeFailures: s.configParams.MaxExpectedFailures, + }, + }) + + monitorCtx, cancel := context.WithCancel(tc.Context()) + defer cancel() + + // Start VM serial monitor (only runs if hardware is VM) + monWaitChan, monErr := s.spawnVMSerialMonitor(monitorCtx, tc.ArtifactBroker().StreamArtifactData(tc.Name()+"/serial.log")) + if monErr != nil { + return fmt.Errorf("failed to start VM serial monitor: %w", monErr) + } + + // On exit, give the monitor up to 1 minute to reach the login prompt and exit. + defer func() { + select { + case <-time.After(time.Minute): + logrus.Infof("Waited 1 minute for serial monitor to reach login prompt, cancelling monitor.") + cancel() + case <-monWaitChan: + // Monitor exited on its own + } + }() + + logrus.Infof("Running split Trident A/B update (finalize)...") + err := runTridentUpdate(tc, s.runtime, s.sshClient, args+" --allowed-operations finalize") + if err != nil { + return fmt.Errorf("failed to run Trident A/B update (finalize): %w", err) + } + + // Wait for SSH client to disconnect, meaning the host is rebooting, before + // trying to reconnect again. + logrus.Info("Waiting for SSH client to disconnect after Trident A/B update...") + disconnectCtx, disconnectCancel := context.WithTimeout(tc.Context(), time.Minute*2) + defer disconnectCancel() + err = s.waitForSshToDisconnect(disconnectCtx) + if err != nil { + tc.FailFromError(fmt.Errorf("failed to detect SSH disconnection after Trident A/B update: %w", err)) + } + + logrus.Info("SSH client disconnected, host is rebooting. Will attempt to reconnect...") + + // Then, try to reconnect via SSH and check that Trident is running. + conn_ctx, connCancel := context.WithTimeout(tc.Context(), time.Minute*5) + defer connCancel() + err = s.populateSshClient(conn_ctx) + if err != nil { + tc.FailFromError(err) + return nil + } + + logrus.Info("Reacquired SSH connection to host after reboot.") + + // Give it some extra time to ensure Trident is up after reboot. + err = trident.CheckTridentService(s.sshClient, s.runtime, time.Minute*2, true) + if err != nil { + tc.FailFromError(err) + } + + return nil +} + func runTridentUpdate(tc storm.TestCase, runtime trident.RuntimeType, client *ssh.Client, args string) error { for i := 1; ; i++ { logrus.Infof("Invoking Trident attempt #%d with args: %s", i, args) diff --git a/tools/storm/e2e/scenario/logs.go b/tools/storm/e2e/scenario/logs.go new file mode 100644 index 000000000..4071bfb07 --- /dev/null +++ b/tools/storm/e2e/scenario/logs.go @@ -0,0 +1,51 @@ +package scenario + +import ( + "os" + "path/filepath" + + "github.com/microsoft/storm" + "github.com/sirupsen/logrus" +) + +// publishLogs publishes log and metrics files as artifacts for ADO collection. +// This consolidates the display-logs and metrics collection that was previously +// done as separate YAML pipeline steps. +func (s *TridentE2EScenario) publishLogs(tc storm.TestCase) error { + // Publish logstream file + if s.args.LogstreamFile != "" { + publishFileIfExists(tc, s.args.LogstreamFile) + } + + // Publish tracestream (metrics) file from clean install + traceStreamFile := s.args.TracestreamFile + if traceStreamFile == "" { + traceStreamFile = "trident-clean-install-metrics.jsonl" + } + publishFileIfExists(tc, traceStreamFile) + + // Publish boot metrics file + publishFileIfExists(tc, bootMetricsFile) + + // Publish AB update metrics files (generated by netlisten during AB updates) + matches, err := filepath.Glob("metrics-*.jsonl") + if err == nil { + for _, m := range matches { + publishFileIfExists(tc, m) + } + } + + return nil +} + +// publishFileIfExists publishes a file as an artifact if it exists. +func publishFileIfExists(tc storm.TestCase, filePath string) { + if _, err := os.Stat(filePath); err != nil { + logrus.Debugf("Skipping artifact %s: %v", filePath, err) + return + } + + artifactName := filepath.Base(filePath) + tc.ArtifactBroker().PublishLogFile(artifactName, filePath) + logrus.Infof("Published artifact: %s", artifactName) +} diff --git a/tools/storm/e2e/scenario/metrics.go b/tools/storm/e2e/scenario/metrics.go new file mode 100644 index 000000000..88ed7d3fc --- /dev/null +++ b/tools/storm/e2e/scenario/metrics.go @@ -0,0 +1,132 @@ +package scenario + +import ( + "encoding/json" + "fmt" + "os" + "regexp" + "strconv" + "time" + + "github.com/microsoft/storm" + "github.com/sirupsen/logrus" +) + +const bootMetricsFile = "boot-metrics.jsonl" + +// bootMetric holds the parsed systemd-analyze boot timing data. +type bootMetric struct { + Operation string `json:"operation"` + FirmwareMs float64 `json:"firmware,omitempty"` + LoaderMs float64 `json:"loader,omitempty"` + KernelMs float64 `json:"kernel,omitempty"` + InitrdMs float64 `json:"initrd,omitempty"` + UserspaceMs float64 `json:"userspace,omitempty"` +} + +// bootMetricRecord represents a single boot metrics record written to the JSONL file. +type bootMetricRecord struct { + Timestamp string `json:"timestamp"` + MetricName string `json:"metric_name"` + Value bootMetric `json:"value"` +} + +// collectBootMetrics collects systemd-analyze boot timing data from the test +// host via SSH and appends it to the boot-metrics.jsonl file. +func (s *TridentE2EScenario) collectBootMetrics(tc storm.TestCase, operation string) error { + if s.sshClient == nil { + tc.Skip("No SSH client available for boot metrics collection") + return nil + } + + logrus.Infof("Collecting boot metrics (operation: %s)", operation) + + output, err := runCommand(s.sshClient, "systemd-analyze | head -n 1") + if err != nil { + logrus.WithError(err).Warn("Failed to collect boot metrics via systemd-analyze") + tc.FailFromError(fmt.Errorf("failed to run systemd-analyze: %w", err)) + return nil + } + + logrus.Infof("systemd-analyze output: %s", output) + + metric := bootMetric{Operation: operation} + if val, units, ok := parseBootTiming(output, "(firmware)"); ok { + metric.FirmwareMs, _ = toMilliseconds(val, units) + } + if val, units, ok := parseBootTiming(output, "(loader)"); ok { + metric.LoaderMs, _ = toMilliseconds(val, units) + } + if val, units, ok := parseBootTiming(output, "(kernel)"); ok { + metric.KernelMs, _ = toMilliseconds(val, units) + } + if val, units, ok := parseBootTiming(output, "(initrd)"); ok { + metric.InitrdMs, _ = toMilliseconds(val, units) + } + if val, units, ok := parseBootTiming(output, "(userspace)"); ok { + metric.UserspaceMs, _ = toMilliseconds(val, units) + } + + record := bootMetricRecord{ + Timestamp: time.Now().Format(time.RFC3339), + MetricName: "boot_info", + Value: metric, + } + + jsonBytes, err := json.Marshal(record) + if err != nil { + return fmt.Errorf("failed to marshal boot metrics: %w", err) + } + + file, err := os.OpenFile(bootMetricsFile, os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0600) + if err != nil { + return fmt.Errorf("failed to open boot metrics file: %w", err) + } + defer file.Close() + + if _, err := file.WriteString(string(jsonBytes) + "\n"); err != nil { + return fmt.Errorf("failed to write boot metrics: %w", err) + } + + logrus.Infof("Boot metrics collected: firmware=%.0fms, kernel=%.0fms, initrd=%.0fms, userspace=%.0fms", + metric.FirmwareMs, metric.KernelMs, metric.InitrdMs, metric.UserspaceMs) + + return nil +} + +// collectInstallBootMetrics collects boot metrics after the initial OS installation. +func (s *TridentE2EScenario) collectInstallBootMetrics(tc storm.TestCase) error { + return s.collectBootMetrics(tc, "clean-install") +} + +// parseBootTiming extracts the numeric value and unit preceding a target string +// in systemd-analyze output. +func parseBootTiming(text, target string) (string, string, bool) { + re := regexp.MustCompile(`([-+]?\d*\.?\d+)(.)\s+` + regexp.QuoteMeta(target)) + match := re.FindStringSubmatch(text) + if len(match) >= 3 { + return match[1], match[2], true + } + return "", "", false +} + +// toMilliseconds converts a time value with a unit suffix to milliseconds. +func toMilliseconds(value string, unit string) (float64, error) { + v, err := strconv.ParseFloat(value, 64) + if err != nil { + return 0, fmt.Errorf("failed to parse value: %s", value) + } + + switch unit { + case "s": + return v * 1000, nil + case "m": + return v * 60 * 1000, nil + case "ms": + return v, nil + case "ns": + return v / 1000000, nil + default: + return 0, fmt.Errorf("unknown time unit: %s", unit) + } +} diff --git a/tools/storm/e2e/scenario/metrics_test.go b/tools/storm/e2e/scenario/metrics_test.go new file mode 100644 index 000000000..3ffda097b --- /dev/null +++ b/tools/storm/e2e/scenario/metrics_test.go @@ -0,0 +1,91 @@ +package scenario + +import ( + "testing" +) + +func TestParseBootTiming(t *testing.T) { + tests := []struct { + name string + input string + target string + wantVal string + wantUnit string + wantFound bool + }{ + { + name: "firmware in seconds", + input: "Startup finished in 13.022s (firmware) + 2.552s (loader) + 4.740s (kernel)", + target: "(firmware)", + wantVal: "13.022", + wantUnit: "s", + wantFound: true, + }, + { + name: "loader in seconds", + input: "Startup finished in 13.022s (firmware) + 2.552s (loader) + 4.740s (kernel)", + target: "(loader)", + wantVal: "2.552", + wantUnit: "s", + wantFound: true, + }, + { + name: "kernel in seconds", + input: "Startup finished in 4.740s (kernel) + 1.267s (initrd) + 15.249s (userspace) = 35.565s", + target: "(kernel)", + wantVal: "4.740", + wantUnit: "s", + wantFound: true, + }, + { + name: "not found", + input: "Startup finished in 4.740s (kernel)", + target: "(firmware)", + wantFound: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + val, unit, found := parseBootTiming(tc.input, tc.target) + if found != tc.wantFound { + t.Errorf("found=%v, want %v", found, tc.wantFound) + } + if found { + if val != tc.wantVal { + t.Errorf("val=%q, want %q", val, tc.wantVal) + } + if unit != tc.wantUnit { + t.Errorf("unit=%q, want %q", unit, tc.wantUnit) + } + } + }) + } +} + +func TestToMilliseconds(t *testing.T) { + tests := []struct { + value string + unit string + want float64 + err bool + }{ + {"4.740", "s", 4740, false}, + {"100", "ms", 100, false}, + {"2", "m", 120000, false}, + {"500000", "ns", 0.5, false}, + {"1.5", "x", 0, true}, + } + + for _, tc := range tests { + t.Run(tc.value+tc.unit, func(t *testing.T) { + got, err := toMilliseconds(tc.value, tc.unit) + if (err != nil) != tc.err { + t.Errorf("err=%v, wantErr=%v", err, tc.err) + } + if !tc.err && got != tc.want { + t.Errorf("got=%f, want=%f", got, tc.want) + } + }) + } +} diff --git a/tools/storm/e2e/scenario/trident.go b/tools/storm/e2e/scenario/trident.go index 875f71842..20f2b5408 100644 --- a/tools/storm/e2e/scenario/trident.go +++ b/tools/storm/e2e/scenario/trident.go @@ -51,6 +51,10 @@ type TridentE2EScenario struct { originalConfig hostconfig.HostConfig // Parameters specific to this host configuration configParams TridentE2EHostConfigParams + // Test tags derived from the test-selection.yaml configuration. These + // tags (e.g. "test:base", "test:encryption") control which validation + // test cases run for this scenario. + testTags []string // Storm scenario arguments, populated when the scenario is executed. args struct { @@ -92,6 +96,7 @@ func NewTridentE2EScenario( hardware HardwareType, runtime trident.RuntimeType, testRings testrings.TestRingSet, + testTags []string, ) (*TridentE2EScenario, error) { configClone, err := config.Clone() if err != nil { @@ -107,6 +112,7 @@ func NewTridentE2EScenario( runtime: runtime, testRings: testRings, config: configClone, + testTags: testTags, }, nil } @@ -164,6 +170,24 @@ func (s *TridentE2EScenario) RuntimeType() trident.RuntimeType { return s.runtime } +// TestTags returns the test selection tags for this scenario (e.g. "test:base", +// "test:encryption"). These are derived from the configuration's +// test-selection.yaml during discovery. +func (s *TridentE2EScenario) TestTags() []string { + return s.testTags +} + +// HasTestTag reports whether the scenario has the given test tag. The tag +// should include the "test:" prefix (e.g. "test:base"). +func (s *TridentE2EScenario) HasTestTag(tag string) bool { + for _, t := range s.testTags { + if t == tag { + return true + } + } + return false +} + func (s *TridentE2EScenario) RegisterTestCases(r storm.TestRegistrar) error { if s.hardware.IsVM() { r.RegisterTestCase("install-vm-deps", s.installVmDependencies) @@ -173,11 +197,36 @@ func (s *TridentE2EScenario) RegisterTestCases(r storm.TestRegistrar) error { r.RegisterTestCase("setup-test-host", s.setupTestHost) r.RegisterTestCase("install-os", s.installOs) r.RegisterTestCase("check-trident-ssh", s.checkTridentViaSshAfterInstall) + r.RegisterTestCase("collect-install-boot-metrics", s.collectInstallBootMetrics) + + if s.HasTestTag("test:base") { + r.RegisterTestCase("validate-partitions", s.validatePartitions) + r.RegisterTestCase("validate-users", s.validateUsers) + r.RegisterTestCase("validate-uefi-fallback", s.validateUefiFallback) + } + + if s.HasTestTag("test:encryption") { + r.RegisterTestCase("validate-encryption", s.validateEncryption) + } + + if s.HasTestTag("test:root_verity") || s.HasTestTag("test:usr_verity") { + r.RegisterTestCase("validate-verity", s.validateVerity) + } + + if s.HasTestTag("test:extensions") { + r.RegisterTestCase("validate-extensions", s.validateExtensions) + } + + if s.HasTestTag("test:rollback") { + r.RegisterTestCase("validate-rollback", s.validateRollback) + } if s.originalConfig.HasABUpdate() { s.addAbUpdateTests(r, "ab-update-1") s.addSplitABUpdateTests(r, "ab-update-split") } + + r.RegisterTestCase("publish-logs", s.publishLogs) return nil } diff --git a/tools/storm/e2e/scenario/trident_test.go b/tools/storm/e2e/scenario/trident_test.go new file mode 100644 index 000000000..57895c08f --- /dev/null +++ b/tools/storm/e2e/scenario/trident_test.go @@ -0,0 +1,339 @@ +package scenario + +import ( + "testing" + "tridenttools/pkg/hostconfig" + "tridenttools/storm/e2e/testrings" + "tridenttools/storm/utils/trident" + + "github.com/microsoft/storm" +) + +// mockRegistrar records the names of test cases registered via RegisterTestCase. +type mockRegistrar struct { + names []string +} + +func (m *mockRegistrar) RegisterTestCase(name string, _ storm.TestCaseFunction) { + m.names = append(m.names, name) +} + +func (m *mockRegistrar) has(name string) bool { + for _, n := range m.names { + if n == name { + return true + } + } + return false +} + +func newTestScenario(t *testing.T, testTags []string) *TridentE2EScenario { + t.Helper() + hc, err := hostconfig.NewHostConfigFromYaml([]byte(`os: {}`)) + if err != nil { + t.Fatalf("failed to create host config: %v", err) + } + s, err := NewTridentE2EScenario( + "test-scenario_vm-host", + []string{"e2e", "vm", "host", "test:base"}, + hc, + TridentE2EHostConfigParams{}, + HardwareTypeVM, + trident.RuntimeTypeHost, + testrings.TestRingSet{testrings.TestRingPre}, + testTags, + ) + if err != nil { + t.Fatalf("failed to create scenario: %v", err) + } + return s +} + +func TestTridentE2EScenario_TestTags(t *testing.T) { + tags := []string{"test:base", "test:encryption"} + s := newTestScenario(t, tags) + + got := s.TestTags() + if len(got) != 2 { + t.Fatalf("expected 2 test tags, got %d", len(got)) + } + if got[0] != "test:base" || got[1] != "test:encryption" { + t.Errorf("expected [test:base test:encryption], got %v", got) + } +} + +func TestTridentE2EScenario_TestTagsEmpty(t *testing.T) { + s := newTestScenario(t, nil) + got := s.TestTags() + if len(got) != 0 { + t.Fatalf("expected empty test tags, got %v", got) + } +} + +func TestTridentE2EScenario_HasTestTag(t *testing.T) { + tags := []string{"test:base", "test:encryption", "test:verity"} + s := newTestScenario(t, tags) + + if !s.HasTestTag("test:base") { + t.Error("expected HasTestTag('test:base') to be true") + } + if !s.HasTestTag("test:encryption") { + t.Error("expected HasTestTag('test:encryption') to be true") + } + if !s.HasTestTag("test:verity") { + t.Error("expected HasTestTag('test:verity') to be true") + } + if s.HasTestTag("test:rollback") { + t.Error("expected HasTestTag('test:rollback') to be false") + } + if s.HasTestTag("base") { + t.Error("expected HasTestTag('base') without prefix to be false") + } + if s.HasTestTag("") { + t.Error("expected HasTestTag('') to be false") + } +} + +func TestTridentE2EScenario_TagsIncludeTestTags(t *testing.T) { + testTags := []string{"test:base", "test:encryption"} + hc, _ := hostconfig.NewHostConfigFromYaml([]byte(`os: {}`)) + s, err := NewTridentE2EScenario( + "combined_vm-host", + []string{"e2e", "vm", "host", "pre", "test:base", "test:encryption"}, + hc, + TridentE2EHostConfigParams{}, + HardwareTypeVM, + trident.RuntimeTypeHost, + testrings.TestRingSet{testrings.TestRingPre}, + testTags, + ) + if err != nil { + t.Fatalf("failed to create scenario: %v", err) + } + + // Verify that tags contain both scenario tags and test tags. + allTags := s.Tags() + tagSet := make(map[string]bool) + for _, tag := range allTags { + tagSet[tag] = true + } + + if !tagSet["e2e"] { + t.Error("expected 'e2e' in tags") + } + if !tagSet["test:base"] { + t.Error("expected 'test:base' in tags") + } + if !tagSet["test:encryption"] { + t.Error("expected 'test:encryption' in tags") + } +} + +// newTestScenarioWithConfig creates a scenario with the given YAML config and test tags. +func newTestScenarioWithConfig(t *testing.T, configYAML string, testTags []string) *TridentE2EScenario { + t.Helper() + hc, err := hostconfig.NewHostConfigFromYaml([]byte(configYAML)) + if err != nil { + t.Fatalf("failed to create host config: %v", err) + } + s, err := NewTridentE2EScenario( + "test-scenario_vm-host", + []string{"e2e", "vm", "host", "test:base"}, + hc, + TridentE2EHostConfigParams{}, + HardwareTypeVM, + trident.RuntimeTypeHost, + testrings.TestRingSet{testrings.TestRingPre}, + testTags, + ) + if err != nil { + t.Fatalf("failed to create scenario: %v", err) + } + return s +} + +// minimalAbUpdateConfig is a minimal host config with an abUpdate section. +const minimalAbUpdateConfig = ` +os: {} +storage: + abUpdate: + volumePairs: + - id: root + volumeAId: root-a + volumeBId: root-b +` + +// TestRegisterTestCases_Phase3_Verity verifies that validate-verity is +// registered when either test:root_verity or test:usr_verity tag is present. +func TestRegisterTestCases_Phase3_Verity(t *testing.T) { + tests := []struct { + name string + tags []string + expectIt bool + }{ + {"root_verity", []string{"test:base", "test:root_verity"}, true}, + {"usr_verity", []string{"test:base", "test:usr_verity"}, true}, + {"both_verity", []string{"test:base", "test:root_verity", "test:usr_verity"}, true}, + {"no_verity", []string{"test:base"}, false}, + {"encryption_only", []string{"test:base", "test:encryption"}, false}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + s := newTestScenarioWithConfig(t, `os: {}`, tc.tags) + r := &mockRegistrar{} + if err := s.RegisterTestCases(r); err != nil { + t.Fatalf("RegisterTestCases failed: %v", err) + } + if got := r.has("validate-verity"); got != tc.expectIt { + t.Errorf("validate-verity registered=%v, want %v", got, tc.expectIt) + } + }) + } +} + +// TestRegisterTestCases_Phase3_Extensions verifies that validate-extensions is +// registered when test:extensions tag is present. +func TestRegisterTestCases_Phase3_Extensions(t *testing.T) { + tests := []struct { + name string + tags []string + expectIt bool + }{ + {"with_extensions", []string{"test:base", "test:extensions"}, true}, + {"without_extensions", []string{"test:base"}, false}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + s := newTestScenarioWithConfig(t, `os: {}`, tc.tags) + r := &mockRegistrar{} + if err := s.RegisterTestCases(r); err != nil { + t.Fatalf("RegisterTestCases failed: %v", err) + } + if got := r.has("validate-extensions"); got != tc.expectIt { + t.Errorf("validate-extensions registered=%v, want %v", got, tc.expectIt) + } + }) + } +} + +// TestRegisterTestCases_Phase3_Rollback verifies that validate-rollback is +// registered when test:rollback tag is present. +func TestRegisterTestCases_Phase3_Rollback(t *testing.T) { + tests := []struct { + name string + tags []string + expectIt bool + }{ + {"with_rollback", []string{"test:rollback"}, true}, + {"without_rollback", []string{"test:base"}, false}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + s := newTestScenarioWithConfig(t, `os: {}`, tc.tags) + r := &mockRegistrar{} + if err := s.RegisterTestCases(r); err != nil { + t.Fatalf("RegisterTestCases failed: %v", err) + } + if got := r.has("validate-rollback"); got != tc.expectIt { + t.Errorf("validate-rollback registered=%v, want %v", got, tc.expectIt) + } + }) + } +} + +// TestRegisterTestCases_Phase3_AbStaged verifies that the split AB update tests +// (including validate-staged) are registered when the config has an AB update section. +func TestRegisterTestCases_Phase3_AbStaged(t *testing.T) { + tests := []struct { + name string + configYAML string + expectIt bool + }{ + {"with_ab_update", minimalAbUpdateConfig, true}, + {"without_ab_update", `os: {}`, false}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + s := newTestScenarioWithConfig(t, tc.configYAML, []string{"test:base"}) + r := &mockRegistrar{} + if err := s.RegisterTestCases(r); err != nil { + t.Fatalf("RegisterTestCases failed: %v", err) + } + if got := r.has("ab-update-split-validate-staged"); got != tc.expectIt { + t.Errorf("ab-update-split-validate-staged registered=%v, want %v", got, tc.expectIt) + } + }) + } +} + +// TestRegisterTestCases_AllPhase3_FeatureParity validates that for each of the +// 19 configuration profiles, the correct Phase 3 test cases are registered. This +// is the comprehensive feature-parity check against the original pytest test suite. +func TestRegisterTestCases_AllPhase3_FeatureParity(t *testing.T) { + type configProfile struct { + name string + configYAML string + testTags []string + // Expected Phase 3 test registrations + expectVerity bool + expectExtensions bool + expectRollback bool + expectAbStaged bool + } + + noAB := `os: {}` + + profiles := []configProfile{ + {"base", minimalAbUpdateConfig, []string{"test:base"}, false, false, false, true}, + {"simple", noAB, []string{"test:base"}, false, false, false, false}, + {"combined", minimalAbUpdateConfig, []string{"test:base", "test:usr_verity", "test:encryption", "test:uki"}, true, false, false, true}, + {"encrypted-partition", noAB, []string{"test:base", "test:encryption"}, false, false, false, false}, + {"encrypted-raid", noAB, []string{"test:base", "test:encryption"}, false, false, false, false}, + {"encrypted-swap", noAB, []string{"test:base", "test:encryption"}, false, false, false, false}, + {"extensions", minimalAbUpdateConfig, []string{"test:base", "test:extensions"}, false, true, false, true}, + {"health-checks-install", noAB, []string{"test:rollback"}, false, false, true, false}, + {"memory-constraint-combined", minimalAbUpdateConfig, []string{"test:base", "test:usr_verity", "test:encryption", "test:uki"}, true, false, false, true}, + {"misc", minimalAbUpdateConfig, []string{"test:base"}, false, false, false, true}, + {"raid-big", noAB, []string{"test:base"}, false, false, false, false}, + {"raid-mirrored", minimalAbUpdateConfig, []string{"test:base"}, false, false, false, true}, + {"raid-resync-small", minimalAbUpdateConfig, []string{"test:base"}, false, false, false, true}, + {"raid-small", minimalAbUpdateConfig, []string{"test:base"}, false, false, false, true}, + {"rerun", minimalAbUpdateConfig, []string{"test:base", "test:usr_verity", "test:encryption", "test:uki"}, true, false, false, true}, + {"root-verity", minimalAbUpdateConfig, []string{"test:base", "test:root_verity", "test:extensions"}, true, true, false, true}, + {"split", noAB, []string{"test:base"}, false, false, false, false}, + {"usr-verity", minimalAbUpdateConfig, []string{"test:base", "test:usr_verity", "test:uki"}, true, false, false, true}, + {"usr-verity-raid", noAB, []string{"test:base", "test:usr_verity", "test:uki"}, true, false, false, false}, + } + + for _, p := range profiles { + t.Run(p.name, func(t *testing.T) { + s := newTestScenarioWithConfig(t, p.configYAML, p.testTags) + r := &mockRegistrar{} + if err := s.RegisterTestCases(r); err != nil { + t.Fatalf("RegisterTestCases failed: %v", err) + } + + check := func(testCaseName string, expected bool) { + if got := r.has(testCaseName); got != expected { + t.Errorf("%s registered=%v, want %v (registered: %v)", testCaseName, got, expected, r.names) + } + } + + check("validate-verity", p.expectVerity) + check("validate-extensions", p.expectExtensions) + check("validate-rollback", p.expectRollback) + check("ab-update-split-validate-staged", p.expectAbStaged) + + // Phase 4: metrics and log collection are always registered + check("collect-install-boot-metrics", true) + check("publish-logs", true) + // AB update boot metrics are registered when AB update is configured + check("ab-update-1-collect-boot-metrics", p.expectAbStaged) + check("ab-update-split-collect-boot-metrics", p.expectAbStaged) + }) + } +} diff --git a/tools/storm/e2e/scenario/validate_ab_staged.go b/tools/storm/e2e/scenario/validate_ab_staged.go new file mode 100644 index 000000000..204a11885 --- /dev/null +++ b/tools/storm/e2e/scenario/validate_ab_staged.go @@ -0,0 +1,60 @@ +package scenario + +import ( + "fmt" + + "github.com/microsoft/storm" + "github.com/sirupsen/logrus" + + "tridenttools/storm/utils/trident" +) + +// validateAbStaged validates that an A/B update has been staged correctly +// on the remote host. Converted from ab_update_staged_test.py test_ab_update_staged. +// +// It validates: +// - servicingState is "ab-update-staged" +// - abActiveVolume is unchanged from pre-update (volume-a) +func (s *TridentE2EScenario) validateAbStaged(tc storm.TestCase) error { + if err := s.populateSshClient(tc.Context()); err != nil { + return fmt.Errorf("failed to populate SSH client: %w", err) + } + + // Get host status via trident get + tridentOut, err := trident.InvokeTrident(s.runtime, s.sshClient, nil, "get") + if err != nil { + return fmt.Errorf("failed to run trident get: %w", err) + } + + if tridentOut.Status != 0 { + return fmt.Errorf("trident get failed with status %d: %s", + tridentOut.Status, tridentOut.Stderr) + } + + hostStatus, err := ParseTridentGetOutput(tridentOut.Stdout) + if err != nil { + return fmt.Errorf("failed to parse trident get output: %w", err) + } + + // Validate servicingState is "ab-update-staged" + servicingState, _ := hostStatus["servicingState"].(string) + if servicingState != "ab-update-staged" { + tc.Fail(fmt.Sprintf("expected servicingState %q, got %q", + "ab-update-staged", servicingState)) + return nil + } + logrus.Info("servicingState matches expected: ab-update-staged") + + // Validate abActiveVolume is unchanged (should still be volume-a after staging) + expectedVolume := "volume-a" + hsActiveVolume, _ := hostStatus["abActiveVolume"].(string) + if hsActiveVolume != expectedVolume { + tc.Fail(fmt.Sprintf("expected abActiveVolume %q (unchanged from pre-update), got %q", + expectedVolume, hsActiveVolume)) + return nil + } + logrus.Infof("abActiveVolume unchanged: %s", expectedVolume) + + logrus.Info("A/B update staged validation passed") + return nil +} diff --git a/tools/storm/e2e/scenario/validate_base.go b/tools/storm/e2e/scenario/validate_base.go new file mode 100644 index 000000000..b0660163b --- /dev/null +++ b/tools/storm/e2e/scenario/validate_base.go @@ -0,0 +1,480 @@ +package scenario + +import ( + "fmt" + "math" + "strconv" + "strings" + "unicode" + + "github.com/microsoft/storm" + "github.com/sirupsen/logrus" + + "tridenttools/storm/utils/trident" +) + +// parseSizeToBytes converts a partition size string (e.g. "8G", "512M", "1024") +// to bytes, matching the Python SizeUnit enum from base_test.py. +func parseSizeToBytes(sizeStr string) (float64, error) { + if sizeStr == "" { + return 0, fmt.Errorf("empty size string") + } + + lastChar := rune(sizeStr[len(sizeStr)-1]) + if unicode.IsLetter(lastChar) { + numberStr := sizeStr[:len(sizeStr)-1] + number, err := strconv.ParseFloat(numberStr, 64) + if err != nil { + return 0, fmt.Errorf("failed to parse size number %q: %w", numberStr, err) + } + + unitMultipliers := map[rune]float64{ + 'B': 1, + 'K': math.Pow(1024, 1), + 'M': math.Pow(1024, 2), + 'G': math.Pow(1024, 3), + 'T': math.Pow(1024, 4), + 'P': math.Pow(1024, 5), + } + + multiplier, ok := unitMultipliers[unicode.ToUpper(lastChar)] + if !ok { + return 0, fmt.Errorf("unknown size unit %q", string(lastChar)) + } + + return number * multiplier, nil + } + + return strconv.ParseFloat(sizeStr, 64) +} + +// validatePartitions validates that disk partitions on the remote host match +// the expected host configuration. Converted from base_test.py test_partitions. +// +// It runs blkid, lsblk, mount, and trident get on the remote host, then checks: +// - Each expected partition (by PARTLABEL) is present in system info and host status +// - servicingState is "provisioned" +// - For A/B update configs: validates root mount is on the correct active volume +func (s *TridentE2EScenario) validatePartitions(tc storm.TestCase) error { + if err := s.populateSshClient(tc.Context()); err != nil { + return fmt.Errorf("failed to populate SSH client: %w", err) + } + + // --- 1. Build expected partitions from host configuration --- + expectedPartitions := make(map[string]float64) + + for _, disk := range s.originalConfig.S("storage", "disks").Children() { + for _, part := range disk.S("partitions").Children() { + id, ok := part.S("id").Data().(string) + if !ok { + continue + } + + sizeStr, _ := part.S("size").Data().(string) + sizeBytes, err := parseSizeToBytes(sizeStr) + if err != nil { + logrus.WithError(err).Warnf("Failed to parse size for partition %s", id) + sizeBytes = 0 + } + + expectedPartitions[id] = sizeBytes + } + } + + logrus.Infof("Expected %d partitions from host configuration", len(expectedPartitions)) + + // --- 2. Run blkid and parse output --- + blkidOut, err := sudoCommand(s.sshClient, "blkid") + if err != nil { + return fmt.Errorf("failed to run blkid: %w", err) + } + + blkidEntries := ParseBlkid(blkidOut) + + // --- 3. Run lsblk -J -b and parse output --- + lsblkOut, err := runCommand(s.sshClient, "lsblk -J -b") + if err != nil { + return fmt.Errorf("failed to run lsblk: %w", err) + } + + lsblkData, err := ParseLsblk(lsblkOut) + if err != nil { + return fmt.Errorf("failed to parse lsblk output: %w", err) + } + + // --- 4. Merge lsblk info into blkid entries, then index by PARTLABEL --- + for _, lsblkPart := range lsblkData.FlattenPartitions() { + if _, exists := blkidEntries[lsblkPart.Name]; !exists { + blkidEntries[lsblkPart.Name] = BlkidEntry{ + Properties: make(map[string]string), + } + } + + entry := blkidEntries[lsblkPart.Name] + entry.Properties["lsblk_size"] = lsblkPart.Size.String() + entry.Properties["lsblk_name"] = lsblkPart.Name + entry.Properties["lsblk_type"] = lsblkPart.Type + blkidEntries[lsblkPart.Name] = entry + } + + partitionsByLabel := make(map[string]BlkidEntry) + for _, entry := range blkidEntries { + if label, ok := entry.Properties["PARTLABEL"]; ok { + partitionsByLabel[label] = entry + } + } + + // --- 5. Get host status via trident get --- + tridentOut, err := trident.InvokeTrident(s.runtime, s.sshClient, nil, "get") + if err != nil { + return fmt.Errorf("failed to run trident get: %w", err) + } + + if tridentOut.Status != 0 { + return fmt.Errorf("trident get failed with status %d: %s", tridentOut.Status, tridentOut.Stderr) + } + + hostStatus, err := ParseTridentGetOutput(tridentOut.Stdout) + if err != nil { + return fmt.Errorf("failed to parse trident get output: %w", err) + } + + // --- 6. Check servicing state --- + servicingState, _ := hostStatus["servicingState"].(string) + if servicingState != "provisioned" { + tc.Fail(fmt.Sprintf("expected servicingState 'provisioned', got %q", servicingState)) + return nil + } + + // --- 7. Check that each expected partition is present --- + partitionPaths, _ := hostStatus["partitionPaths"].(map[interface{}]interface{}) + for partID := range expectedPartitions { + if _, ok := partitionPaths[partID]; !ok { + tc.Fail(fmt.Sprintf("partition %q not found in host status partitionPaths", partID)) + return nil + } + + if _, ok := partitionsByLabel[partID]; !ok { + tc.Fail(fmt.Sprintf("partition %q not found in system partition info (by PARTLABEL)", partID)) + return nil + } + } + + // --- 8. Find root device from mount --- + mountOut, err := runCommand(s.sshClient, "mount") + if err != nil { + return fmt.Errorf("failed to run mount: %w", err) + } + + mountEntries := ParseMount(mountOut) + rootDevicePath := FindRootDevice(mountEntries) + + // --- 9. A/B update validation --- + spec, _ := hostStatus["spec"].(map[interface{}]interface{}) + if spec == nil { + logrus.Info("Partition validation passed (no spec in host status)") + return nil + } + + storage, _ := spec["storage"].(map[interface{}]interface{}) + if storage == nil { + logrus.Info("Partition validation passed (no storage in spec)") + return nil + } + + if _, hasABUpdate := storage["abUpdate"]; !hasABUpdate { + logrus.Info("Partition validation passed (no A/B update configured)") + return nil + } + + // After initial install, the active volume is always volume-a. + abActiveVolume := "volume-a" + + // Find root mount ID from filesystems + var rootMountID string + filesystems, _ := storage["filesystems"].([]interface{}) + for _, fsRaw := range filesystems { + fs, _ := fsRaw.(map[interface{}]interface{}) + if fs == nil { + continue + } + mp, _ := fs["mountPoint"].(map[interface{}]interface{}) + if mp == nil { + continue + } + if path, _ := mp["path"].(string); path == "/" { + rootMountID, _ = fs["deviceId"].(string) + break + } + } + + if rootMountID == "" { + tc.Fail("root mount point not found in host status filesystems") + return nil + } + + logrus.Infof("Root mount point ID: %s", rootMountID) + + // Check for verity device on root mount + var verityDeviceName, verityDataDeviceID string + verityList, _ := storage["verity"].([]interface{}) + for _, vRaw := range verityList { + v, _ := vRaw.(map[interface{}]interface{}) + if v == nil { + continue + } + if vID, _ := v["id"].(string); vID == rootMountID { + verityDeviceName, _ = v["name"].(string) + verityDataDeviceID, _ = v["dataDeviceId"].(string) + logrus.Infof("Found verity device with matching ID %q", rootMountID) + break + } + } + + logrus.Infof("Verity device name: %s, data device ID: %s", verityDeviceName, verityDataDeviceID) + + // Determine A/B volume ID: if verity, use the data device; otherwise, use root mount + abVolumeID := rootMountID + if verityDataDeviceID != "" { + abVolumeID = verityDataDeviceID + } + + logrus.Infof("Root A/B volume ID: %s", abVolumeID) + + // For non-verity configs, validate the active volume block device path + if verityDeviceName == "" { + var activeVolumeID string + abUpdate, _ := storage["abUpdate"].(map[interface{}]interface{}) + volumePairs, _ := abUpdate["volumePairs"].([]interface{}) + + for _, vpRaw := range volumePairs { + vp, _ := vpRaw.(map[interface{}]interface{}) + if vp == nil { + continue + } + if vpID, _ := vp["id"].(string); vpID == abVolumeID { + logrus.Infof("Found volume pair: %s", abVolumeID) + if abActiveVolume == "volume-a" { + activeVolumeID, _ = vp["volumeAId"].(string) + } else { + activeVolumeID, _ = vp["volumeBId"].(string) + } + logrus.Infof("Active volume ID: %s", activeVolumeID) + break + } + } + + if activeVolumeID == "" { + tc.Fail("active volume ID not found for root A/B volume pair") + return nil + } + + activeIsPartition := IsPartition(hostStatus, activeVolumeID) + activeIsRaid := IsRaid(hostStatus, activeVolumeID) + if activeIsPartition == activeIsRaid { + tc.Fail(fmt.Sprintf("active volume %q must be either a partition or RAID (not both/neither): partition=%v, raid=%v", + activeVolumeID, activeIsPartition, activeIsRaid)) + return nil + } + + // Resolve the expected root device path + var expectedRootPath string + + if activeIsPartition { + canonicalName := rootDevicePath + if idx := strings.LastIndex(rootDevicePath, "/"); idx >= 0 { + canonicalName = rootDevicePath[idx+1:] + } + if entry, ok := blkidEntries[canonicalName]; ok { + if partuuid, ok := entry.Properties["PARTUUID"]; ok { + expectedRootPath = fmt.Sprintf("/dev/disk/by-partuuid/%s", partuuid) + } + } + } else if activeIsRaid { + raidName, err := GetRaidNameFromDeviceName(s.sshClient, rootDevicePath) + if err != nil { + return fmt.Errorf("failed to get RAID name for %q: %w", rootDevicePath, err) + } + expectedRootPath = raidName + } + + // Verify that the active volume path matches in partitionPaths + for bdevID, bdevPathRaw := range partitionPaths { + bdevIDStr, _ := bdevID.(string) + if bdevIDStr == activeVolumeID { + bdevPath, _ := bdevPathRaw.(string) + if bdevPath != expectedRootPath { + tc.Fail(fmt.Sprintf("active volume path mismatch for %q: expected %q, got %q", + activeVolumeID, expectedRootPath, bdevPath)) + return nil + } + } + } + + // Verify abActiveVolume from host status + hsActiveVolume, _ := hostStatus["abActiveVolume"].(string) + if hsActiveVolume != abActiveVolume { + tc.Fail(fmt.Sprintf("abActiveVolume mismatch: expected %q, got %q", abActiveVolume, hsActiveVolume)) + return nil + } + } + + logrus.Info("Partition validation passed") + return nil +} + +// validateUsers validates that users and groups on the remote host match the +// expected host configuration. Converted from base_test.py test_users. +// +// It reads /etc/passwd and /etc/group on the remote host, then checks: +// - Each expected user (from os.users) is present in /etc/passwd +// - Each expected group membership is present in /etc/group +func (s *TridentE2EScenario) validateUsers(tc storm.TestCase) error { + if err := s.populateSshClient(tc.Context()); err != nil { + return fmt.Errorf("failed to populate SSH client: %w", err) + } + + // --- 1. Build expected users and group memberships from host configuration --- + var expectedUsers []string + expectedGroups := make(map[string][]string) // group name โ†’ list of usernames + + for _, user := range s.originalConfig.S("os", "users").Children() { + name, ok := user.S("name").Data().(string) + if !ok { + continue + } + expectedUsers = append(expectedUsers, name) + + for _, group := range user.S("groups").Children() { + groupName, ok := group.Data().(string) + if !ok { + continue + } + expectedGroups[groupName] = append(expectedGroups[groupName], name) + } + } + + logrus.Infof("Expected %d users and %d group memberships from host configuration", + len(expectedUsers), len(expectedGroups)) + + // --- 2. Read and parse /etc/passwd --- + passwdOut, err := runCommand(s.sshClient, "cat /etc/passwd") + if err != nil { + return fmt.Errorf("failed to read /etc/passwd: %w", err) + } + + systemUsers := ParsePasswd(passwdOut) + + // --- 3. Check that each expected user exists --- + for _, user := range expectedUsers { + if _, ok := systemUsers[user]; !ok { + tc.Fail(fmt.Sprintf("expected user %q not found in /etc/passwd", user)) + return nil + } + } + + // --- 4. Read and parse /etc/group --- + groupOut, err := runCommand(s.sshClient, "cat /etc/group") + if err != nil { + return fmt.Errorf("failed to read /etc/group: %w", err) + } + + systemGroups := ParseGroup(groupOut) + + // --- 5. Check that each expected group exists and contains expected members --- + for groupName, expectedMembers := range expectedGroups { + groupEntry, ok := systemGroups[groupName] + if !ok { + tc.Fail(fmt.Sprintf("expected group %q not found in /etc/group", groupName)) + return nil + } + + memberSet := make(map[string]bool) + for _, m := range groupEntry.Members { + memberSet[m] = true + } + + for _, user := range expectedMembers { + if !memberSet[user] { + tc.Fail(fmt.Sprintf("expected user %q not found in group %q (members: %v)", + user, groupName, groupEntry.Members)) + return nil + } + } + } + + logrus.Info("User validation passed") + return nil +} + +// validateUefiFallback validates the UEFI fallback boot configuration on the +// remote host. Converted from base_test.py test_uefi_fallback. +// +// It checks the uefiFallback mode from the host configuration: +// - "disabled": verifies /efi/boot/EFI/BOOT is empty +// - "conservative" or "optimistic": verifies /efi/boot/EFI/BOOT/* matches +// /efi/azl/EFI//* via diff +func (s *TridentE2EScenario) validateUefiFallback(tc storm.TestCase) error { + if err := s.populateSshClient(tc.Context()); err != nil { + return fmt.Errorf("failed to populate SSH client: %w", err) + } + + // --- 1. Determine the uefiFallback mode --- + mode := "conservative" // default + if modeVal, ok := s.originalConfig.S("os", "uefiFallback").Data().(string); ok { + mode = modeVal + } + + logrus.Infof("UEFI fallback mode: %s", mode) + + switch mode { + case "disabled": + // Verify /efi/boot/EFI/BOOT is empty: find should find no files + _, err := sudoCommand(s.sshClient, "find /efi/boot/EFI/BOOT/* && exit 1 || exit 0") + if err != nil { + tc.Fail(fmt.Sprintf("expected /efi/boot/EFI/BOOT to be empty, but find succeeded or errored: %v", err)) + return nil + } + + case "conservative", "optimistic": + // Get the current boot entry name via efibootmgr + efiOut, err := sudoCommand(s.sshClient, "efibootmgr") + if err != nil { + return fmt.Errorf("failed to run efibootmgr: %w", err) + } + + efiInfo := ParseEfiBootMgr(efiOut) + + if efiInfo.BootCurrent == "" { + tc.Fail("BootCurrent not found in efibootmgr output") + return nil + } + + currentBootName := efiInfo.CurrentBootName() + if currentBootName == "" { + tc.Fail(fmt.Sprintf("could not determine boot name for BootCurrent %q", efiInfo.BootCurrent)) + return nil + } + + logrus.Infof("Current boot entry: %s, name: %s", efiInfo.BootCurrent, currentBootName) + + // Compare /efi/boot/EFI/BOOT/* with /efi/azl/EFI//* + // Replicates the exact command from the Python test (base_test.py test_uefi_fallback). + diffCmd := fmt.Sprintf( + "diff /efi/boot/EFI/BOOT/* /efi/azl/EFI/%s/* && exit 1 || exit 0", + currentBootName, + ) + _, err = sudoCommand(s.sshClient, diffCmd) + if err != nil { + tc.Fail(fmt.Sprintf("UEFI fallback diff check failed: %v", err)) + return nil + } + + default: + tc.Fail(fmt.Sprintf("unknown uefiFallback mode: %q", mode)) + return nil + } + + logrus.Info("UEFI fallback validation passed") + return nil +} diff --git a/tools/storm/e2e/scenario/validate_base_test.go b/tools/storm/e2e/scenario/validate_base_test.go new file mode 100644 index 000000000..92eb3175f --- /dev/null +++ b/tools/storm/e2e/scenario/validate_base_test.go @@ -0,0 +1,47 @@ +package scenario + +import ( + "math" + "testing" +) + +func TestParseSizeToBytes(t *testing.T) { + tests := []struct { + name string + input string + expected float64 + wantErr bool + }{ + {"bytes numeric", "1024", 1024, false}, + {"bytes unit", "512B", 512, false}, + {"kilobytes", "1K", 1024, false}, + {"megabytes", "512M", 512 * math.Pow(1024, 2), false}, + {"gigabytes", "8G", 8 * math.Pow(1024, 3), false}, + {"terabytes", "1T", math.Pow(1024, 4), false}, + {"petabytes", "1P", math.Pow(1024, 5), false}, + {"fractional", "1.5G", 1.5 * math.Pow(1024, 3), false}, + {"lowercase", "8g", 8 * math.Pow(1024, 3), false}, + {"empty string", "", 0, true}, + {"invalid unit", "8X", 0, true}, + {"invalid number", "abcG", 0, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := parseSizeToBytes(tt.input) + if tt.wantErr { + if err == nil { + t.Errorf("parseSizeToBytes(%q) expected error, got nil", tt.input) + } + return + } + if err != nil { + t.Errorf("parseSizeToBytes(%q) unexpected error: %v", tt.input, err) + return + } + if result != tt.expected { + t.Errorf("parseSizeToBytes(%q) = %v, want %v", tt.input, result, tt.expected) + } + }) + } +} diff --git a/tools/storm/e2e/scenario/validate_common.go b/tools/storm/e2e/scenario/validate_common.go new file mode 100644 index 000000000..249ac99ea --- /dev/null +++ b/tools/storm/e2e/scenario/validate_common.go @@ -0,0 +1,785 @@ +package scenario + +import ( + "encoding/json" + "fmt" + "regexp" + "strings" + "tridenttools/storm/utils/sshutils" + + "github.com/sirupsen/logrus" + "golang.org/x/crypto/ssh" + "gopkg.in/yaml.v2" +) + +// --- SSH command helpers --- + +// sudoCommand runs a command with sudo on the remote host and returns the +// trimmed stdout. Returns an error if the command fails with a non-zero exit. +func sudoCommand(client *ssh.Client, cmd string) (string, error) { + fullCmd := fmt.Sprintf("sudo %s", cmd) + logrus.WithField("command", fullCmd).Debug("Running remote command") + + out, err := sshutils.RunCommand(client, fullCmd) + if err != nil { + return "", fmt.Errorf("failed to run command %q: %w", fullCmd, err) + } + + if err := out.Check(); err != nil { + return "", fmt.Errorf("command %q failed (status %d): %s\nstderr: %s", + fullCmd, out.Status, err, out.Stderr) + } + + return strings.TrimSpace(out.Stdout), nil +} + +// runCommand runs a command (without sudo) on the remote host and returns the +// trimmed stdout. Returns an error if the command fails with a non-zero exit. +func runCommand(client *ssh.Client, cmd string) (string, error) { + logrus.WithField("command", cmd).Debug("Running remote command") + + out, err := sshutils.RunCommand(client, cmd) + if err != nil { + return "", fmt.Errorf("failed to run command %q: %w", cmd, err) + } + + if err := out.Check(); err != nil { + return "", fmt.Errorf("command %q failed (status %d): %s\nstderr: %s", + cmd, out.Status, err, out.Stderr) + } + + return strings.TrimSpace(out.Stdout), nil +} + +// --- blkid parser --- + +// BlkidEntry holds parsed properties for a single block device from blkid output. +type BlkidEntry struct { + DevicePath string + Properties map[string]string +} + +// ParseBlkid parses the output of `blkid` (standard format, one device per line). +// Example line: +// +// /dev/sda1: UUID="D920-8BA4" BLOCK_SIZE="512" TYPE="vfat" PARTLABEL="esp" PARTUUID="6fcc..." +// +// Returns a map keyed by the short device name (e.g. "sda1") to BlkidEntry. +func ParseBlkid(stdout string) map[string]BlkidEntry { + entries := make(map[string]BlkidEntry) + + for _, line := range strings.Split(stdout, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + + parts := strings.SplitN(line, ": ", 2) + if len(parts) < 1 { + continue + } + + devPath := parts[0] + // Extract short name: /dev/sda1 โ†’ sda1, /dev/mapper/root โ†’ root + shortName := devPath + if idx := strings.LastIndex(devPath, "/"); idx >= 0 { + shortName = devPath[idx+1:] + } + + props := make(map[string]string) + if len(parts) == 2 { + for _, token := range splitBlkidFields(parts[1]) { + kv := strings.SplitN(token, "=", 2) + if len(kv) == 2 { + props[kv[0]] = strings.Trim(kv[1], "\"") + } + } + } + + entries[shortName] = BlkidEntry{ + DevicePath: devPath, + Properties: props, + } + } + + return entries +} + +// splitBlkidFields splits a blkid property string respecting quoted values. +func splitBlkidFields(s string) []string { + var fields []string + var current strings.Builder + inQuote := false + + for _, ch := range s { + switch { + case ch == '"': + inQuote = !inQuote + current.WriteRune(ch) + case ch == ' ' && !inQuote: + if current.Len() > 0 { + fields = append(fields, current.String()) + current.Reset() + } + default: + current.WriteRune(ch) + } + } + if current.Len() > 0 { + fields = append(fields, current.String()) + } + + return fields +} + +// ParseBlkidExport parses the output of `blkid --output export`. +// Returns a map keyed by DEVNAME (e.g. "/dev/md127") to properties. +func ParseBlkidExport(stdout string) map[string]map[string]string { + devs := make(map[string]map[string]string) + var currentDev string + + for _, line := range strings.Split(stdout, "\n") { + line = strings.TrimSpace(line) + if line == "" { + currentDev = "" + continue + } + + kv := strings.SplitN(line, "=", 2) + if len(kv) != 2 { + continue + } + + if kv[0] == "DEVNAME" { + currentDev = kv[1] + devs[currentDev] = make(map[string]string) + } else if currentDev != "" { + devs[currentDev][kv[0]] = kv[1] + } + } + + return devs +} + +// --- lsblk JSON parser --- + +// LsblkOutput represents the top-level JSON from `lsblk -J -b`. +type LsblkOutput struct { + BlockDevices []LsblkDevice `json:"blockdevices"` +} + +// LsblkDevice represents a block device in lsblk JSON output. +type LsblkDevice struct { + Name string `json:"name"` + MajMin string `json:"maj:min"` + Rm bool `json:"rm"` + Size json.Number `json:"size"` + Ro bool `json:"ro"` + Type string `json:"type"` + MountPoints []interface{} `json:"mountpoints"` + Children []LsblkDevice `json:"children,omitempty"` +} + +// ParseLsblk parses JSON output from `lsblk -J -b` and returns the structure. +func ParseLsblk(stdout string) (*LsblkOutput, error) { + var output LsblkOutput + if err := json.Unmarshal([]byte(stdout), &output); err != nil { + return nil, fmt.Errorf("failed to parse lsblk JSON: %w", err) + } + return &output, nil +} + +// FlattenPartitions returns all leaf-level partitions across all block devices. +// Block devices with no children are treated as partitions themselves. +func (o *LsblkOutput) FlattenPartitions() []LsblkDevice { + var partitions []LsblkDevice + for _, bd := range o.BlockDevices { + if len(bd.Children) == 0 { + partitions = append(partitions, bd) + } else { + partitions = append(partitions, bd.Children...) + } + } + return partitions +} + +// --- mount output parser --- + +// MountEntry represents a single mount point from `mount` output. +type MountEntry struct { + Device string + MountPoint string + FsType string + Options string +} + +// ParseMount parses the output of the `mount` command. +// Each line has format: on type () +func ParseMount(stdout string) []MountEntry { + var entries []MountEntry + + for _, line := range strings.Split(stdout, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + + parts := strings.Fields(line) + if len(parts) < 5 { + continue + } + + entry := MountEntry{ + Device: parts[0], + MountPoint: parts[2], + FsType: parts[4], + } + + if len(parts) > 5 { + entry.Options = strings.Trim(parts[5], "()") + } + + entries = append(entries, entry) + } + + return entries +} + +// FindRootDevice returns the device path mounted at "/". +func FindRootDevice(entries []MountEntry) string { + for _, e := range entries { + if e.MountPoint == "/" { + return e.Device + } + } + return "" +} + +// --- /etc/passwd parser --- + +// PasswdEntry represents a single line from /etc/passwd. +type PasswdEntry struct { + Username string + UID string + GID string + Home string + Shell string +} + +// ParsePasswd parses /etc/passwd content and returns entries keyed by username. +func ParsePasswd(stdout string) map[string]PasswdEntry { + entries := make(map[string]PasswdEntry) + + for _, line := range strings.Split(stdout, "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + + fields := strings.Split(line, ":") + if len(fields) < 7 { + continue + } + + entries[fields[0]] = PasswdEntry{ + Username: fields[0], + UID: fields[2], + GID: fields[3], + Home: fields[5], + Shell: fields[6], + } + } + + return entries +} + +// --- /etc/group parser --- + +// GroupEntry represents a single line from /etc/group. +type GroupEntry struct { + Name string + GID string + Members []string +} + +// ParseGroup parses /etc/group content and returns entries keyed by group name. +func ParseGroup(stdout string) map[string]GroupEntry { + entries := make(map[string]GroupEntry) + + for _, line := range strings.Split(stdout, "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + + fields := strings.Split(line, ":") + if len(fields) < 4 { + continue + } + + var members []string + if fields[3] != "" { + members = strings.Split(fields[3], ",") + } + + entries[fields[0]] = GroupEntry{ + Name: fields[0], + GID: fields[2], + Members: members, + } + } + + return entries +} + +// --- efibootmgr parser --- + +// EfiBootInfo holds parsed efibootmgr output. +type EfiBootInfo struct { + BootCurrent string + BootEntries map[string]string // Boot number โ†’ entry name/description +} + +// ParseEfiBootMgr parses efibootmgr output. +// Example: +// +// BootCurrent: 0001 +// Boot0000* EFI DVD/CDROM +// Boot0001* Azure Linux +func ParseEfiBootMgr(stdout string) EfiBootInfo { + info := EfiBootInfo{ + BootEntries: make(map[string]string), + } + + for _, line := range strings.Split(stdout, "\n") { + line = strings.TrimSpace(line) + + if strings.HasPrefix(line, "BootCurrent:") { + info.BootCurrent = strings.TrimSpace(strings.SplitN(line, ":", 2)[1]) + continue + } + + if strings.HasPrefix(line, "Boot") && len(line) > 8 && line[8] == '*' { + // Pattern: Boot0001* Azure Linux + numStr := line[4:8] + rest := strings.TrimSpace(line[9:]) + info.BootEntries[numStr] = rest + } + } + + return info +} + +// CurrentBootName returns the name/description of the current boot entry. +func (e *EfiBootInfo) CurrentBootName() string { + if e.BootCurrent == "" { + return "" + } + + // Look up the current boot entry, return just the first word (name) + if desc, ok := e.BootEntries[e.BootCurrent]; ok { + fields := strings.Fields(desc) + if len(fields) > 0 { + return fields[0] + } + return desc + } + + return "" +} + +// --- Key-value line parser (cryptsetup status, veritysetup status, dmsetup info) --- + +// ParseKeyValueLines parses lines in "key: value" format into a map. +func ParseKeyValueLines(stdout string) map[string]string { + result := make(map[string]string) + + for _, line := range strings.Split(stdout, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + + kv := strings.SplitN(line, ":", 2) + if len(kv) == 2 { + result[strings.TrimSpace(kv[0])] = strings.TrimSpace(kv[1]) + } + } + + return result +} + +// --- Table parser (findmnt, swapon) --- + +// ParseTable parses whitespace-separated table output with a header row. +// Returns a slice of maps, one per data row. +func ParseTable(stdout string) []map[string]string { + lines := strings.Split(strings.TrimSpace(stdout), "\n") + if len(lines) < 2 { + return nil + } + + headers := strings.Fields(lines[0]) + var rows []map[string]string + + for _, line := range lines[1:] { + fields := strings.Fields(line) + row := make(map[string]string) + for i, h := range headers { + if i < len(fields) { + row[h] = fields[i] + } + } + rows = append(rows, row) + } + + return rows +} + +// --- YAML parser for trident get output --- + +// ParseTridentGetOutput parses the YAML output of `trident get` into a +// generic map structure. Handles YAML tags (e.g. !image) gracefully. +func ParseTridentGetOutput(stdout string) (map[string]interface{}, error) { + var result map[string]interface{} + if err := yaml.Unmarshal([]byte(stdout), &result); err != nil { + return nil, fmt.Errorf("failed to parse trident get YAML: %w", err) + } + return result, nil +} + +// --- RAID name resolver --- + +var raidNameRegex = regexp.MustCompile(`(\S+)\s+->\s+\.\./(\S+)`) + +// ParseDevMdListing parses the output of `ls -l /dev/md` and returns a map +// from md device number (e.g. "md127") to RAID name path (e.g. "/dev/md/root-a"). +func ParseDevMdListing(stdout string) map[string]string { + result := make(map[string]string) + + for _, line := range strings.Split(stdout, "\n") { + matches := raidNameRegex.FindStringSubmatch(line) + if len(matches) == 3 { + raidName := matches[1] + mdDevice := matches[2] + result[mdDevice] = fmt.Sprintf("/dev/md/%s", raidName) + } + } + + return result +} + +// GetRaidNameFromDeviceName resolves a device name like "/dev/md127" to its +// RAID name like "/dev/md/root-a" by parsing `ls -l /dev/md`. +func GetRaidNameFromDeviceName(client *ssh.Client, deviceName string) (string, error) { + stdout, err := sudoCommand(client, "ls -l /dev/md || true") + if err != nil { + return "", nil // non-fatal, /dev/md may not exist + } + + if strings.Contains(stdout, "No such file or directory") || stdout == "" { + return "", nil + } + + // Extract the md device number: /dev/md127 โ†’ md127 + mdDeviceNumber := deviceName + if idx := strings.LastIndex(deviceName, "/"); idx >= 0 { + mdDeviceNumber = deviceName[idx+1:] + } + + raidMap := ParseDevMdListing(stdout) + if name, ok := raidMap[mdDeviceNumber]; ok { + return name, nil + } + + return "", nil +} + +// --- cryptsetup status parser --- + +// CryptsetupStatus holds parsed output from `cryptsetup status `. +type CryptsetupStatus struct { + Type string + Cipher string + Keysize string + KeyLocation string + Device string + SectorSize string + Offset string + Size string + Mode string + Properties map[string]string +} + +// ParseCryptsetupStatus parses the output of `cryptsetup status `. +func ParseCryptsetupStatus(stdout string) CryptsetupStatus { + kv := ParseKeyValueLines(stdout) + status := CryptsetupStatus{ + Type: kv["type"], + Cipher: kv["cipher"], + Keysize: kv["keysize"], + KeyLocation: kv["key location"], + Device: kv["device"], + SectorSize: kv["sector size"], + Offset: kv["offset"], + Size: kv["size"], + Mode: kv["mode"], + Properties: kv, + } + return status +} + +// --- dmsetup info parser --- + +// DmsetupInfo holds parsed output from `dmsetup info `. +type DmsetupInfo struct { + Name string + State string + ReadAhead string + TablesPresent string + OpenCount string + EventNumber string + MajorMinor string + NumberOfTargets string + UUID string + Properties map[string]string +} + +// ParseDmsetupInfo parses the output of `dmsetup info `. +func ParseDmsetupInfo(stdout string) DmsetupInfo { + kv := ParseKeyValueLines(stdout) + info := DmsetupInfo{ + Name: kv["Name"], + State: kv["State"], + ReadAhead: kv["Read Ahead"], + TablesPresent: kv["Tables present"], + OpenCount: kv["Open count"], + EventNumber: kv["Event number"], + MajorMinor: kv["Major, minor"], + NumberOfTargets: kv["Number of targets"], + UUID: kv["UUID"], + Properties: kv, + } + return info +} + +// --- veritysetup status parser --- + +// VerityStatus holds parsed veritysetup status output. +type VerityStatus struct { + IsActive bool + IsInUse bool + Properties map[string]string + DataDevice string + HashDevice string + StatusLine string +} + +// ParseVeritySetupStatus parses the output of `veritysetup status `. +func ParseVeritySetupStatus(stdout string) VerityStatus { + lines := strings.Split(strings.TrimSpace(stdout), "\n") + status := VerityStatus{ + Properties: make(map[string]string), + } + + if len(lines) == 0 { + return status + } + + status.StatusLine = lines[0] + status.IsActive = strings.Contains(lines[0], "is active") + status.IsInUse = strings.Contains(lines[0], "is in use") + + for _, line := range lines[1:] { + kv := strings.SplitN(strings.TrimSpace(line), ":", 2) + if len(kv) == 2 { + key := strings.TrimSpace(kv[0]) + val := strings.TrimSpace(kv[1]) + status.Properties[key] = val + + switch key { + case "data device": + status.DataDevice = val + case "hash device": + status.HashDevice = val + } + } + } + + return status +} + +// --- cryptsetup luksDump JSON parser --- + +// LuksDump represents the JSON metadata from `cryptsetup luksDump --dump-json-metadata`. +type LuksDump struct { + Keyslots map[string]LuksKeyslot `json:"keyslots"` + Tokens map[string]LuksToken `json:"tokens"` + Segments map[string]LuksSegment `json:"segments"` + Digests map[string]LuksDigest `json:"digests"` + Config LuksConfig `json:"config"` +} + +// LuksKeyslot represents a LUKS2 keyslot. +type LuksKeyslot struct { + Type string `json:"type"` + KeySize int `json:"key_size"` + KDF LuksKDF `json:"kdf"` + Area LuksArea `json:"area"` + AF interface{} `json:"af"` +} + +// LuksKDF represents the Key Derivation Function config. +type LuksKDF struct { + Type string `json:"type"` + Hash string `json:"hash"` + Iterations int `json:"iterations"` + Salt string `json:"salt"` +} + +// LuksArea represents the keyslot area config. +type LuksArea struct { + Type string `json:"type"` + Encryption string `json:"encryption"` + KeySize int `json:"key_size"` +} + +// LuksToken represents a LUKS2 token. +type LuksToken struct { + Type string `json:"type"` + Keyslots []string `json:"keyslots"` + TPM2PCRs []int `json:"tpm2-pcrs"` + TPM2PCRLock *bool `json:"tpm2_pcrlock,omitempty"` +} + +// LuksSegment represents a LUKS2 segment. +type LuksSegment struct { + Type string `json:"type"` + Encryption string `json:"encryption"` + SectorSize int `json:"sector_size"` +} + +// LuksDigest represents a LUKS2 digest. +type LuksDigest struct { + Type string `json:"type"` + Hash string `json:"hash"` +} + +// LuksConfig represents the LUKS2 config section. +type LuksConfig struct { + JsonSize string `json:"json_size"` + KeyslotsSize string `json:"keyslots_size"` +} + +// ParseLuksDump parses JSON output from `cryptsetup luksDump --dump-json-metadata`. +func ParseLuksDump(stdout string) (*LuksDump, error) { + var dump LuksDump + if err := json.Unmarshal([]byte(stdout), &dump); err != nil { + return nil, fmt.Errorf("failed to parse luksDump JSON: %w", err) + } + return &dump, nil +} + +// --- systemd-sysext/confext status parser --- + +// SysextHierarchy represents one hierarchy entry from systemd-sysext status JSON. +type SysextHierarchy struct { + Hierarchy string `json:"hierarchy"` + Extensions []string `json:"extensions"` +} + +// ParseSysextStatus parses JSON output from `systemd-sysext status --json=pretty`. +func ParseSysextStatus(stdout string) ([]SysextHierarchy, error) { + var hierarchies []SysextHierarchy + if err := json.Unmarshal([]byte(stdout), &hierarchies); err != nil { + return nil, fmt.Errorf("failed to parse sysext status JSON: %w", err) + } + return hierarchies, nil +} + +// AllActiveExtensions returns a flat list of all active extension names across hierarchies. +func AllActiveExtensions(hierarchies []SysextHierarchy) []string { + var exts []string + for _, h := range hierarchies { + exts = append(exts, h.Extensions...) + } + return exts +} + +// --- Host config helpers --- + +// IsPartition checks if a device ID refers to a disk partition in the host status. +func IsPartition(hostStatus map[string]interface{}, blockDeviceID string) bool { + spec, ok := hostStatus["spec"].(map[interface{}]interface{}) + if !ok { + return false + } + storage, ok := spec["storage"].(map[interface{}]interface{}) + if !ok { + return false + } + disks, ok := storage["disks"].([]interface{}) + if !ok { + return false + } + + for _, d := range disks { + disk, ok := d.(map[interface{}]interface{}) + if !ok { + continue + } + partitions, ok := disk["partitions"].([]interface{}) + if !ok { + continue + } + for _, p := range partitions { + part, ok := p.(map[interface{}]interface{}) + if !ok { + continue + } + if id, ok := part["id"].(string); ok && id == blockDeviceID { + return true + } + } + } + + return false +} + +// IsRaid checks if a device ID refers to a software RAID array in the host status. +func IsRaid(hostStatus map[string]interface{}, blockDeviceID string) bool { + spec, ok := hostStatus["spec"].(map[interface{}]interface{}) + if !ok { + return false + } + storage, ok := spec["storage"].(map[interface{}]interface{}) + if !ok { + return false + } + raid, ok := storage["raid"].(map[interface{}]interface{}) + if !ok { + return false + } + software, ok := raid["software"].([]interface{}) + if !ok { + return false + } + + for _, r := range software { + arr, ok := r.(map[interface{}]interface{}) + if !ok { + continue + } + if id, ok := arr["id"].(string); ok && id == blockDeviceID { + return true + } + } + + return false +} + +// CheckPathExists verifies that a path exists on the remote host. +func CheckPathExists(client *ssh.Client, path string) error { + _, err := sudoCommand(client, fmt.Sprintf("ls %s", path)) + return err +} diff --git a/tools/storm/e2e/scenario/validate_common_test.go b/tools/storm/e2e/scenario/validate_common_test.go new file mode 100644 index 000000000..9804ee4f4 --- /dev/null +++ b/tools/storm/e2e/scenario/validate_common_test.go @@ -0,0 +1,810 @@ +package scenario + +import ( + "testing" +) + +// --- ParseBlkid tests --- + +func TestParseBlkid_BasicOutput(t *testing.T) { + input := `/dev/sda1: UUID="D920-8BA4" BLOCK_SIZE="512" TYPE="vfat" PARTLABEL="esp" PARTUUID="6fcc7c57-b21c-46e5-bc79-041c7fc53f34" +/dev/sda2: UUID="04267584-7e18-4612-a649-c71e1811bd82" BLOCK_SIZE="4096" TYPE="ext4" PARTLABEL="root-a" PARTUUID="f1be3a27-36e2-4d4b-b8ec-5b0b5909cbf9" +/dev/sda3: PARTLABEL="root-b" PARTUUID="573fdf4c-9133-4a9f-8cf5-aff7b74d1aeb" +/dev/sr0: BLOCK_SIZE="2048" UUID="2023-12-16-00-55-13-99" LABEL="TRIDENT_CDROM" TYPE="iso9660"` + + entries := ParseBlkid(input) + + if len(entries) != 4 { + t.Fatalf("expected 4 entries, got %d", len(entries)) + } + + sda1 := entries["sda1"] + if sda1.Properties["TYPE"] != "vfat" { + t.Errorf("sda1 TYPE: expected 'vfat', got %q", sda1.Properties["TYPE"]) + } + if sda1.Properties["PARTLABEL"] != "esp" { + t.Errorf("sda1 PARTLABEL: expected 'esp', got %q", sda1.Properties["PARTLABEL"]) + } + if sda1.DevicePath != "/dev/sda1" { + t.Errorf("sda1 DevicePath: expected '/dev/sda1', got %q", sda1.DevicePath) + } + + sda3 := entries["sda3"] + if sda3.Properties["PARTLABEL"] != "root-b" { + t.Errorf("sda3 PARTLABEL: expected 'root-b', got %q", sda3.Properties["PARTLABEL"]) + } + if _, ok := sda3.Properties["UUID"]; ok { + t.Error("sda3 should not have UUID") + } +} + +func TestParseBlkid_DevMapper(t *testing.T) { + input := `/dev/mapper/root: UUID="aeca4bee-73f3-4ae0-aaa3-57ae0a29ee4b" BLOCK_SIZE="4096" TYPE="ext4"` + + entries := ParseBlkid(input) + + root, ok := entries["root"] + if !ok { + t.Fatal("expected 'root' entry for /dev/mapper/root") + } + if root.Properties["TYPE"] != "ext4" { + t.Errorf("root TYPE: expected 'ext4', got %q", root.Properties["TYPE"]) + } +} + +func TestParseBlkid_EmptyInput(t *testing.T) { + entries := ParseBlkid("") + if len(entries) != 0 { + t.Errorf("expected 0 entries for empty input, got %d", len(entries)) + } +} + +// --- ParseBlkidExport tests --- + +func TestParseBlkidExport_BasicOutput(t *testing.T) { + input := `DEVNAME=/dev/md127 +UUID=475f0351-4bb7-49bb-b9af-1f53f94b91cb +TYPE=crypto_LUKS + +DEVNAME=/dev/sr0 +BLOCK_SIZE=2048 +UUID=2024-10-30-22-05-47-00 +LABEL=CDROM +TYPE=iso9660` + + devs := ParseBlkidExport(input) + + if len(devs) != 2 { + t.Fatalf("expected 2 devices, got %d", len(devs)) + } + + md127 := devs["/dev/md127"] + if md127["TYPE"] != "crypto_LUKS" { + t.Errorf("md127 TYPE: expected 'crypto_LUKS', got %q", md127["TYPE"]) + } + if md127["UUID"] != "475f0351-4bb7-49bb-b9af-1f53f94b91cb" { + t.Errorf("md127 UUID: expected '475f0351-...', got %q", md127["UUID"]) + } + + sr0 := devs["/dev/sr0"] + if sr0["LABEL"] != "CDROM" { + t.Errorf("sr0 LABEL: expected 'CDROM', got %q", sr0["LABEL"]) + } +} + +func TestParseBlkidExport_EmptyInput(t *testing.T) { + devs := ParseBlkidExport("") + if len(devs) != 0 { + t.Errorf("expected 0 devices for empty input, got %d", len(devs)) + } +} + +// --- ParseLsblk tests --- + +func TestParseLsblk_BasicOutput(t *testing.T) { + input := `{ + "blockdevices": [ + { + "name": "sda", + "maj:min": "8:0", + "rm": false, + "size": "34359738368", + "ro": false, + "type": "disk", + "mountpoints": [null], + "children": [ + { + "name": "sda1", + "maj:min": "8:1", + "rm": false, + "size": "1073741824", + "ro": false, + "type": "part", + "mountpoints": ["/boot/efi"] + }, + { + "name": "sda2", + "maj:min": "8:2", + "rm": false, + "size": "8589934592", + "ro": false, + "type": "part", + "mountpoints": ["/"] + } + ] + }, + { + "name": "sr0", + "maj:min": "11:0", + "rm": true, + "size": "500000000", + "ro": false, + "type": "rom", + "mountpoints": [null] + } + ] +}` + + output, err := ParseLsblk(input) + if err != nil { + t.Fatalf("ParseLsblk failed: %v", err) + } + + if len(output.BlockDevices) != 2 { + t.Fatalf("expected 2 block devices, got %d", len(output.BlockDevices)) + } + + sda := output.BlockDevices[0] + if sda.Name != "sda" { + t.Errorf("expected first device 'sda', got %q", sda.Name) + } + if len(sda.Children) != 2 { + t.Errorf("expected 2 children for sda, got %d", len(sda.Children)) + } + + partitions := output.FlattenPartitions() + if len(partitions) != 3 { + t.Fatalf("expected 3 partitions, got %d", len(partitions)) + } + if partitions[0].Name != "sda1" { + t.Errorf("expected first partition 'sda1', got %q", partitions[0].Name) + } +} + +func TestParseLsblk_InvalidJSON(t *testing.T) { + _, err := ParseLsblk("not json") + if err == nil { + t.Error("expected error for invalid JSON") + } +} + +// --- ParseMount tests --- + +func TestParseMount_BasicOutput(t *testing.T) { + input := `/dev/sda3 on / type ext4 (rw,relatime) +devtmpfs on /dev type devtmpfs (rw,nosuid,size=4096k,nr_inodes=721913,mode=755) +/dev/sda5 on /home type ext4 (rw,relatime) +/dev/sda1 on /boot/efi type vfat (rw,relatime,fmask=0077,dmask=0077)` + + entries := ParseMount(input) + if len(entries) != 4 { + t.Fatalf("expected 4 entries, got %d", len(entries)) + } + + root := FindRootDevice(entries) + if root != "/dev/sda3" { + t.Errorf("expected root device '/dev/sda3', got %q", root) + } + + if entries[2].MountPoint != "/home" { + t.Errorf("expected mount point '/home', got %q", entries[2].MountPoint) + } +} + +func TestParseMount_NoRoot(t *testing.T) { + input := `/dev/sda1 on /boot type ext4 (rw)` + entries := ParseMount(input) + root := FindRootDevice(entries) + if root != "" { + t.Errorf("expected empty root device, got %q", root) + } + _ = entries +} + +// --- ParsePasswd tests --- + +func TestParsePasswd_BasicOutput(t *testing.T) { + input := `root:x:0:0:root:/root:/bin/bash +bin:x:1:1:bin:/dev/null:/bin/false +testing-user:x:1001:1001::/home/testing-user:/bin/bash` + + entries := ParsePasswd(input) + if len(entries) != 3 { + t.Fatalf("expected 3 entries, got %d", len(entries)) + } + + user := entries["testing-user"] + if user.UID != "1001" { + t.Errorf("expected UID '1001', got %q", user.UID) + } + if user.Home != "/home/testing-user" { + t.Errorf("expected home '/home/testing-user', got %q", user.Home) + } +} + +func TestParsePasswd_SkipsComments(t *testing.T) { + input := `# comment line +root:x:0:0:root:/root:/bin/bash` + + entries := ParsePasswd(input) + if len(entries) != 1 { + t.Errorf("expected 1 entry, got %d", len(entries)) + } +} + +// --- ParseGroup tests --- + +func TestParseGroup_BasicOutput(t *testing.T) { + input := `root:x:0: +bin:x:1:daemon +wheel:x:10:testing-user,admin` + + entries := ParseGroup(input) + if len(entries) != 3 { + t.Fatalf("expected 3 entries, got %d", len(entries)) + } + + wheel := entries["wheel"] + if len(wheel.Members) != 2 { + t.Fatalf("expected 2 members in wheel, got %d", len(wheel.Members)) + } + if wheel.Members[0] != "testing-user" { + t.Errorf("expected first member 'testing-user', got %q", wheel.Members[0]) + } + + root := entries["root"] + if len(root.Members) != 0 { + t.Errorf("expected 0 members in root, got %d", len(root.Members)) + } +} + +// --- ParseEfiBootMgr tests --- + +func TestParseEfiBootMgr_BasicOutput(t *testing.T) { + input := `BootCurrent: 0001 +Timeout: 0 seconds +BootOrder: 0001,0000 +Boot0000* EFI DVD/CDROM +Boot0001* Azure Linux` + + info := ParseEfiBootMgr(input) + + if info.BootCurrent != "0001" { + t.Errorf("expected BootCurrent '0001', got %q", info.BootCurrent) + } + if len(info.BootEntries) != 2 { + t.Fatalf("expected 2 boot entries, got %d", len(info.BootEntries)) + } + if info.BootEntries["0001"] != "Azure Linux" { + t.Errorf("expected Boot0001 'Azure Linux', got %q", info.BootEntries["0001"]) + } + + name := info.CurrentBootName() + if name != "Azure" { + t.Errorf("expected current boot name 'Azure', got %q", name) + } +} + +// --- ParseKeyValueLines tests --- + +func TestParseKeyValueLines_CryptsetupStatus(t *testing.T) { + input := ` type: n/a + cipher: aes-xts-plain64 + keysize: 512 bits + key location: keyring + device: /dev/md127 + sector size: 512 + offset: 16384 sectors + size: 2080640 sectors + mode: read/write` + + result := ParseKeyValueLines(input) + + if result["cipher"] != "aes-xts-plain64" { + t.Errorf("cipher: expected 'aes-xts-plain64', got %q", result["cipher"]) + } + if result["keysize"] != "512 bits" { + t.Errorf("keysize: expected '512 bits', got %q", result["keysize"]) + } + if result["mode"] != "read/write" { + t.Errorf("mode: expected 'read/write', got %q", result["mode"]) + } +} + +func TestParseKeyValueLines_DmsetupInfo(t *testing.T) { + input := `Name: web +State: ACTIVE +Read Ahead: 256 +Tables present: LIVE +Open count: 0 +Event number: 0 +Major, minor: 254, 0 +Number of targets: 1 +UUID: CRYPT-LUKS2-475f03514bb749bbb9af1f53f94b91cb-web` + + result := ParseKeyValueLines(input) + + if result["Name"] != "web" { + t.Errorf("Name: expected 'web', got %q", result["Name"]) + } + if result["State"] != "ACTIVE" { + t.Errorf("State: expected 'ACTIVE', got %q", result["State"]) + } + if result["UUID"] != "CRYPT-LUKS2-475f03514bb749bbb9af1f53f94b91cb-web" { + t.Errorf("UUID: expected 'CRYPT-LUKS2-...-web', got %q", result["UUID"]) + } +} + +// --- ParseTable tests --- + +func TestParseTable_FindmntOutput(t *testing.T) { + input := `TARGET SOURCE FSTYPE OPTIONS +/mnt/web /dev/mapper/web ext4 rw,relatime` + + rows := ParseTable(input) + if len(rows) != 1 { + t.Fatalf("expected 1 row, got %d", len(rows)) + } + + if rows[0]["TARGET"] != "/mnt/web" { + t.Errorf("TARGET: expected '/mnt/web', got %q", rows[0]["TARGET"]) + } + if rows[0]["SOURCE"] != "/dev/mapper/web" { + t.Errorf("SOURCE: expected '/dev/mapper/web', got %q", rows[0]["SOURCE"]) + } +} + +func TestParseTable_EmptyInput(t *testing.T) { + rows := ParseTable("") + if rows != nil { + t.Errorf("expected nil for empty input, got %v", rows) + } +} + +// --- ParseDevMdListing tests --- + +func TestParseDevMdListing_BasicOutput(t *testing.T) { + input := `lrwxrwxrwx 1 root root 8 Apr 1 22:42 home -> ../md124 +lrwxrwxrwx 1 root root 8 Apr 1 22:42 root-a -> ../md127 +lrwxrwxrwx 1 root root 8 Apr 1 22:42 root-b -> ../md125 +lrwxrwxrwx 1 root root 8 Apr 1 22:42 trident -> ../md126` + + result := ParseDevMdListing(input) + + if len(result) != 4 { + t.Fatalf("expected 4 entries, got %d", len(result)) + } + if result["md127"] != "/dev/md/root-a" { + t.Errorf("md127: expected '/dev/md/root-a', got %q", result["md127"]) + } + if result["md124"] != "/dev/md/home" { + t.Errorf("md124: expected '/dev/md/home', got %q", result["md124"]) + } +} + +func TestParseDevMdListing_EmptyInput(t *testing.T) { + result := ParseDevMdListing("") + if len(result) != 0 { + t.Errorf("expected 0 entries for empty input, got %d", len(result)) + } +} + +// --- ParseVeritySetupStatus tests --- + +// --- ParseCryptsetupStatus tests --- + +func TestParseCryptsetupStatus_BasicOutput(t *testing.T) { + input := ` type: n/a + cipher: aes-xts-plain64 + keysize: 512 bits + key location: keyring + device: /dev/md127 + sector size: 512 + offset: 16384 sectors + size: 2080640 sectors + mode: read/write` + + status := ParseCryptsetupStatus(input) + + if status.Cipher != "aes-xts-plain64" { + t.Errorf("Cipher: expected 'aes-xts-plain64', got %q", status.Cipher) + } + if status.Keysize != "512 bits" { + t.Errorf("Keysize: expected '512 bits', got %q", status.Keysize) + } + if status.KeyLocation != "keyring" { + t.Errorf("KeyLocation: expected 'keyring', got %q", status.KeyLocation) + } + if status.Device != "/dev/md127" { + t.Errorf("Device: expected '/dev/md127', got %q", status.Device) + } + if status.SectorSize != "512" { + t.Errorf("SectorSize: expected '512', got %q", status.SectorSize) + } + if status.Mode != "read/write" { + t.Errorf("Mode: expected 'read/write', got %q", status.Mode) + } +} + +func TestParseCryptsetupStatus_EmptyInput(t *testing.T) { + status := ParseCryptsetupStatus("") + if status.Cipher != "" { + t.Errorf("expected empty Cipher, got %q", status.Cipher) + } + if status.Device != "" { + t.Errorf("expected empty Device, got %q", status.Device) + } + if len(status.Properties) != 0 { + t.Errorf("expected 0 properties, got %d", len(status.Properties)) + } +} + +func TestParseCryptsetupStatus_PlainDmCrypt(t *testing.T) { + input := ` type: PLAIN + cipher: aes-cbc-essiv:sha256 + keysize: 256 bits + key location: dm-crypt + device: /dev/sda2 + sector size: 512 + offset: 0 sectors + size: 41943040 sectors + mode: read/write` + + status := ParseCryptsetupStatus(input) + + if status.Type != "PLAIN" { + t.Errorf("Type: expected 'PLAIN', got %q", status.Type) + } + if status.Cipher != "aes-cbc-essiv:sha256" { + t.Errorf("Cipher: expected 'aes-cbc-essiv:sha256', got %q", status.Cipher) + } + if status.KeyLocation != "dm-crypt" { + t.Errorf("KeyLocation: expected 'dm-crypt', got %q", status.KeyLocation) + } + if status.Offset != "0 sectors" { + t.Errorf("Offset: expected '0 sectors', got %q", status.Offset) + } +} + +// --- ParseDmsetupInfo tests --- + +func TestParseDmsetupInfo_BasicOutput(t *testing.T) { + input := `Name: web +State: ACTIVE +Read Ahead: 256 +Tables present: LIVE +Open count: 0 +Event number: 0 +Major, minor: 254, 0 +Number of targets: 1 +UUID: CRYPT-LUKS2-475f03514bb749bbb9af1f53f94b91cb-web` + + info := ParseDmsetupInfo(input) + + if info.Name != "web" { + t.Errorf("Name: expected 'web', got %q", info.Name) + } + if info.State != "ACTIVE" { + t.Errorf("State: expected 'ACTIVE', got %q", info.State) + } + if info.ReadAhead != "256" { + t.Errorf("ReadAhead: expected '256', got %q", info.ReadAhead) + } + if info.TablesPresent != "LIVE" { + t.Errorf("TablesPresent: expected 'LIVE', got %q", info.TablesPresent) + } + if info.OpenCount != "0" { + t.Errorf("OpenCount: expected '0', got %q", info.OpenCount) + } + if info.MajorMinor != "254, 0" { + t.Errorf("MajorMinor: expected '254, 0', got %q", info.MajorMinor) + } + if info.NumberOfTargets != "1" { + t.Errorf("NumberOfTargets: expected '1', got %q", info.NumberOfTargets) + } + if info.UUID != "CRYPT-LUKS2-475f03514bb749bbb9af1f53f94b91cb-web" { + t.Errorf("UUID: expected 'CRYPT-LUKS2-...-web', got %q", info.UUID) + } +} + +func TestParseDmsetupInfo_EmptyInput(t *testing.T) { + info := ParseDmsetupInfo("") + if info.Name != "" { + t.Errorf("expected empty Name, got %q", info.Name) + } + if info.State != "" { + t.Errorf("expected empty State, got %q", info.State) + } + if len(info.Properties) != 0 { + t.Errorf("expected 0 properties, got %d", len(info.Properties)) + } +} + +func TestParseDmsetupInfo_SuspendedDevice(t *testing.T) { + input := `Name: root +State: SUSPENDED +Read Ahead: 256 +Tables present: LIVE +Open count: 1 +Event number: 3 +Major, minor: 254, 1 +Number of targets: 1 +UUID: CRYPT-LUKS2-a1b2c3d4e5f6-root` + + info := ParseDmsetupInfo(input) + + if info.Name != "root" { + t.Errorf("Name: expected 'root', got %q", info.Name) + } + if info.State != "SUSPENDED" { + t.Errorf("State: expected 'SUSPENDED', got %q", info.State) + } + if info.OpenCount != "1" { + t.Errorf("OpenCount: expected '1', got %q", info.OpenCount) + } +} + +// --- ParseVeritySetupStatus tests (continued) --- + +func TestParseVeritySetupStatus_BasicOutput(t *testing.T) { + input := `/dev/mapper/root is active and is in use. + type: VERITY + status: verified + hash type: 1 + data block: 4096 + hash block: 4096 + hash name: sha256 + salt: 95c671631e5202431ead38146e1af8342100ff03bc2a89f2590dcb3454cc6e31 + data device: /dev/sda3 + size: 1377128 sectors + mode: readonly + hash device: /dev/sda4 + hash offset: 8 sectors + root hash: a8c34ed685f365352231db21aa36ff23bf8b658e001afa8e498f57d1755e9a19 + flags: panic_on_corruption` + + status := ParseVeritySetupStatus(input) + + if !status.IsActive { + t.Error("expected IsActive to be true") + } + if !status.IsInUse { + t.Error("expected IsInUse to be true") + } + if status.Properties["type"] != "VERITY" { + t.Errorf("type: expected 'VERITY', got %q", status.Properties["type"]) + } + if status.Properties["status"] != "verified" { + t.Errorf("status: expected 'verified', got %q", status.Properties["status"]) + } + if status.Properties["mode"] != "readonly" { + t.Errorf("mode: expected 'readonly', got %q", status.Properties["mode"]) + } + if status.DataDevice != "/dev/sda3" { + t.Errorf("DataDevice: expected '/dev/sda3', got %q", status.DataDevice) + } + if status.HashDevice != "/dev/sda4" { + t.Errorf("HashDevice: expected '/dev/sda4', got %q", status.HashDevice) + } +} + +func TestParseVeritySetupStatus_RaidDevices(t *testing.T) { + input := `/dev/mapper/root is active and is in use. + type: VERITY + status: verified + data device: /dev/md126 + mode: readonly + hash device: /dev/md127` + + status := ParseVeritySetupStatus(input) + if status.DataDevice != "/dev/md126" { + t.Errorf("DataDevice: expected '/dev/md126', got %q", status.DataDevice) + } + if status.HashDevice != "/dev/md127" { + t.Errorf("HashDevice: expected '/dev/md127', got %q", status.HashDevice) + } +} + +// --- ParseLuksDump tests --- + +func TestParseLuksDump_BasicOutput(t *testing.T) { + input := `{ + "keyslots": { + "1": { + "type": "luks2", + "key_size": 64, + "kdf": { + "type": "pbkdf2", + "hash": "sha512", + "iterations": 1000, + "salt": "FHJf95bq+nk/WkCCCOIyPDwLbzpwkkiTgs2vjFZgLU0=" + }, + "area": { + "type": "raw", + "encryption": "aes-xts-plain64", + "key_size": 64 + } + } + }, + "tokens": { + "0": { + "type": "systemd-tpm2", + "keyslots": ["1"], + "tpm2-pcrs": [], + "tpm2_pcrlock": true + } + }, + "segments": { + "0": { + "type": "crypt", + "encryption": "aes-xts-plain64", + "sector_size": 512 + } + }, + "digests": { + "0": { + "type": "pbkdf2", + "hash": "sha512" + } + }, + "config": { + "json_size": "12288", + "keyslots_size": "16744448" + } +}` + + dump, err := ParseLuksDump(input) + if err != nil { + t.Fatalf("ParseLuksDump failed: %v", err) + } + + if len(dump.Keyslots) != 1 { + t.Fatalf("expected 1 keyslot, got %d", len(dump.Keyslots)) + } + ks := dump.Keyslots["1"] + if ks.Type != "luks2" { + t.Errorf("keyslot type: expected 'luks2', got %q", ks.Type) + } + if ks.KDF.Type != "pbkdf2" { + t.Errorf("KDF type: expected 'pbkdf2', got %q", ks.KDF.Type) + } + if ks.KDF.Hash != "sha512" { + t.Errorf("KDF hash: expected 'sha512', got %q", ks.KDF.Hash) + } + + if len(dump.Tokens) != 1 { + t.Fatalf("expected 1 token, got %d", len(dump.Tokens)) + } + tok := dump.Tokens["0"] + if tok.Type != "systemd-tpm2" { + t.Errorf("token type: expected 'systemd-tpm2', got %q", tok.Type) + } + if tok.TPM2PCRLock == nil || !*tok.TPM2PCRLock { + t.Error("expected tpm2_pcrlock to be true") + } + if len(tok.TPM2PCRs) != 0 { + t.Errorf("expected empty tpm2-pcrs, got %v", tok.TPM2PCRs) + } + + if dump.Digests["0"].Type != "pbkdf2" { + t.Errorf("digest type: expected 'pbkdf2', got %q", dump.Digests["0"].Type) + } + if dump.Digests["0"].Hash != "sha512" { + t.Errorf("digest hash: expected 'sha512', got %q", dump.Digests["0"].Hash) + } +} + +func TestParseLuksDump_NonUkiPCRs(t *testing.T) { + input := `{ + "keyslots": {}, + "tokens": { + "0": { + "type": "systemd-tpm2", + "keyslots": ["1"], + "tpm2-pcrs": [7], + "tpm2_pcrlock": false + } + }, + "segments": {}, + "digests": {}, + "config": { + "json_size": "12288", + "keyslots_size": "16744448" + } +}` + + dump, err := ParseLuksDump(input) + if err != nil { + t.Fatalf("ParseLuksDump failed: %v", err) + } + + tok := dump.Tokens["0"] + if tok.TPM2PCRLock == nil || *tok.TPM2PCRLock { + t.Error("expected tpm2_pcrlock to be false") + } + if len(tok.TPM2PCRs) != 1 || tok.TPM2PCRs[0] != 7 { + t.Errorf("expected tpm2-pcrs [7], got %v", tok.TPM2PCRs) + } +} + +// --- ParseSysextStatus tests --- + +func TestParseSysextStatus_BasicOutput(t *testing.T) { + input := `[ + { + "hierarchy": "/usr", + "extensions": ["my-sysext", "another-ext"] + }, + { + "hierarchy": "/opt", + "extensions": ["opt-ext"] + } +]` + + hierarchies, err := ParseSysextStatus(input) + if err != nil { + t.Fatalf("ParseSysextStatus failed: %v", err) + } + + if len(hierarchies) != 2 { + t.Fatalf("expected 2 hierarchies, got %d", len(hierarchies)) + } + + allExts := AllActiveExtensions(hierarchies) + if len(allExts) != 3 { + t.Fatalf("expected 3 extensions, got %d", len(allExts)) + } + + expected := map[string]bool{"my-sysext": true, "another-ext": true, "opt-ext": true} + for _, ext := range allExts { + if !expected[ext] { + t.Errorf("unexpected extension %q", ext) + } + } +} + +func TestParseSysextStatus_EmptyHierarchies(t *testing.T) { + input := `[{"hierarchy": "/usr", "extensions": []}]` + + hierarchies, err := ParseSysextStatus(input) + if err != nil { + t.Fatalf("ParseSysextStatus failed: %v", err) + } + + allExts := AllActiveExtensions(hierarchies) + if len(allExts) != 0 { + t.Errorf("expected 0 extensions, got %d", len(allExts)) + } +} + +// --- ParseTridentGetOutput tests --- + +func TestParseTridentGetOutput_Basic(t *testing.T) { + input := `servicingState: provisioned +abActiveVolume: volume-a +partitionPaths: + root-a: /dev/disk/by-partuuid/f1be3a27 + root-b: /dev/disk/by-partuuid/573fdf4c` + + result, err := ParseTridentGetOutput(input) + if err != nil { + t.Fatalf("ParseTridentGetOutput failed: %v", err) + } + + if result["servicingState"] != "provisioned" { + t.Errorf("servicingState: expected 'provisioned', got %v", result["servicingState"]) + } + if result["abActiveVolume"] != "volume-a" { + t.Errorf("abActiveVolume: expected 'volume-a', got %v", result["abActiveVolume"]) + } +} diff --git a/tools/storm/e2e/scenario/validate_encryption.go b/tools/storm/e2e/scenario/validate_encryption.go new file mode 100644 index 000000000..420151919 --- /dev/null +++ b/tools/storm/e2e/scenario/validate_encryption.go @@ -0,0 +1,592 @@ +package scenario + +import ( + "fmt" + "strings" + + "github.com/microsoft/storm" + "github.com/sirupsen/logrus" +) + +// validateEncryption validates LUKS2/TPM2 encryption on the remote host. +// Converted from encryption_test.py test_encryption. +// +// For each encryption volume in the host configuration, it validates: +// - Backing device type (partition or RAID array) and crypto_LUKS type in blkid +// - cryptsetup luksDump metadata (LUKS2 keyslots, TPM2 tokens, KDF, digest) +// - cryptsetup status (cipher aes-xts-plain64, keysize 512) +// - dmsetup info (active state, CRYPT-LUKS2 or CRYPT-PLAIN UUID) +// - Filesystem mount or swap activation for the decrypted device +// - A/B volume pair active/inactive state +func (s *TridentE2EScenario) validateEncryption(tc storm.TestCase) error { + if err := s.populateSshClient(tc.Context()); err != nil { + return fmt.Errorf("failed to populate SSH client: %w", err) + } + + // Get blkid export output for looking up device types and paths + blkidExportOut, err := sudoCommand(s.sshClient, "blkid --output export") + if err != nil { + return fmt.Errorf("failed to run blkid --output export: %w", err) + } + blockDevs := ParseBlkidExport(blkidExportOut) + + // After initial install, the active volume is always volume-a. + abActiveVolume := "volume-a" + + encryptionVolumes := s.originalConfig.S("storage", "encryption", "volumes").Children() + logrus.Infof("Found %d encryption volumes to validate", len(encryptionVolumes)) + + for _, crypt := range encryptionVolumes { + cryptId, _ := crypt.S("id").Data().(string) + cryptDevName, _ := crypt.S("deviceName").Data().(string) + cryptDevId, _ := crypt.S("deviceId").Data().(string) + + logrus.Infof("Validating encryption volume: id=%s, deviceName=%s, deviceId=%s", + cryptId, cryptDevName, cryptDevId) + + if err := s.checkCryptDevice(tc, abActiveVolume, blockDevs, + cryptId, cryptDevName, cryptDevId); err != nil { + return err + } + } + + logrus.Info("Encryption validation passed") + return nil +} + +// checkCryptDevice validates a single encryption volume. It checks the backing +// device, LUKS metadata, cryptsetup status, dmsetup info, and filesystem mount +// or swap activation. +func (s *TridentE2EScenario) checkCryptDevice( + tc storm.TestCase, + abActiveVolume string, + blockDevs map[string]map[string]string, + cryptId, cryptDevName, cryptDevId string, +) error { + cryptDevicePath := fmt.Sprintf("/dev/mapper/%s", cryptDevName) + + // Validate backing device and LUKS dump + if err := s.checkParentDevices(tc, blockDevs, cryptDevId); err != nil { + return err + } + + isSwap := false + isInUse := true + + // Check if this crypt volume is part of an A/B update volume pair + volumePairId, isVolumeA, hasABPair := s.getChildABUpdateVolumePair(cryptId) + + if hasABPair { + if abActiveVolume != "volume-a" && abActiveVolume != "volume-b" { + tc.Fail(fmt.Sprintf("expected active volume to be 'volume-a' or 'volume-b', got %q", + abActiveVolume)) + return nil + } + + isInUse = (abActiveVolume == "volume-a" && isVolumeA) || + (abActiveVolume == "volume-b" && !isVolumeA) + + mpPath := s.getFilesystemMountPath(volumePairId) + if mpPath == "" { + tc.Fail(fmt.Sprintf("no filesystem/mount point found for A/B volume pair %q", + volumePairId)) + return nil + } + + if err := CheckPathExists(s.sshClient, mpPath); err != nil { + tc.Fail(fmt.Sprintf("mount path %q does not exist: %v", mpPath, err)) + return nil + } + + if err := s.checkFindmnt(tc, mpPath, cryptDevicePath, isInUse); err != nil { + return err + } + } else if s.isSwapDevice(cryptId) { + isSwap = true + + activeSwaps, err := s.getActiveSwaps() + if err != nil { + return fmt.Errorf("failed to get active swaps: %w", err) + } + + realPath, err := sudoCommand(s.sshClient, + fmt.Sprintf("readlink -f %s", cryptDevicePath)) + if err != nil { + return fmt.Errorf("failed to resolve %s: %w", cryptDevicePath, err) + } + + if !activeSwaps[realPath] { + tc.Fail(fmt.Sprintf("expected %q to be in active swaps: %v", + realPath, activeSwaps)) + return nil + } + } else { + // Regular filesystem (not A/B, not swap) + mpPath := s.getFilesystemMountPath(cryptId) + if mpPath == "" { + tc.Fail(fmt.Sprintf("no filesystem/mount point found for encryption volume %q", + cryptId)) + return nil + } + + if err := CheckPathExists(s.sshClient, mpPath); err != nil { + tc.Fail(fmt.Sprintf("mount path %q does not exist: %v", mpPath, err)) + return nil + } + + if err := s.checkFindmnt(tc, mpPath, cryptDevicePath, isInUse); err != nil { + return err + } + } + + // Verify the device mapper path exists + if err := CheckPathExists(s.sshClient, cryptDevicePath); err != nil { + tc.Fail(fmt.Sprintf("crypt device path %q does not exist: %v", + cryptDevicePath, err)) + return nil + } + + // Validate cryptsetup status + if err := s.checkCryptsetupStatus(tc, cryptDevName, isInUse); err != nil { + return err + } + + // Validate dmsetup info + return s.checkDmsetupInfo(tc, cryptDevName, isSwap) +} + +// checkParentDevices validates the backing device for an encryption volume. +// The backing device can be either a disk partition or a RAID array. It also +// validates the device type is crypto_LUKS and checks the LUKS dump metadata. +func (s *TridentE2EScenario) checkParentDevices( + tc storm.TestCase, + blockDevs map[string]map[string]string, + cryptDevId string, +) error { + var cryptDevPath string + + if s.isDiskPartition(cryptDevId) { + cryptDevPath = getBlockDevPathByPartlabel(blockDevs, cryptDevId) + if cryptDevPath == "" { + tc.Fail(fmt.Sprintf("expected device with PARTLABEL %q in blkid export output", + cryptDevId)) + return nil + } + } else { + raidName := s.getRaidSoftwareArrayName(cryptDevId) + if raidName == "" { + tc.Fail(fmt.Sprintf("expected %q to be a disk partition or RAID array", + cryptDevId)) + return nil + } + + resolvedPath, err := sudoCommand(s.sshClient, + fmt.Sprintf("readlink -f /dev/md/%s", raidName)) + if err != nil { + return fmt.Errorf("failed to resolve RAID device path for %q: %w", + raidName, err) + } + cryptDevPath = resolvedPath + } + + // Validate that the device type is crypto_LUKS + devProps, exists := blockDevs[cryptDevPath] + if !exists { + tc.Fail(fmt.Sprintf("device %q not found in blkid export output", cryptDevPath)) + return nil + } + + if actualType := devProps["TYPE"]; actualType != "crypto_LUKS" { + tc.Fail(fmt.Sprintf("expected TYPE 'crypto_LUKS' for %q, got %q", + cryptDevPath, actualType)) + return nil + } + + return s.checkCryptsetupLuksDump(tc, cryptDevPath) +} + +// checkCryptsetupLuksDump validates LUKS2 metadata from cryptsetup luksDump. +// It verifies keyslots, tokens, KDF, digests, and UKI vs non-UKI PCR policies. +func (s *TridentE2EScenario) checkCryptsetupLuksDump( + tc storm.TestCase, + cryptDevPath string, +) error { + // SELinux workaround: luksDump needs additional lvm_t permissions that + // are a test-infra quirk, not part of the Trident SELinux policy. + enforcing, err := sudoCommand(s.sshClient, "getenforce") + if err != nil { + return fmt.Errorf("failed to check SELinux status: %w", err) + } + isEnforcing := strings.TrimSpace(enforcing) == "Enforcing" + + if isEnforcing { + if _, err := sudoCommand(s.sshClient, "setenforce 0"); err != nil { + return fmt.Errorf("failed to set SELinux to permissive: %w", err) + } + } + + luksDumpOut, err := sudoCommand(s.sshClient, + fmt.Sprintf("cryptsetup luksDump --dump-json-metadata %s", cryptDevPath)) + + if isEnforcing { + if _, restoreErr := sudoCommand(s.sshClient, "setenforce 1"); restoreErr != nil { + logrus.WithError(restoreErr).Warn("Failed to restore SELinux to enforcing") + } + } + + if err != nil { + return fmt.Errorf("failed to run cryptsetup luksDump on %s: %w", + cryptDevPath, err) + } + + dump, err := ParseLuksDump(luksDumpOut) + if err != nil { + return fmt.Errorf("failed to parse luksDump output for %s: %w", + cryptDevPath, err) + } + + // --- Validate digests --- + digest0, ok := dump.Digests["0"] + if !ok { + tc.Fail(fmt.Sprintf("expected digest 0 in luksDump for %s", cryptDevPath)) + return nil + } + if digest0.Type != "pbkdf2" { + tc.Fail(fmt.Sprintf("expected digest type 'pbkdf2', got %q", digest0.Type)) + return nil + } + if digest0.Hash != "sha512" { + tc.Fail(fmt.Sprintf("expected digest hash 'sha512', got %q", digest0.Hash)) + return nil + } + + // --- Validate tokens --- + token0, ok := dump.Tokens["0"] + if !ok { + tc.Fail(fmt.Sprintf("expected token 0 in luksDump for %s", cryptDevPath)) + return nil + } + if len(dump.Tokens) != 1 { + tc.Fail(fmt.Sprintf("expected 1 token, got %d", len(dump.Tokens))) + return nil + } + if len(token0.Keyslots) != 1 || token0.Keyslots[0] != "1" { + tc.Fail(fmt.Sprintf("expected token 0 keyslots [\"1\"], got %v", + token0.Keyslots)) + return nil + } + if token0.Type != "systemd-tpm2" { + tc.Fail(fmt.Sprintf("expected token type 'systemd-tpm2', got %q", token0.Type)) + return nil + } + + // UKI vs non-UKI PCR policy validation + if s.configParams.IsUki { + if token0.TPM2PCRLock == nil || !*token0.TPM2PCRLock { + tc.Fail("expected tpm2_pcrlock to be true for UKI image") + return nil + } + if len(token0.TPM2PCRs) != 0 { + tc.Fail(fmt.Sprintf("expected empty tpm2-pcrs for UKI image, got %v", + token0.TPM2PCRs)) + return nil + } + } else { + if token0.TPM2PCRLock != nil && *token0.TPM2PCRLock { + tc.Fail("expected tpm2_pcrlock to be false for non-UKI image") + return nil + } + if len(token0.TPM2PCRs) != 1 || token0.TPM2PCRs[0] != 7 { + tc.Fail(fmt.Sprintf("expected tpm2-pcrs [7] for non-UKI image, got %v", + token0.TPM2PCRs)) + return nil + } + } + + // --- Validate keyslots --- + if len(dump.Keyslots) != 1 { + tc.Fail(fmt.Sprintf("expected 1 keyslot, got %d", len(dump.Keyslots))) + return nil + } + keyslot1, ok := dump.Keyslots["1"] + if !ok { + tc.Fail("expected keyslot 1 in luksDump") + return nil + } + if keyslot1.Type != "luks2" { + tc.Fail(fmt.Sprintf("expected keyslot type 'luks2', got %q", keyslot1.Type)) + return nil + } + if keyslot1.KDF.Type != "pbkdf2" { + tc.Fail(fmt.Sprintf("expected keyslot KDF type 'pbkdf2', got %q", + keyslot1.KDF.Type)) + return nil + } + if keyslot1.KDF.Hash != "sha512" { + tc.Fail(fmt.Sprintf("expected keyslot KDF hash 'sha512', got %q", + keyslot1.KDF.Hash)) + return nil + } + if keyslot1.Area.Encryption != "aes-xts-plain64" { + tc.Fail(fmt.Sprintf("expected keyslot area encryption 'aes-xts-plain64', got %q", + keyslot1.Area.Encryption)) + return nil + } + + logrus.Infof("LUKS dump validation passed for %s", cryptDevPath) + return nil +} + +// checkCryptsetupStatus validates the output of `cryptsetup status` for an +// encrypted device. Checks the active/in-use status line, cipher, and keysize. +func (s *TridentE2EScenario) checkCryptsetupStatus( + tc storm.TestCase, + name string, + isInUse bool, +) error { + stdout, err := sudoCommand(s.sshClient, + fmt.Sprintf("cryptsetup status %s", name)) + if err != nil { + return fmt.Errorf("failed to run cryptsetup status %s: %w", name, err) + } + + lines := strings.SplitN(stdout, "\n", 2) + if len(lines) == 0 { + tc.Fail(fmt.Sprintf("empty output from cryptsetup status %s", name)) + return nil + } + + firstLine := strings.TrimSpace(lines[0]) + if isInUse { + expected := fmt.Sprintf("/dev/mapper/%s is active and is in use.", name) + if firstLine != expected { + tc.Fail(fmt.Sprintf("expected %q, got %q", expected, firstLine)) + return nil + } + } else { + expected := fmt.Sprintf("/dev/mapper/%s is active.", name) + if firstLine != expected { + tc.Fail(fmt.Sprintf("expected %q, got %q", expected, firstLine)) + return nil + } + } + + status := ParseCryptsetupStatus(stdout) + + if status.Cipher != "aes-xts-plain64" { + tc.Fail(fmt.Sprintf("expected cipher 'aes-xts-plain64', got %q", status.Cipher)) + return nil + } + if status.Keysize != "512 bits" { + tc.Fail(fmt.Sprintf("expected keysize '512 bits', got %q", status.Keysize)) + return nil + } + + logrus.Infof("Cryptsetup status validation passed for %s", name) + return nil +} + +// checkDmsetupInfo validates the output of `dmsetup info` for an encrypted +// device. Checks the name, state, tables, and UUID format. +func (s *TridentE2EScenario) checkDmsetupInfo( + tc storm.TestCase, + name string, + isSwap bool, +) error { + stdout, err := sudoCommand(s.sshClient, + fmt.Sprintf("dmsetup info %s", name)) + if err != nil { + return fmt.Errorf("failed to run dmsetup info %s: %w", name, err) + } + + info := ParseDmsetupInfo(stdout) + + if info.Name != name { + tc.Fail(fmt.Sprintf("expected Name %q, got %q", name, info.Name)) + return nil + } + if info.State != "ACTIVE" { + tc.Fail(fmt.Sprintf("expected State 'ACTIVE', got %q", info.State)) + return nil + } + if info.TablesPresent != "LIVE" { + tc.Fail(fmt.Sprintf("expected Tables present 'LIVE', got %q", info.TablesPresent)) + return nil + } + + cryptKind := "LUKS2" + if isSwap { + cryptKind = "PLAIN" + } + + expectedPrefix := fmt.Sprintf("CRYPT-%s-", cryptKind) + if !strings.HasPrefix(info.UUID, expectedPrefix) { + tc.Fail(fmt.Sprintf("expected UUID prefix %q, got %q", expectedPrefix, info.UUID)) + return nil + } + + expectedSuffix := fmt.Sprintf("-%s", name) + if !strings.HasSuffix(info.UUID, expectedSuffix) { + tc.Fail(fmt.Sprintf("expected UUID suffix %q, got %q", expectedSuffix, info.UUID)) + return nil + } + + logrus.Infof("Dmsetup info validation passed for %s", name) + return nil +} + +// checkFindmnt validates the mount point for an encrypted device using findmnt. +func (s *TridentE2EScenario) checkFindmnt( + tc storm.TestCase, + target, source string, + isActive bool, +) error { + stdout, err := sudoCommand(s.sshClient, fmt.Sprintf("findmnt %s", target)) + if err != nil { + return fmt.Errorf("failed to run findmnt %s: %w", target, err) + } + + table := ParseTable(stdout) + if len(table) != 1 { + tc.Fail(fmt.Sprintf("expected 1 findmnt row for %s, got %d", target, len(table))) + return nil + } + + row := table[0] + if row["TARGET"] != target { + tc.Fail(fmt.Sprintf("expected TARGET %q, got %q", target, row["TARGET"])) + return nil + } + if row["FSTYPE"] != "ext4" { + tc.Fail(fmt.Sprintf("expected FSTYPE 'ext4' for %s, got %q", target, row["FSTYPE"])) + return nil + } + + if isActive { + if row["SOURCE"] != source { + tc.Fail(fmt.Sprintf("expected SOURCE %q when active, got %q", source, row["SOURCE"])) + return nil + } + } else { + if row["SOURCE"] == source { + tc.Fail(fmt.Sprintf("expected SOURCE different from %q when inactive", source)) + return nil + } + } + + return nil +} + +// --- Host config accessor helpers --- + +// getChildABUpdateVolumePair checks if the given crypt ID is a member of an +// A/B update volume pair. Returns the volume pair ID, whether the crypt ID +// is volume A, and whether a pair was found. +func (s *TridentE2EScenario) getChildABUpdateVolumePair(cryptId string) (string, bool, bool) { + for _, vp := range s.originalConfig.S("storage", "abUpdate", "volumePairs").Children() { + volumeAId, _ := vp.S("volumeAId").Data().(string) + volumeBId, _ := vp.S("volumeBId").Data().(string) + + if volumeAId == cryptId { + vpId, _ := vp.S("id").Data().(string) + return vpId, true, true + } + if volumeBId == cryptId { + vpId, _ := vp.S("id").Data().(string) + return vpId, false, true + } + } + return "", false, false +} + +// getFilesystemMountPath finds the mount point path for a filesystem by its +// deviceId. The mountPoint can be either a string or an object with a "path" key. +func (s *TridentE2EScenario) getFilesystemMountPath(fsId string) string { + for _, fs := range s.originalConfig.S("storage", "filesystems").Children() { + deviceId, _ := fs.S("deviceId").Data().(string) + if deviceId != fsId { + continue + } + + mpData := fs.S("mountPoint").Data() + if mpPath, ok := mpData.(string); ok { + return mpPath + } + if mpPath, ok := fs.S("mountPoint", "path").Data().(string); ok { + return mpPath + } + } + return "" +} + +// isSwapDevice checks if the given device ID is configured as a swap device. +// Swap entries can be either a bare string (deviceId) or an object with a +// "deviceId" key. +func (s *TridentE2EScenario) isSwapDevice(devId string) bool { + for _, swapItem := range s.originalConfig.S("storage", "swap").Children() { + data := swapItem.Data() + if str, ok := data.(string); ok && str == devId { + return true + } + if did, ok := swapItem.S("deviceId").Data().(string); ok && did == devId { + return true + } + } + return false +} + +// getActiveSwaps returns a set of resolved paths for active swap devices. +func (s *TridentE2EScenario) getActiveSwaps() (map[string]bool, error) { + stdout, err := sudoCommand(s.sshClient, + "swapon --show=NAME --raw --bytes --noheadings | xargs -I @ readlink -f @") + if err != nil { + return nil, fmt.Errorf("failed to get active swaps: %w", err) + } + + swaps := make(map[string]bool) + for _, line := range strings.Split(stdout, "\n") { + line = strings.TrimSpace(line) + if line != "" { + swaps[line] = true + } + } + return swaps, nil +} + +// isDiskPartition checks if a device ID refers to a disk partition in the host +// configuration. +func (s *TridentE2EScenario) isDiskPartition(pId string) bool { + for _, disk := range s.originalConfig.S("storage", "disks").Children() { + for _, part := range disk.S("partitions").Children() { + id, _ := part.S("id").Data().(string) + if id == pId { + return true + } + } + } + return false +} + +// getRaidSoftwareArrayName returns the RAID array name for the given array ID, +// or empty string if not found. +func (s *TridentE2EScenario) getRaidSoftwareArrayName(aId string) string { + for _, arr := range s.originalConfig.S("storage", "raid", "software").Children() { + id, _ := arr.S("id").Data().(string) + if id == aId { + name, _ := arr.S("name").Data().(string) + return name + } + } + return "" +} + +// getBlockDevPathByPartlabel finds a device path by PARTLABEL from blkid +// export output. +func getBlockDevPathByPartlabel(blockDevs map[string]map[string]string, label string) string { + for devPath, props := range blockDevs { + if props["PARTLABEL"] == label { + return devPath + } + } + return "" +} diff --git a/tools/storm/e2e/scenario/validate_extensions.go b/tools/storm/e2e/scenario/validate_extensions.go new file mode 100644 index 000000000..67f60f526 --- /dev/null +++ b/tools/storm/e2e/scenario/validate_extensions.go @@ -0,0 +1,128 @@ +package scenario + +import ( + "fmt" + "path/filepath" + "strings" + + "github.com/microsoft/storm" + "github.com/sirupsen/logrus" + + "tridenttools/storm/utils/trident" +) + +// validateExtensions validates systemd-sysext and confext extensions on the +// remote host. Converted from extensions_test.py test_extensions. +// +// It validates: +// - systemd-sysext/confext status returns valid JSON +// - Each configured extension path exists on the target OS +// - Each configured extension name appears in the active extension list +func (s *TridentE2EScenario) validateExtensions(tc storm.TestCase) error { + if err := s.populateSshClient(tc.Context()); err != nil { + return fmt.Errorf("failed to populate SSH client: %w", err) + } + + // Get host status via trident get + tridentOut, err := trident.InvokeTrident(s.runtime, s.sshClient, nil, "get") + if err != nil { + return fmt.Errorf("failed to run trident get: %w", err) + } + + if tridentOut.Status != 0 { + return fmt.Errorf("trident get failed with status %d: %s", + tridentOut.Status, tridentOut.Stderr) + } + + hostStatus, err := ParseTridentGetOutput(tridentOut.Stdout) + if err != nil { + return fmt.Errorf("failed to parse trident get output: %w", err) + } + + spec, _ := hostStatus["spec"].(map[interface{}]interface{}) + if spec == nil { + tc.Fail("no spec found in host status") + return nil + } + + osConfig, _ := spec["os"].(map[interface{}]interface{}) + if osConfig == nil { + tc.Fail("no os config found in host status spec") + return nil + } + + // Validate each extension type: sysext and confext + for _, extType := range []string{"sysext", "confext"} { + configKey := extType + "s" + extConfigRaw, ok := osConfig[configKey] + if !ok { + continue + } + + extConfigList, ok := extConfigRaw.([]interface{}) + if !ok || len(extConfigList) == 0 { + continue + } + + logrus.Infof("Validating %s extensions (%d configured)", extType, len(extConfigList)) + + // Run systemd-sysext/confext status --json=pretty + statusOut, err := sudoCommand(s.sshClient, + fmt.Sprintf("systemd-%s status --json=pretty --no-pager", extType)) + if err != nil { + tc.Fail(fmt.Sprintf("failed to run 'systemd-%s status': %v", extType, err)) + continue + } + + hierarchies, err := ParseSysextStatus(statusOut) + if err != nil { + tc.Fail(fmt.Sprintf("failed to parse 'systemd-%s status' JSON: %v", extType, err)) + continue + } + + activeExts := AllActiveExtensions(hierarchies) + + for _, extRaw := range extConfigList { + ext, ok := extRaw.(map[interface{}]interface{}) + if !ok { + continue + } + + extPath, _ := ext["path"].(string) + if extPath == "" { + continue + } + + // Verify that the path exists on the target OS + _, err := sudoCommand(s.sshClient, fmt.Sprintf("test -e %s", extPath)) + if err != nil { + tc.Fail(fmt.Sprintf("%s path does not exist: %s", extType, extPath)) + continue + } + + // Extract extension name from path (equivalent to Python's Path.stem) + extName := strings.TrimSuffix(filepath.Base(extPath), filepath.Ext(extPath)) + + if !containsString(activeExts, extName) { + tc.Fail(fmt.Sprintf("%s '%s' not found in 'systemd-%s status'", + extType, extName, extType)) + continue + } + + logrus.Infof("Extension validated: %s %s (%s)", extType, extName, extPath) + } + } + + logrus.Info("Extensions validation passed") + return nil +} + +// containsString checks if a string slice contains a given value. +func containsString(slice []string, val string) bool { + for _, s := range slice { + if s == val { + return true + } + } + return false +} diff --git a/tools/storm/e2e/scenario/validate_rollback.go b/tools/storm/e2e/scenario/validate_rollback.go new file mode 100644 index 000000000..a8a0ac0da --- /dev/null +++ b/tools/storm/e2e/scenario/validate_rollback.go @@ -0,0 +1,140 @@ +package scenario + +import ( + "fmt" + "strings" + + "github.com/microsoft/storm" + "github.com/sirupsen/logrus" + "gopkg.in/yaml.v2" + + "tridenttools/storm/utils/trident" +) + +// validateRollback validates health check rollback behavior on the remote host. +// Converted from rollback_test.py test_rollback. +// +// It validates: +// - servicingState matches expected state based on scenario context +// - lastError contains "Failed health check(s)" +// - abActiveVolume is absent (not-provisioned) or unchanged (after A/B update) +// - Exactly one health-check-failure log file exists +// - Log file contains expected failure messages +func (s *TridentE2EScenario) validateRollback(tc storm.TestCase) error { + if err := s.populateSshClient(tc.Context()); err != nil { + return fmt.Errorf("failed to populate SSH client: %w", err) + } + + // Get host status via trident get + tridentOut, err := trident.InvokeTrident(s.runtime, s.sshClient, nil, "get") + if err != nil { + return fmt.Errorf("failed to run trident get: %w", err) + } + + if tridentOut.Status != 0 { + return fmt.Errorf("trident get failed with status %d: %s", + tridentOut.Status, tridentOut.Stderr) + } + + hostStatus, err := ParseTridentGetOutput(tridentOut.Stdout) + if err != nil { + return fmt.Errorf("failed to parse trident get output: %w", err) + } + + // Determine expected state based on scenario context. + // For health-checks-install (no A/B update), health check failures during + // clean install result in "not-provisioned" state. + // For scenarios with A/B update, the state remains "provisioned". + expectedState := "not-provisioned" + if s.originalConfig.HasABUpdate() { + expectedState = "provisioned" + } + + // Validate servicingState + servicingState, _ := hostStatus["servicingState"].(string) + if servicingState != expectedState { + tc.Fail(fmt.Sprintf("expected servicingState %q, got %q", + expectedState, servicingState)) + return nil + } + logrus.Infof("servicingState matches expected: %s", expectedState) + + // Validate lastError contains "Failed health check(s)" + lastError, ok := hostStatus["lastError"] + if !ok { + tc.Fail("lastError not found in host status") + return nil + } + + lastErrorYAML, err := yaml.Marshal(lastError) + if err != nil { + return fmt.Errorf("failed to serialize lastError to YAML: %w", err) + } + + if !strings.Contains(string(lastErrorYAML), "Failed health check(s)") { + tc.Fail(fmt.Sprintf("lastError does not contain 'Failed health check(s)': %s", + string(lastErrorYAML))) + return nil + } + logrus.Info("lastError contains expected health check failure message") + + // Validate abActiveVolume + if expectedState == "not-provisioned" { + // When not provisioned, abActiveVolume should not be set + if _, exists := hostStatus["abActiveVolume"]; exists { + tc.Fail("abActiveVolume should not be set when state is not-provisioned") + return nil + } + logrus.Info("abActiveVolume correctly absent for not-provisioned state") + } else { + // After A/B update rollback, active volume should remain unchanged + hsActiveVolume, _ := hostStatus["abActiveVolume"].(string) + expectedVolume := "volume-a" + if hsActiveVolume != expectedVolume { + tc.Fail(fmt.Sprintf("expected abActiveVolume %q, got %q", + expectedVolume, hsActiveVolume)) + return nil + } + logrus.Infof("abActiveVolume matches expected: %s", expectedVolume) + } + + // Validate health check failure log files + listLogsOut, err := sudoCommand(s.sshClient, + "ls /var/lib/trident/trident-health-check-failure-*.log") + if err != nil { + tc.Fail(fmt.Sprintf("failed to list health check failure logs: %v", err)) + return nil + } + + logFiles := strings.Split(strings.TrimSpace(listLogsOut), "\n") + if len(logFiles) != 1 { + tc.Fail(fmt.Sprintf("expected exactly 1 health check failure log file, found %d", + len(logFiles))) + return nil + } + logrus.Infof("Found health check failure log: %s", logFiles[0]) + + // Read log file contents + logContent, err := sudoCommand(s.sshClient, fmt.Sprintf("cat %s", logFiles[0])) + if err != nil { + tc.Fail(fmt.Sprintf("failed to read health check failure log: %v", err)) + return nil + } + + // Verify expected failure messages in log + expectedMessages := []string{ + "Script 'invoke-rollback-from-script' failed", + "Unit non-existent-service1.service could not be found", + "Unit non-existent-service2.service could not be found", + } + + for _, msg := range expectedMessages { + if !strings.Contains(logContent, msg) { + tc.Fail(fmt.Sprintf("health check failure log does not contain %q", msg)) + return nil + } + } + + logrus.Info("Rollback validation passed") + return nil +} diff --git a/tools/storm/e2e/scenario/validate_verity.go b/tools/storm/e2e/scenario/validate_verity.go new file mode 100644 index 000000000..eb00a06b8 --- /dev/null +++ b/tools/storm/e2e/scenario/validate_verity.go @@ -0,0 +1,391 @@ +package scenario + +import ( + "fmt" + "strings" + + "github.com/microsoft/storm" + "github.com/sirupsen/logrus" + + "tridenttools/storm/utils/trident" +) + +// validateVerity validates DM-Verity root filesystem configuration on the +// remote host. Converted from verity_test.py test_verity_root. +// +// It validates: +// - /dev/mapper/root exists in blkid output +// - veritysetup status reports type VERITY, status verified, mode readonly +// - Data and hash device mapping matches host status verity configuration +// - A/B active volume matches expected block devices (partition or RAID) +func (s *TridentE2EScenario) validateVerity(tc storm.TestCase) error { + if err := s.populateSshClient(tc.Context()); err != nil { + return fmt.Errorf("failed to populate SSH client: %w", err) + } + + // --- 1. Run blkid and verify /dev/mapper/root exists --- + blkidOut, err := sudoCommand(s.sshClient, "blkid") + if err != nil { + return fmt.Errorf("failed to run blkid: %w", err) + } + + blkidEntries := ParseBlkid(blkidOut) + + if _, ok := blkidEntries["root"]; !ok { + tc.Fail("/dev/mapper/root not found in blkid output") + return nil + } + + // Build a map from short device name to PARTLABEL for later device matching + partitionsByLabel := make(map[string]BlkidEntry) + for name, entry := range blkidEntries { + partitionsByLabel[name] = entry + } + + // --- 2. Run veritysetup status and validate key properties --- + verityStatusOut, err := sudoCommand(s.sshClient, "veritysetup status root") + if err != nil { + return fmt.Errorf("failed to run veritysetup status root: %w", err) + } + + verityStatus := ParseVeritySetupStatus(verityStatusOut) + + if !verityStatus.IsActive || !verityStatus.IsInUse { + tc.Fail(fmt.Sprintf("expected /dev/mapper/root to be active and in use, got: %q", + verityStatus.StatusLine)) + return nil + } + + if verityStatus.Properties["type"] != "VERITY" { + tc.Fail(fmt.Sprintf("expected veritysetup type 'VERITY', got %q", + verityStatus.Properties["type"])) + return nil + } + + if verityStatus.Properties["status"] != "verified" { + tc.Fail(fmt.Sprintf("expected veritysetup status 'verified', got %q", + verityStatus.Properties["status"])) + return nil + } + + if verityStatus.Properties["mode"] != "readonly" { + tc.Fail(fmt.Sprintf("expected veritysetup mode 'readonly', got %q", + verityStatus.Properties["mode"])) + return nil + } + + logrus.Info("Verity status validation passed (type=VERITY, status=verified, mode=readonly)") + + // --- 3. Get host status via trident get --- + tridentOut, err := trident.InvokeTrident(s.runtime, s.sshClient, nil, "get") + if err != nil { + return fmt.Errorf("failed to run trident get: %w", err) + } + + if tridentOut.Status != 0 { + return fmt.Errorf("trident get failed with status %d: %s", + tridentOut.Status, tridentOut.Stderr) + } + + hostStatus, err := ParseTridentGetOutput(tridentOut.Stdout) + if err != nil { + return fmt.Errorf("failed to parse trident get output: %w", err) + } + + // --- 4. Find root mount filesystem and its verity device --- + spec, _ := hostStatus["spec"].(map[interface{}]interface{}) + if spec == nil { + tc.Fail("no spec found in host status") + return nil + } + + storage, _ := spec["storage"].(map[interface{}]interface{}) + if storage == nil { + tc.Fail("no storage found in host status spec") + return nil + } + + // Find the filesystem with mountPoint path "/" + var rootMountID string + filesystems, _ := storage["filesystems"].([]interface{}) + for _, fsRaw := range filesystems { + fs, _ := fsRaw.(map[interface{}]interface{}) + if fs == nil { + continue + } + mp, _ := fs["mountPoint"].(map[interface{}]interface{}) + if mp == nil { + continue + } + if path, _ := mp["path"].(string); path == "/" { + rootMountID, _ = fs["deviceId"].(string) + break + } + } + + if rootMountID == "" { + tc.Fail("root mount point not found in host status filesystems") + return nil + } + + logrus.Infof("Root mount device ID: %s", rootMountID) + + // Find the verity device matching the root mount + var verityDeviceName, dataDeviceID, hashDeviceID string + verityList, _ := storage["verity"].([]interface{}) + for _, vRaw := range verityList { + v, _ := vRaw.(map[interface{}]interface{}) + if v == nil { + continue + } + if vID, _ := v["id"].(string); vID == rootMountID { + verityDeviceName, _ = v["name"].(string) + dataDeviceID, _ = v["dataDeviceId"].(string) + hashDeviceID, _ = v["hashDeviceId"].(string) + break + } + } + + if verityDeviceName == "" || hashDeviceID == "" { + tc.Fail(fmt.Sprintf("no verity configuration found for root mount ID %q", rootMountID)) + return nil + } + + logrus.Infof("Verity device: name=%s, dataDeviceId=%s, hashDeviceId=%s", + verityDeviceName, dataDeviceID, hashDeviceID) + + // --- 5. Validate data/hash devices against block devices --- + // After initial install, the active volume is always volume-a. + abActiveVolume := "volume-a" + + _, hasABUpdate := storage["abUpdate"] + if hasABUpdate { + return s.validateVerityWithABUpdate(tc, blkidEntries, verityDeviceName, + dataDeviceID, hashDeviceID, storage, abActiveVolume) + } + + return s.validateVerityWithoutABUpdate(tc, blkidEntries, verityStatus, + dataDeviceID, hashDeviceID) +} + +// validateVerityWithABUpdate validates verity data/hash devices when A/B update +// is configured. It identifies the active volume pair members and checks they +// match the veritysetup output (resolving through RAID if needed). +func (s *TridentE2EScenario) validateVerityWithABUpdate( + tc storm.TestCase, + blkidEntries map[string]BlkidEntry, + verityDeviceName, dataDeviceID, hashDeviceID string, + storage map[interface{}]interface{}, + abActiveVolume string, +) error { + // Find the active data and hash device IDs from A/B volume pairs + var activeDataID, activeHashID string + abUpdate, _ := storage["abUpdate"].(map[interface{}]interface{}) + volumePairs, _ := abUpdate["volumePairs"].([]interface{}) + + for _, vpRaw := range volumePairs { + vp, _ := vpRaw.(map[interface{}]interface{}) + if vp == nil { + continue + } + vpID, _ := vp["id"].(string) + + if vpID == dataDeviceID { + if abActiveVolume == "volume-a" { + activeDataID, _ = vp["volumeAId"].(string) + } else { + activeDataID, _ = vp["volumeBId"].(string) + } + } + + if vpID == hashDeviceID { + if abActiveVolume == "volume-a" { + activeHashID, _ = vp["volumeAId"].(string) + } else { + activeHashID, _ = vp["volumeBId"].(string) + } + } + } + + if activeDataID == "" || activeHashID == "" { + tc.Fail(fmt.Sprintf( + "could not find active A/B volume IDs for data=%q hash=%q (activeVolume=%s)", + dataDeviceID, hashDeviceID, abActiveVolume)) + return nil + } + + logrus.Infof("Active A/B volumes: data=%s, hash=%s", activeDataID, activeHashID) + + // Get data/hash block device paths from veritysetup status + dataBlockDevice, hashBlockDevice, err := s.getVerityDevicePaths(verityDeviceName) + if err != nil { + return err + } + + // Check if devices are RAID arrays + dataRaidName, err := GetRaidNameFromDeviceName(s.sshClient, dataBlockDevice) + if err != nil { + return fmt.Errorf("failed to check RAID for data device %s: %w", dataBlockDevice, err) + } + + hashRaidName, err := GetRaidNameFromDeviceName(s.sshClient, hashBlockDevice) + if err != nil { + return fmt.Errorf("failed to check RAID for hash device %s: %w", hashBlockDevice, err) + } + + // Both must be the same type (both RAID or both partition) + if (dataRaidName == "") != (hashRaidName == "") { + tc.Fail(fmt.Sprintf( + "data and hash devices must both be RAID or both be partitions: data_raid=%q, hash_raid=%q", + dataRaidName, hashRaidName)) + return nil + } + + if dataRaidName != "" { + // RAID: extract name from path (e.g. /dev/md/root-a โ†’ root-a) + extractedData := extractBaseName(dataRaidName) + extractedHash := extractBaseName(hashRaidName) + + if extractedData != activeDataID { + tc.Fail(fmt.Sprintf("expected active data RAID name %q, got %q", + activeDataID, extractedData)) + return nil + } + + if extractedHash != activeHashID { + tc.Fail(fmt.Sprintf("expected active hash RAID name %q, got %q", + activeHashID, extractedHash)) + return nil + } + } else { + // Partition: look up PARTLABEL in blkid + extractedData := extractBaseName(dataBlockDevice) + extractedHash := extractBaseName(hashBlockDevice) + + dataEntry, dataOK := blkidEntries[extractedData] + hashEntry, hashOK := blkidEntries[extractedHash] + + if !dataOK || !hashOK { + tc.Fail(fmt.Sprintf( + "data or hash block device not found in blkid: data=%q (found=%v), hash=%q (found=%v)", + extractedData, dataOK, extractedHash, hashOK)) + return nil + } + + dataPartLabel := dataEntry.Properties["PARTLABEL"] + hashPartLabel := hashEntry.Properties["PARTLABEL"] + + if dataPartLabel != activeDataID { + tc.Fail(fmt.Sprintf("expected data PARTLABEL %q, got %q", + activeDataID, dataPartLabel)) + return nil + } + + if hashPartLabel != activeHashID { + tc.Fail(fmt.Sprintf("expected hash PARTLABEL %q, got %q", + activeHashID, hashPartLabel)) + return nil + } + } + + logrus.Info("Verity A/B update device validation passed") + return nil +} + +// validateVerityWithoutABUpdate validates verity data/hash devices when no A/B +// update is configured. It checks the veritysetup status devices match the +// expected device IDs (resolving through RAID if needed). +func (s *TridentE2EScenario) validateVerityWithoutABUpdate( + tc storm.TestCase, + blkidEntries map[string]BlkidEntry, + verityStatus VerityStatus, + dataDeviceID, hashDeviceID string, +) error { + dataBlockDevice := verityStatus.DataDevice + hashBlockDevice := verityStatus.HashDevice + + // Check if devices are RAID arrays + dataRaidName, err := GetRaidNameFromDeviceName(s.sshClient, dataBlockDevice) + if err != nil { + return fmt.Errorf("failed to check RAID for data device %s: %w", dataBlockDevice, err) + } + + hashRaidName, err := GetRaidNameFromDeviceName(s.sshClient, hashBlockDevice) + if err != nil { + return fmt.Errorf("failed to check RAID for hash device %s: %w", hashBlockDevice, err) + } + + // Both must be the same type + if (dataRaidName == "") != (hashRaidName == "") { + tc.Fail(fmt.Sprintf( + "data and hash devices must both be RAID or both be partitions: data_raid=%q, hash_raid=%q", + dataRaidName, hashRaidName)) + return nil + } + + if dataRaidName != "" { + // RAID: extract name (e.g. /dev/md/root โ†’ root) + extractedData := extractBaseName(dataRaidName) + extractedHash := extractBaseName(hashRaidName) + + if extractedData != dataDeviceID { + tc.Fail(fmt.Sprintf("expected data RAID device ID %q, got %q", + dataDeviceID, extractedData)) + return nil + } + + if extractedHash != hashDeviceID { + tc.Fail(fmt.Sprintf("expected hash RAID device ID %q, got %q", + hashDeviceID, extractedHash)) + return nil + } + } else { + // Partition: verify devices exist in blkid + extractedData := extractBaseName(dataBlockDevice) + extractedHash := extractBaseName(hashBlockDevice) + + if _, ok := blkidEntries[extractedData]; !ok { + tc.Fail(fmt.Sprintf("data block device %q not found in blkid output", + extractedData)) + return nil + } + + if _, ok := blkidEntries[extractedHash]; !ok { + tc.Fail(fmt.Sprintf("hash block device %q not found in blkid output", + extractedHash)) + return nil + } + } + + logrus.Info("Verity device validation passed (no A/B update)") + return nil +} + +// getVerityDevicePaths runs `veritysetup status` for the given device name +// and extracts the data and hash block device paths. +func (s *TridentE2EScenario) getVerityDevicePaths(deviceName string) (string, string, error) { + stdout, err := sudoCommand(s.sshClient, + fmt.Sprintf("veritysetup status %s", deviceName)) + if err != nil { + return "", "", fmt.Errorf("failed to run veritysetup status %s: %w", + deviceName, err) + } + + status := ParseVeritySetupStatus(stdout) + + if status.DataDevice == "" || status.HashDevice == "" { + return "", "", fmt.Errorf( + "failed to extract data/hash device from veritysetup status %s", deviceName) + } + + return status.DataDevice, status.HashDevice, nil +} + +// extractBaseName returns the last path component (e.g. "/dev/md/root-a" โ†’ "root-a", +// "/dev/sda3" โ†’ "sda3"). +func extractBaseName(path string) string { + if idx := strings.LastIndex(path, "/"); idx >= 0 { + return path[idx+1:] + } + return path +} diff --git a/tools/storm/e2e/testselection.go b/tools/storm/e2e/testselection.go new file mode 100644 index 000000000..f968b4335 --- /dev/null +++ b/tools/storm/e2e/testselection.go @@ -0,0 +1,138 @@ +package e2e + +import ( + "fmt" + + "gopkg.in/yaml.v3" +) + +const ( + // TestTagPrefix is the prefix applied to all test selection markers when + // converting them to storm scenario tags. + TestTagPrefix = "test:" +) + +// TestSelection represents the parsed contents of a test-selection.yaml file. +// The "compatible" list contains the base set of test markers that a +// configuration supports. Ring-level overrides can add or remove markers for +// specific pipeline stages. +type TestSelection struct { + Compatible []string `yaml:"compatible"` + Weekly *TestSelectionOverride `yaml:"weekly,omitempty"` + Daily *TestSelectionOverride `yaml:"daily,omitempty"` + PostMerge *TestSelectionOverride `yaml:"post_merge,omitempty"` + PullRequest *TestSelectionOverride `yaml:"pullrequest,omitempty"` + Validation *TestSelectionOverride `yaml:"validation,omitempty"` +} + +// TestSelectionOverride describes markers to add or remove relative to the +// compatible set for a specific pipeline ring. +type TestSelectionOverride struct { + Add []string `yaml:"add,omitempty"` + Remove []string `yaml:"remove,omitempty"` +} + +// ParseTestSelection parses a test-selection.yaml file into a TestSelection. +func ParseTestSelection(data []byte) (*TestSelection, error) { + var ts TestSelection + if err := yaml.Unmarshal(data, &ts); err != nil { + return nil, fmt.Errorf("failed to parse test-selection YAML: %w", err) + } + return &ts, nil +} + +// TestTags returns the compatible markers as storm scenario tags with the +// "test:" prefix. This is the base set of test tags for the configuration. +func (ts *TestSelection) TestTags() []string { + tags := make([]string, 0, len(ts.Compatible)) + for _, marker := range ts.Compatible { + tags = append(tags, TestTagPrefix+marker) + } + return tags +} + +// TestTagsForRing returns the resolved set of test tags after applying any +// ring-specific overrides. If no override exists for the given ring, the base +// compatible tags are returned. The ring parameter should match one of the +// YAML keys: "weekly", "daily", "post_merge", "pullrequest", "validation". +func (ts *TestSelection) TestTagsForRing(ring string) []string { + override := ts.overrideForRing(ring) + if override == nil { + return ts.TestTags() + } + return applyOverride(ts.Compatible, override) +} + +// overrideForRing returns the TestSelectionOverride for a given ring name, or +// nil if no override is defined. +func (ts *TestSelection) overrideForRing(ring string) *TestSelectionOverride { + switch ring { + case "weekly": + return ts.Weekly + case "daily": + return ts.Daily + case "post_merge": + return ts.PostMerge + case "pullrequest": + return ts.PullRequest + case "validation": + return ts.Validation + default: + return nil + } +} + +// RingNames returns the list of recognised ring override names. +func RingNames() []string { + return []string{"weekly", "daily", "post_merge", "pullrequest", "validation"} +} + +// applyOverride computes the resolved marker list by starting from the base +// compatible set, removing any markers in override.Remove, then appending any +// markers in override.Add. The result is returned as prefixed tags. +func applyOverride(compatible []string, override *TestSelectionOverride) []string { + // Build a set from the compatible markers. + markerSet := make(map[string]struct{}, len(compatible)) + for _, m := range compatible { + markerSet[m] = struct{}{} + } + + // Remove entries. + for _, m := range override.Remove { + delete(markerSet, m) + } + + // Add entries. + for _, m := range override.Add { + markerSet[m] = struct{}{} + } + + // Convert to sorted tag list for deterministic output. + tags := make([]string, 0, len(markerSet)) + // Preserve order: first compatible (if still present), then added. + seen := make(map[string]bool, len(markerSet)) + for _, m := range compatible { + if _, ok := markerSet[m]; ok && !seen[m] { + tags = append(tags, TestTagPrefix+m) + seen[m] = true + } + } + for _, m := range override.Add { + if _, ok := markerSet[m]; ok && !seen[m] { + tags = append(tags, TestTagPrefix+m) + seen[m] = true + } + } + + return tags +} + +// HasMarker reports whether the compatible list contains the given marker. +func (ts *TestSelection) HasMarker(marker string) bool { + for _, m := range ts.Compatible { + if m == marker { + return true + } + } + return false +} diff --git a/tools/storm/e2e/testselection_test.go b/tools/storm/e2e/testselection_test.go new file mode 100644 index 000000000..43f4a3c3c --- /dev/null +++ b/tools/storm/e2e/testselection_test.go @@ -0,0 +1,331 @@ +package e2e + +import ( + "reflect" + "testing" +) + +func TestParseTestSelection_SimpleCompatible(t *testing.T) { + input := []byte(`compatible: + - base +`) + ts, err := ParseTestSelection(input) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(ts.Compatible) != 1 || ts.Compatible[0] != "base" { + t.Fatalf("expected [base], got %v", ts.Compatible) + } + if ts.Weekly != nil || ts.Daily != nil || ts.PostMerge != nil || + ts.PullRequest != nil || ts.Validation != nil { + t.Error("expected all overrides to be nil") + } +} + +func TestParseTestSelection_MultipleMarkers(t *testing.T) { + input := []byte(`compatible: + - base + - usr_verity + - encryption + - uki +`) + ts, err := ParseTestSelection(input) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + expected := []string{"base", "usr_verity", "encryption", "uki"} + if !reflect.DeepEqual(ts.Compatible, expected) { + t.Fatalf("expected %v, got %v", expected, ts.Compatible) + } +} + +func TestParseTestSelection_WithOverrides(t *testing.T) { + input := []byte(`compatible: + - marker1 + - marker2 + - marker3 + - marker4 + - marker5 +weekly: + remove: + - marker5 +daily: + remove: + - marker5 +post_merge: + remove: + - marker4 + - marker2 + add: + - extra_test +pullrequest: + remove: + - marker3 +validation: + remove: + - marker1 + add: + - val_test1 + - val_test2 +`) + ts, err := ParseTestSelection(input) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(ts.Compatible) != 5 { + t.Fatalf("expected 5 compatible markers, got %d", len(ts.Compatible)) + } + + // Weekly override + if ts.Weekly == nil { + t.Fatal("expected weekly override") + } + if len(ts.Weekly.Remove) != 1 || ts.Weekly.Remove[0] != "marker5" { + t.Errorf("weekly remove: expected [marker5], got %v", ts.Weekly.Remove) + } + + // Post-merge override + if ts.PostMerge == nil { + t.Fatal("expected post_merge override") + } + if len(ts.PostMerge.Remove) != 2 { + t.Errorf("post_merge remove: expected 2 items, got %d", len(ts.PostMerge.Remove)) + } + if len(ts.PostMerge.Add) != 1 || ts.PostMerge.Add[0] != "extra_test" { + t.Errorf("post_merge add: expected [extra_test], got %v", ts.PostMerge.Add) + } + + // Validation override + if ts.Validation == nil { + t.Fatal("expected validation override") + } + if len(ts.Validation.Add) != 2 { + t.Errorf("validation add: expected 2 items, got %d", len(ts.Validation.Add)) + } +} + +func TestTestTags_SimpleCompatible(t *testing.T) { + ts := &TestSelection{ + Compatible: []string{"base", "encryption"}, + } + tags := ts.TestTags() + expected := []string{"test:base", "test:encryption"} + if !reflect.DeepEqual(tags, expected) { + t.Fatalf("expected %v, got %v", expected, tags) + } +} + +func TestTestTags_EmptyCompatible(t *testing.T) { + ts := &TestSelection{} + tags := ts.TestTags() + if len(tags) != 0 { + t.Fatalf("expected empty tags, got %v", tags) + } +} + +func TestTestTagsForRing_NoOverride(t *testing.T) { + ts := &TestSelection{ + Compatible: []string{"base", "encryption"}, + } + // No weekly override defined, should return base tags. + tags := ts.TestTagsForRing("weekly") + expected := []string{"test:base", "test:encryption"} + if !reflect.DeepEqual(tags, expected) { + t.Fatalf("expected %v, got %v", expected, tags) + } +} + +func TestTestTagsForRing_UnknownRing(t *testing.T) { + ts := &TestSelection{ + Compatible: []string{"base"}, + } + // Unknown ring should return base tags. + tags := ts.TestTagsForRing("unknown_ring") + expected := []string{"test:base"} + if !reflect.DeepEqual(tags, expected) { + t.Fatalf("expected %v, got %v", expected, tags) + } +} + +func TestTestTagsForRing_WithRemove(t *testing.T) { + ts := &TestSelection{ + Compatible: []string{"marker1", "marker2", "marker3"}, + PullRequest: &TestSelectionOverride{ + Remove: []string{"marker2"}, + }, + } + tags := ts.TestTagsForRing("pullrequest") + expected := []string{"test:marker1", "test:marker3"} + if !reflect.DeepEqual(tags, expected) { + t.Fatalf("expected %v, got %v", expected, tags) + } +} + +func TestTestTagsForRing_WithAdd(t *testing.T) { + ts := &TestSelection{ + Compatible: []string{"base"}, + Validation: &TestSelectionOverride{ + Add: []string{"val_test1", "val_test2"}, + }, + } + tags := ts.TestTagsForRing("validation") + expected := []string{"test:base", "test:val_test1", "test:val_test2"} + if !reflect.DeepEqual(tags, expected) { + t.Fatalf("expected %v, got %v", expected, tags) + } +} + +func TestTestTagsForRing_WithAddAndRemove(t *testing.T) { + ts := &TestSelection{ + Compatible: []string{"marker1", "marker2", "marker3"}, + PostMerge: &TestSelectionOverride{ + Remove: []string{"marker2"}, + Add: []string{"extra_test"}, + }, + } + tags := ts.TestTagsForRing("post_merge") + expected := []string{"test:marker1", "test:marker3", "test:extra_test"} + if !reflect.DeepEqual(tags, expected) { + t.Fatalf("expected %v, got %v", expected, tags) + } +} + +func TestTestTagsForRing_AllRings(t *testing.T) { + ts := &TestSelection{ + Compatible: []string{"a", "b", "c"}, + Weekly: &TestSelectionOverride{Remove: []string{"c"}}, + Daily: &TestSelectionOverride{Remove: []string{"b"}}, + PostMerge: &TestSelectionOverride{Add: []string{"d"}}, + PullRequest: &TestSelectionOverride{Remove: []string{"a", "b"}}, + Validation: &TestSelectionOverride{Remove: []string{"a"}, Add: []string{"e"}}, + } + + tests := []struct { + ring string + expected []string + }{ + {"weekly", []string{"test:a", "test:b"}}, + {"daily", []string{"test:a", "test:c"}}, + {"post_merge", []string{"test:a", "test:b", "test:c", "test:d"}}, + {"pullrequest", []string{"test:c"}}, + {"validation", []string{"test:b", "test:c", "test:e"}}, + } + + for _, tc := range tests { + t.Run(tc.ring, func(t *testing.T) { + tags := ts.TestTagsForRing(tc.ring) + if !reflect.DeepEqual(tags, tc.expected) { + t.Errorf("ring %s: expected %v, got %v", tc.ring, tc.expected, tags) + } + }) + } +} + +func TestParseTestSelection_RealBaseConfig(t *testing.T) { + // Matches the actual base/test-selection.yaml format. + input := []byte(`compatible: + - base +`) + ts, err := ParseTestSelection(input) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + tags := ts.TestTags() + if len(tags) != 1 || tags[0] != "test:base" { + t.Fatalf("expected [test:base], got %v", tags) + } +} + +func TestParseTestSelection_RealCombinedConfig(t *testing.T) { + // Matches the actual combined/test-selection.yaml format. + input := []byte(`compatible: + - base + - usr_verity + - encryption + - uki +`) + ts, err := ParseTestSelection(input) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + tags := ts.TestTags() + expected := []string{"test:base", "test:usr_verity", "test:encryption", "test:uki"} + if !reflect.DeepEqual(tags, expected) { + t.Fatalf("expected %v, got %v", expected, tags) + } +} + +func TestHasMarker(t *testing.T) { + ts := &TestSelection{ + Compatible: []string{"base", "encryption"}, + } + + if !ts.HasMarker("base") { + t.Error("expected HasMarker('base') to return true") + } + if !ts.HasMarker("encryption") { + t.Error("expected HasMarker('encryption') to return true") + } + if ts.HasMarker("verity") { + t.Error("expected HasMarker('verity') to return false") + } + if ts.HasMarker("") { + t.Error("expected HasMarker('') to return false") + } +} + +func TestParseTestSelection_InvalidYAML(t *testing.T) { + input := []byte(`{{{invalid yaml`) + _, err := ParseTestSelection(input) + if err == nil { + t.Fatal("expected error for invalid YAML") + } +} + +func TestParseTestSelection_EmptyInput(t *testing.T) { + input := []byte(``) + ts, err := ParseTestSelection(input) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(ts.Compatible) != 0 { + t.Fatalf("expected empty compatible, got %v", ts.Compatible) + } +} + +func TestRingNames(t *testing.T) { + names := RingNames() + expected := []string{"weekly", "daily", "post_merge", "pullrequest", "validation"} + if !reflect.DeepEqual(names, expected) { + t.Fatalf("expected %v, got %v", expected, names) + } +} + +func TestTestTagsForRing_RemoveNonexistent(t *testing.T) { + ts := &TestSelection{ + Compatible: []string{"base", "encryption"}, + Weekly: &TestSelectionOverride{ + Remove: []string{"nonexistent"}, + }, + } + tags := ts.TestTagsForRing("weekly") + expected := []string{"test:base", "test:encryption"} + if !reflect.DeepEqual(tags, expected) { + t.Fatalf("expected %v, got %v", expected, tags) + } +} + +func TestTestTagsForRing_AddDuplicate(t *testing.T) { + ts := &TestSelection{ + Compatible: []string{"base"}, + Weekly: &TestSelectionOverride{ + Add: []string{"base"}, // already in compatible + }, + } + tags := ts.TestTagsForRing("weekly") + expected := []string{"test:base"} + if !reflect.DeepEqual(tags, expected) { + t.Fatalf("expected %v (no duplicates), got %v", expected, tags) + } +}