diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 000000000..3b4a67a60 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,2 @@ +# For any changes, ensure owners approvals +* @microsoft/azurelinux-trident diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 000000000..b86ceb8ba --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,100 @@ +# For most projects, this workflow file will not need changing; you simply need +# to commit it to your repository. +# +# You may wish to alter this file to override the set of languages analyzed, +# or to provide custom queries or build logic. +# +# ******** NOTE ******** +# We have attempted to detect the languages in your repository. Please check +# the `language` matrix defined below to confirm you have the correct set of +# supported CodeQL languages. +# +name: "CodeQL Advanced" + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + schedule: + - cron: '43 9 * * 0' + +jobs: + analyze: + name: Analyze (${{ matrix.language }}) + # Runner size impacts CodeQL analysis time. To learn more, please see: + # - https://gh.io/recommended-hardware-resources-for-running-codeql + # - https://gh.io/supported-runners-and-hardware-resources + # - https://gh.io/using-larger-runners (GitHub.com only) + # Consider using larger runners or machines with greater resources for possible analysis time improvements. + runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }} + permissions: + # required for all workflows + security-events: write + + # required to fetch internal or private CodeQL packs + packages: read + + # only required for workflows in private repositories + actions: read + contents: read + + strategy: + fail-fast: false + matrix: + include: + - language: go + build-mode: autobuild + - language: python + build-mode: none + # CodeQL supports the following values keywords for 'language': 'actions', 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'swift' + # Use `c-cpp` to analyze code written in C, C++ or both + # Use 'java-kotlin' to analyze code written in Java, Kotlin or both + # Use 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both + # To learn more about changing the languages that are analyzed or customizing the build mode for your analysis, + # see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/customizing-your-advanced-setup-for-code-scanning. + # If you are analyzing a compiled language, you can modify the 'build-mode' for that language to customize how + # your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + # Add any setup steps before running the `github/codeql-action/init` action. + # This includes steps like installing compilers or runtimes (`actions/setup-node` + # or others). This is typically only required for manual builds. + # - name: Setup runtime (example) + # uses: actions/setup-example@v1 + + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: ${{ matrix.language }} + build-mode: ${{ matrix.build-mode }} + # If you wish to specify custom queries, you can do so here or in a config file. + # By default, queries listed here will override any specified in a config file. + # Prefix the list here with "+" to use these queries and those in the config file. + + # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs + # queries: security-extended,security-and-quality + + # If the analyze step fails for one of the languages you are analyzing with + # "We were unable to automatically build your code", modify the matrix above + # to set the build mode to "manual" for that language. Then modify this step + # to build your code. + # â„šī¸ Command-line programs to run using the OS shell. + # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun + - if: matrix.build-mode == 'manual' + shell: bash + run: | + echo 'If you are using a "manual" build mode for one or more of the' \ + 'languages you are analyzing, replace this with the commands to build' \ + 'your code, for example:' + echo ' make bootstrap' + echo ' make release' + exit 1 + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v3 + with: + category: "/language:${{matrix.language}}" diff --git a/.pipelines/templates/MockOB.yml b/.pipelines/templates/MockOB.yml new file mode 100644 index 000000000..a656b3d7a --- /dev/null +++ b/.pipelines/templates/MockOB.yml @@ -0,0 +1,114 @@ +parameters: + - name: stages + type: stageList + default: [] + +stages: + - ${{ each stage in parameters.stages }}: + - stage: ${{ stage.stage }} + ${{ if ne(stage.displayName, '') }}: + displayName: ${{ stage.displayName }} + + ${{ if ne(join(stage.dependsOn, ','), '') }}: + dependsOn: ${{ stage.dependsOn }} + + ${{ if ne(stage.condition, '') }}: + condition: ${{ stage.condition }} + + ${{ if ne(stage.variables, '') }}: + variables: ${{ stage.variables }} + + ${{ if ne(stage.isSkippable, '') }}: + isSkippable: ${{ stage.isSkippable }} + + ${{ if ne(stage.lockBehavior, '') }}: + lockBehavior: ${{ stage.lockBehavior }} + + jobs: + - ${{ each job in stage.jobs }}: + - job: ${{ job.job }} + ${{ if ne(job.displayName, '') }}: + displayName: ${{ job.displayName }} + + ${{ if and(ne(job.timeoutInMinutes, ''), gt(2880, job.timeoutInMinutes)) }}: + timeoutInMinutes: ${{ job.timeoutInMinutes }} + ${{ else }}: + timeoutInMinutes: 1440 + + ${{ if ne(job.condition, '') }}: + condition: ${{ job.condition }} + + ${{ if ne(job.strategy, '') }}: + strategy: ${{ job.strategy }} + + ${{ if ne(join(job.dependsOn, ','), '') }}: + dependsOn: ${{ job.dependsOn }} + + variables: + - ${{ if ne(job.variables, '') }}: + - ${{ each v in job.variables }}: + - ${{ if ne(v.key, '') }}: + - name: ${{ v.key }} + value: ${{ v.value }} + - ${{ else }}: + - ${{ insert }}: ${{ v }} + - name: ob_customArtifactName + value: ${{ coalesce(variables.ob_artifactBaseName, format('drop_{0}_{1}', stage.stage, job.job)) }}${{ coalesce(variables.ob_artifactSuffix, '') }} + + pool: + name: ${{ coalesce(job.pool.name, 'trident-azl3-1es-pool-westus2') }} + + ${{ if ne(job.pool.hostArchitecture, '') }}: + HostArchitecture: ${{ job.pool.hostArchitecture }} + + LinuxHostVersion: + vmBuild: true + distribution: mariner + Host: linux + + steps: + - ${{ if eq(variables.ob_outputDirectory, '' ) }}: + - ${{ job.job }} + - "Variable ob_outputDirectory is not defined. Please check https://aka.ms/obpipelines/artifacts": error + + - checkout: self + fetchDepth: 1 + submodules: recursive + path: s + + - bash: | + set -eux + mkdir -p "${{ variables.ob_outputDirectory }}" + displayName: "Create output directory" + + - ${{ each step in job.steps }}: + - ${{ if ne(step.task, '') }}: + task: ${{ step.task }} + ${{ if ne(step.displayName, '') }}: + displayName: ${{ step.displayName }} + ${{ if ne(step.inputs, '') }}: + inputs: ${{ step.inputs }} + ${{ if ne(step.condition, '') }}: + condition: ${{ step.condition }} + ${{ if ne(step.env, '') }}: + env: ${{ step.env }} + ${{ if ne(step.retryCountOnTaskFailure, '') }}: + retryCountOnTaskFailure: ${{ step.retryCountOnTaskFailure }} + ${{ if ne(step.continueOnError, '') }}: + continueOnError: ${{ step.continueOnError }} + ${{ if ne(step.target, '') }}: + target: ${{ step.target }} + ${{ if ne(step.enabled, '') }}: + enabled: ${{ step.enabled }} + ${{ if ne(step.name, '') }}: + name: ${{ step.name }} + ${{ if ne(step.timeoutInMinutes, '') }}: + timeoutInMinutes: ${{ step.timeoutInMinutes }} + + # - bash: | + # # ACTUAL: + # # ${{ convertToJson(step) }} + + - publish: ${{ variables.ob_outputDirectory }} + artifact: ${{ variables.ob_customArtifactName }} + condition: always() diff --git a/.pipelines/templates/e2e-template.yml b/.pipelines/templates/e2e-template.yml index 21120c67d..0b9faa364 100644 --- a/.pipelines/templates/e2e-template.yml +++ b/.pipelines/templates/e2e-template.yml @@ -77,10 +77,23 @@ stages: # Build tools (Go stuff) - template: stages/building_tools/building-tools.yml - # Makefile validation, only for CI and PR-E2E - - ${{ if or(eq(parameters.stageType, 'ci'), eq(parameters.stageType, 'pr-e2e'), eq(parameters.stageType, 'pr-e2e-azure')) }}: + # Makefile validation, only for CI + - ${{ if eq(parameters.stageType, 'ci') }}: - template: stages/validate_makefile/dev-build.yml + # Build FT base Image, only in CI + - ${{ if eq(parameters.stageType, 'ci') }}: + # Build Trident installer ISO (host) + - template: stages/build_image/build-image.yml + parameters: + imageName: trident-functest + dependsOnTrident: false + baseimgBuildType: ${{ parameters.baseimgBuildType }} + baseImagePipelineBuildId: ${{ parameters.baseImagePipelineBuildId }} + micBuildType: ${{ parameters.micBuildType }} + micVersion: ${{ parameters.micVersion }} + clones: 1 + # Build Trident container image - template: stages/build_docker_image/trident-container.yml parameters: @@ -88,79 +101,90 @@ stages: baseImagePipelineBuildId: ${{ parameters.baseImagePipelineBuildId }} # Build Trident installer ISO (host) - - template: stages/build_image/trident-testimg.yml + - template: stages/build_image/build-image.yml parameters: - imageName: build/trident-installer-testimage.iso - outputArtifactName: trident-installer-testimage + imageName: trident-installer baseimgBuildType: ${{ parameters.baseimgBuildType }} baseImagePipelineBuildId: ${{ parameters.baseImagePipelineBuildId }} micBuildType: ${{ parameters.micBuildType }} micVersion: ${{ parameters.micVersion }} + clones: 1 # Build Trident split (stage and finalize separated) installer ISO (host) - - template: stages/build_image/trident-testimg.yml + - template: stages/build_image/build-image.yml parameters: - imageName: build/trident-split-installer-testimage.iso - outputArtifactName: trident-split-installer-testimage + imageName: trident-split-installer baseimgBuildType: ${{ parameters.baseimgBuildType }} baseImagePipelineBuildId: ${{ parameters.baseImagePipelineBuildId }} micBuildType: ${{ parameters.micBuildType }} micVersion: ${{ parameters.micVersion }} + clones: 1 # Build Trident installer ISO (container) - - template: stages/build_image/trident-testimg.yml + - template: stages/build_image/build-image.yml parameters: - imageName: build/trident-container-installer-testimage.iso - outputArtifactName: trident-container-installer-testimage + imageName: trident-container-installer baseimgBuildType: ${{ parameters.baseimgBuildType }} baseImagePipelineBuildId: ${{ parameters.baseImagePipelineBuildId }} runtimeEnv: "container" micBuildType: ${{ parameters.micBuildType }} micVersion: ${{ parameters.micVersion }} + clones: 1 # Build Trident test image (regular) - - template: stages/build_image/trident-testimg.yml + - template: stages/build_image/build-image.yml parameters: - imageName: build/trident-testimage.cosi - outputArtifactName: trident-testimage + imageName: trident-testimage baseimgBuildType: ${{ parameters.baseimgBuildType }} baseImagePipelineBuildId: ${{ parameters.baseImagePipelineBuildId }} - mkcosiTemplate: "regular" micBuildType: ${{ parameters.micBuildType }} micVersion: ${{ parameters.micVersion }} # Build Trident test image (container) - - template: stages/build_image/trident-testimg.yml + - template: stages/build_image/build-image.yml parameters: - imageName: build/trident-container-testimage.cosi - outputArtifactName: trident-container-testimage + imageName: trident-container-testimage baseimgBuildType: ${{ parameters.baseimgBuildType }} baseImagePipelineBuildId: ${{ parameters.baseImagePipelineBuildId }} runtimeEnv: "container" - mkcosiTemplate: "regular" micBuildType: ${{ parameters.micBuildType }} micVersion: ${{ parameters.micVersion }} # Build Trident test image for verity (host) - - template: stages/build_image/trident-testimg.yml + - template: stages/build_image/build-image.yml parameters: - imageName: build/trident-verity-testimage.cosi - outputArtifactName: trident-verity-testimage + imageName: trident-verity-testimage baseimgBuildType: ${{ parameters.baseimgBuildType }} baseImagePipelineBuildId: ${{ parameters.baseImagePipelineBuildId }} - mkcosiTemplate: "verity" micBuildType: ${{ parameters.micBuildType }} micVersion: ${{ parameters.micVersion }} # Build Trident test image for verity (container) - - template: stages/build_image/trident-testimg.yml + - template: stages/build_image/build-image.yml + parameters: + imageName: trident-container-verity-testimage + baseimgBuildType: ${{ parameters.baseimgBuildType }} + baseImagePipelineBuildId: ${{ parameters.baseImagePipelineBuildId }} + runtimeEnv: "container" + micBuildType: ${{ parameters.micBuildType }} + micVersion: ${{ parameters.micVersion }} + + # Build Trident test image for usr-verity (host) + - template: stages/build_image/build-image.yml parameters: - imageName: build/trident-container-verity-testimage.cosi - outputArtifactName: trident-container-verity-testimage + imageName: trident-usrverity-testimage baseimgBuildType: ${{ parameters.baseimgBuildType }} baseImagePipelineBuildId: ${{ parameters.baseImagePipelineBuildId }} + micBuildType: ${{ parameters.micBuildType }} + micVersion: ${{ parameters.micVersion }} + + # Build Trident test image for usr-verity (container) + - template: stages/build_image/build-image.yml + parameters: + imageName: trident-container-usrverity-testimage runtimeEnv: "container" - mkcosiTemplate: "verity" + baseimgBuildType: ${{ parameters.baseimgBuildType }} + baseImagePipelineBuildId: ${{ parameters.baseImagePipelineBuildId }} micBuildType: ${{ parameters.micBuildType }} micVersion: ${{ parameters.micVersion }} @@ -263,6 +287,8 @@ stages: - ${{ if eq(parameters.stageType, 'ci') }}: # Functional Testing - template: stages/testing_functional/functional-testing.yml + parameters: + downloadPrebuiltImage: false # VM Testing (host, post_merge) - template: stages/testing_vm/netlaunch-testing.yml @@ -281,7 +307,7 @@ stages: # Functional Testing (short version) - template: stages/testing_functional/functional-testing.yml parameters: - buildPurpose: "validation" + rerunTests: false # VM Testing (host, pullrequest) - template: stages/testing_vm/netlaunch-testing.yml diff --git a/.pipelines/templates/stages/build_docker_image/trident-container.yml b/.pipelines/templates/stages/build_docker_image/trident-container.yml index 710036ee4..40cc7ae13 100644 --- a/.pipelines/templates/stages/build_docker_image/trident-container.yml +++ b/.pipelines/templates/stages/build_docker_image/trident-container.yml @@ -28,6 +28,8 @@ stages: ob_artifactBaseName: "trident-docker-image" steps: + - template: ../common_tasks/avoid-pypi-usage.yml + - script: | set -eux mkdir -p bin/RPMS diff --git a/.pipelines/templates/stages/build_image/trident-testimg.yml b/.pipelines/templates/stages/build_image/build-image.yml similarity index 62% rename from .pipelines/templates/stages/build_image/trident-testimg.yml rename to .pipelines/templates/stages/build_image/build-image.yml index 833cd05e5..205474fa9 100644 --- a/.pipelines/templates/stages/build_image/trident-testimg.yml +++ b/.pipelines/templates/stages/build_image/build-image.yml @@ -2,9 +2,6 @@ parameters: - name: imageName type: string - - name: outputArtifactName - type: string - - name: baseimgBuildType displayName: Base Image build type type: string @@ -27,21 +24,13 @@ parameters: - host - container - - name: mkcosiTemplate - type: string - default: "none" - values: - - none - - regular - - verity - - name: micBuildType displayName: MIC Build Type type: string values: - - dev - - preview - - release + - dev + - preview + - release default: release - name: micVersion @@ -49,12 +38,20 @@ parameters: type: string default: "*.*.*" + - name: clones + displayName: "Number of clones to generate" + type: number + default: 2 + + - name: dependsOnTrident + type: boolean + default: true stages: - - stage: TridentTestImg_${{ replace(parameters.outputArtifactName, '-', '_') }} - displayName: Build ${{ parameters.outputArtifactName }} + - stage: TridentTestImg_${{ replace(parameters.imageName, '-', '_') }} + displayName: Build ${{ parameters.imageName }} dependsOn: - - ${{ if eq(parameters.runtimeEnv, 'host') }}: + - ${{ if and(eq(parameters.runtimeEnv, 'host'), parameters.dependsOnTrident) }}: - GetTridentBinaries_rpms - ${{ else }}: [] @@ -66,15 +63,12 @@ stages: type: linux variables: - ob_outputDirectory: $(Pipeline.Workspace)/s/test-images/output - ob_artifactBaseName: ${{ parameters.outputArtifactName }} + ob_outputDirectory: $(Pipeline.Workspace)/s/output + ob_artifactBaseName: ${{ parameters.imageName }} BASEIMG_AZURE_LINUX_VERSION: "3.0" steps: - - task: PipAuthenticate@1 - displayName: Provision - Authenticate Pip - inputs: - artifactFeeds: 'mariner/Mariner-Pypi-Feed' + - template: ../common_tasks/avoid-pypi-usage.yml - task: DownloadPipelineArtifact@2 inputs: @@ -82,21 +76,19 @@ stages: artifactName: trident-binaries targetPath: "$(Build.ArtifactStagingDirectory)/trident" displayName: Download Trident RPMs - condition: eq('${{ parameters.runtimeEnv }}', 'host') + condition: and(eq('${{ parameters.runtimeEnv }}', 'host'), eq('${{ parameters.dependsOnTrident }}', true)) - template: ../common_tasks/find-base-image-version.yml parameters: baseimgBuildType: ${{ parameters.baseimgBuildType }} baseImagePipelineBuildId: ${{ parameters.baseImagePipelineBuildId }} - - template: .pipelines/templates/trident-testimg-template.yml@test-images + - template: .pipelines/templates/build-image.yml@test-images parameters: - target: ${{ parameters.imageName }} - outputDirectory: ${{ variables.ob_outputDirectory }} - testImagesRepo: test-images - micBuildType: ${{ parameters.micBuildType }} - micVersion: ${{ parameters.micVersion }} + imageName: ${{ parameters.imageName }} + clones: ${{ parameters.clones }} baseimgBuildType: ${{ parameters.baseimgBuildType }} baseimgVersion: $(baseimgVersion) - baseimgAzureLinuxVersion: ${{ variables.BASEIMG_AZURE_LINUX_VERSION }} - downloadTrident: false + azureLinuxVersion: ${{ variables.BASEIMG_AZURE_LINUX_VERSION }} + micBuildType: ${{ parameters.micBuildType }} + micVersion: ${{ parameters.micVersion }} diff --git a/.pipelines/templates/stages/building_tools/building-tools.yml b/.pipelines/templates/stages/building_tools/building-tools.yml index 2bc80fbc0..ff5e6c694 100644 --- a/.pipelines/templates/stages/building_tools/building-tools.yml +++ b/.pipelines/templates/stages/building_tools/building-tools.yml @@ -37,10 +37,7 @@ stages: ob_artifactBaseName: go-tools steps: - - task: PipAuthenticate@1 - displayName: Provision - Authenticate Pip - inputs: - artifactFeeds: 'mariner/Mariner-Pypi-Feed' + - template: ../common_tasks/avoid-pypi-usage.yml - ${{ if eq(parameters.buildNetlaunch, true) }}: - bash: | diff --git a/.pipelines/templates/stages/common_tasks/avoid-pypi-usage.yml b/.pipelines/templates/stages/common_tasks/avoid-pypi-usage.yml new file mode 100644 index 000000000..2059ce9c1 --- /dev/null +++ b/.pipelines/templates/stages/common_tasks/avoid-pypi-usage.yml @@ -0,0 +1,7 @@ +steps: + - task: PipAuthenticate@1 + displayName: Provision - Authenticate Pip + inputs: + artifactFeeds: "mariner/Mariner-Pypi-Feed" + + - template: common/sfi-enforce-isolation-with-etc-hosts.yaml@platform-pipelines diff --git a/.pipelines/templates/stages/common_tasks/build-osmodifier.yml b/.pipelines/templates/stages/common_tasks/build-osmodifier.yml index 8a087ee7a..0da3f8d3b 100644 --- a/.pipelines/templates/stages/common_tasks/build-osmodifier.yml +++ b/.pipelines/templates/stages/common_tasks/build-osmodifier.yml @@ -1,4 +1,18 @@ steps: + - bash: | + set -ex + if command -v tdnf; then + # Use msft-golang since it has latest versions supported + sudo tdnf remove golang -y + sudo tdnf install msft-golang -y + else + sudo snap install --classic go + fi + + # Verify installation + go version + displayName: "Install golang to build Image Customizer" + retryCountOnTaskFailure: 3 - bash: | set -ex git submodule init diff --git a/.pipelines/templates/stages/common_tasks/os-info.yml b/.pipelines/templates/stages/common_tasks/os-info.yml new file mode 100644 index 000000000..629770f61 --- /dev/null +++ b/.pipelines/templates/stages/common_tasks/os-info.yml @@ -0,0 +1,30 @@ +steps: + - script: | + echo "OS RELEASE:" + cat /etc/os-release + echo "KERNEL RELEASE:" + uname -r + + echo "" + echo "Installed packages:" + echo "=====================" + if command -v rpm; then + echo "Installed RPMs:" + rpm -qa + fi + + if command -v dpkg; then + echo "Installed DEBs:" + dpkg -l + fi + + displayName: os-release + - script: | + echo "CPU Info:" + echo "N proc: $(nproc)" + lscpu + echo "Memory:" + free -h + echo "Disk space:" + df -h + displayName: system-info diff --git a/.pipelines/templates/stages/download_artifacts/get-artifacts.yml b/.pipelines/templates/stages/download_artifacts/get-artifacts.yml index 7dd7f3361..761d8c975 100644 --- a/.pipelines/templates/stages/download_artifacts/get-artifacts.yml +++ b/.pipelines/templates/stages/download_artifacts/get-artifacts.yml @@ -195,14 +195,14 @@ stages: targetPath: "$(System.ArtifactsDirectory)/container/" - job: TridentContainerInstallerTestimage - displayName: Download trident-container-installer-testimage + displayName: Download trident-container-installer timeoutInMinutes: 10 pool: type: linux variables: ob_outputDirectory: $(Build.SourcesDirectory)/build - ob_artifactBaseName: "trident-container-installer-testimage" + ob_artifactBaseName: "trident-container-installer" steps: - task: DownloadPipelineArtifact@2 @@ -264,17 +264,40 @@ stages: artifactName: $(ob_artifactBaseName) targetPath: $(ob_outputDirectory) + - job: TridentContainerUsrVerityTestimage + displayName: "Download trident-container-usrverity-testimage" + timeoutInMinutes: 10 + pool: + type: linux + + variables: + ob_outputDirectory: $(Build.SourcesDirectory)/build + ob_artifactBaseName: "trident-container-usrverity-testimage" + + steps: + - task: DownloadPipelineArtifact@2 + inputs: + source: specific + project: "ECF" + definition: ${{ parameters.definition }} + runVersion: ${{ parameters.runVersion }} + branchName: "refs/heads/${{ parameters.branch }}" + runId: ${{ parameters.tridentPipelineRunId }} + allowFailedBuilds: ${{ parameters.allowFailedBuilds }} + artifactName: $(ob_artifactBaseName) + targetPath: $(ob_outputDirectory) + # Host images: - ${{ if eq(parameters.hostImages, true) }}: - job: TridentInstallerTestimage - displayName: Download trident-installer-testimage + displayName: Download trident-installer timeoutInMinutes: 10 pool: type: linux variables: ob_outputDirectory: $(Build.SourcesDirectory)/build - ob_artifactBaseName: "trident-installer-testimage" + ob_artifactBaseName: "trident-installer" steps: - task: DownloadPipelineArtifact@2 @@ -291,14 +314,14 @@ stages: targetPath: $(ob_outputDirectory) - job: TridentSplitInstallerTestimage - displayName: Download trident-split-installer-testimage + displayName: Download trident-split-installer timeoutInMinutes: 10 pool: type: linux variables: ob_outputDirectory: $(Build.SourcesDirectory)/build - ob_artifactBaseName: "trident-split-installer-testimage" + ob_artifactBaseName: "trident-split-installer" steps: - task: DownloadPipelineArtifact@2 @@ -359,3 +382,26 @@ stages: allowFailedBuilds: ${{ parameters.allowFailedBuilds }} artifactName: $(ob_artifactBaseName) targetPath: $(ob_outputDirectory) + + - job: TridentUsrVerityTestimage + displayName: "Download trident-usrverity-testimage" + timeoutInMinutes: 10 + pool: + type: linux + + variables: + ob_outputDirectory: $(Build.SourcesDirectory)/build + ob_artifactBaseName: "trident-usrverity-testimage" + + steps: + - task: DownloadPipelineArtifact@2 + inputs: + source: specific + project: "ECF" + definition: ${{ parameters.definition }} + runVersion: ${{ parameters.runVersion }} + branchName: "refs/heads/${{ parameters.branch }}" + runId: ${{ parameters.tridentPipelineRunId }} + allowFailedBuilds: ${{ parameters.allowFailedBuilds }} + artifactName: $(ob_artifactBaseName) + targetPath: $(ob_outputDirectory) diff --git a/.pipelines/templates/stages/publishing/publish.yml b/.pipelines/templates/stages/publishing/publish.yml index 5ecf9d894..1899d3303 100644 --- a/.pipelines/templates/stages/publishing/publish.yml +++ b/.pipelines/templates/stages/publishing/publish.yml @@ -26,6 +26,7 @@ stages: - DeploymentTesting_container - FunctionalTesting - BaremetalDeploymentTesting_host + - BaremetalDeploymentTesting_container - ServicingTesting condition: and(succeeded(), eq(variables['Build.SourceBranch'], 'refs/heads/main') ) diff --git a/.pipelines/templates/stages/testing_baremetal/baremetal-testing.yml b/.pipelines/templates/stages/testing_baremetal/baremetal-testing.yml index 903916dc2..cc60ed21e 100644 --- a/.pipelines/templates/stages/testing_baremetal/baremetal-testing.yml +++ b/.pipelines/templates/stages/testing_baremetal/baremetal-testing.yml @@ -40,13 +40,15 @@ stages: - BuildingTools - ${{ if eq(parameters.runtimeEnv, 'container') }}: - BuildTridentContainerImage - - TridentTestImg_trident_container_installer_testimage + - TridentTestImg_trident_container_installer - TridentTestImg_trident_container_testimage - TridentTestImg_trident_container_verity_testimage + - TridentTestImg_trident_container_usrverity_testimage - ${{ else }}: - TridentTestImg_trident_verity_testimage - - TridentTestImg_trident_installer_testimage + - TridentTestImg_trident_installer - TridentTestImg_trident_testimage + - TridentTestImg_trident_usrverity_testimage jobs: - template: ../testing_common/get-tests.yml @@ -83,6 +85,7 @@ stages: - name: BAREMETAL_LAB_CANARY_IP value: "10.8.6.1" + # Sourced from the matrix - name: TRIDENT_CONFIGURATION_NAME value: $(configuration) @@ -106,9 +109,9 @@ stages: - name: INSTALLER_ISO_NAME ${{ if eq(parameters.runtimeEnv, 'container') }}: - value: "trident-container-installer-testimage" + value: "trident-container-installer" ${{ else }}: - value: "trident-installer-testimage" + value: "trident-installer" - name: IMAGE_NAME ${{ if eq(parameters.runtimeEnv, 'container') }}: @@ -122,6 +125,12 @@ stages: ${{ else }}: value: "trident-verity-testimage" + - name: USRVERITY_IMAGE_NAME + ${{ if eq(parameters.runtimeEnv, 'container') }}: + value: "trident-container-usrverity-testimage" + ${{ else }}: + value: "trident-usrverity-testimage" + - group: baremetal_controller steps: @@ -165,6 +174,7 @@ stages: installerISO: ${{ variables.INSTALLER_ISO_NAME }} tridentTestImage: ${{ variables.IMAGE_NAME }} tridentTestImageVerity: ${{ variables.VERITY_IMAGE_NAME }} + tridentTestImageUsrVerity: ${{ variables.USRVERITY_IMAGE_NAME }} ${{ if eq(parameters.runtimeEnv, 'container') }}: downloadTridentContainer: true @@ -264,6 +274,17 @@ stages: displayName: "Output serial log" condition: always() + - bash: | + set -eux + ./bin/storm-trident helper boot-metrics \ + "$(Build.SourcesDirectory)/e2e_tests/helpers/key" \ + "${{ variables.BAREMETAL_OAM_IP }}" \ + "testing-user" \ + "${{ parameters.runtimeEnv }}" \ + --metrics-file $(TRIDENT_SOURCE_DIR)/trident-clean-install-metrics.jsonl \ + --metrics-operation install + displayName: "Create boot metrics for booting into runtime OS" + - template: ../testing_common/trident-metrics.yml parameters: tridentSourceDirectory: $(TRIDENT_SOURCE_DIR) diff --git a/.pipelines/templates/stages/testing_baremetal/update_host_config.py b/.pipelines/templates/stages/testing_baremetal/update_host_config.py index 7dbf00be5..8e2ce5f0b 100755 --- a/.pipelines/templates/stages/testing_baremetal/update_host_config.py +++ b/.pipelines/templates/stages/testing_baremetal/update_host_config.py @@ -9,54 +9,118 @@ def update_trident_host_config( - host_configuration: str, - oam_ip: str, + *, + host_configuration: dict, interface_name: str, - oam_gateway: Optional[str] = None, - oam_mac: Optional[str] = None, + interface_ip: str, + interface_mac: Optional[str] = None, + network_gateway: Optional[str] = None, use_dhcp: bool = False, ): logging.info("Updating host config section of trident.yaml") - logging.info("oam_ip: %s", oam_ip) - logging.info("oam_gateway: %s", oam_gateway) os = host_configuration.setdefault("os", {}) - network = os.setdefault("network", {}) - ethernets = network.setdefault("ethernets", {}) - # Ensure that all interface dhcp4 settings are consistent - for ethernet in ethernets: - if "dhcp4" in ethernets[ethernet]: - ethernets[ethernet]["dhcp4"] = use_dhcp - - eno_interface = ethernets.setdefault(interface_name, {}) + main_interface = { + "addresses": [f"{interface_ip}/23"], + "dhcp4": use_dhcp, + "set-name": interface_name, + } # Temporary fix for #8837. - if oam_mac: - eno_interface["match"] = {"macaddress": oam_mac} - - eno_interface.setdefault("addresses", []).append(oam_ip + "/23") - eno_interface["dhcp4"] = use_dhcp - if oam_gateway: - eno_interface.setdefault("routes", []).append( - {"to": "0.0.0.0/0", "via": oam_gateway} + if interface_mac: + main_interface["match"] = {"macaddress": interface_mac} + + if network_gateway: + main_interface.setdefault("routes", []).append( + {"to": "0.0.0.0/0", "via": network_gateway} ) + # Override network to only preserve the eno interface. + os["network"] = { + "version": 2, + "ethernets": { + interface_name: main_interface, + }, + } + + # Name of the wait online service for this interface + wait_online_service = f"systemd-networkd-wait-online@{interface_name}.service" + + # Enable systemd-networkd-wait-online service for the interface. + enable_services = os.setdefault("services", {}).setdefault("enable", []) + if wait_online_service not in enable_services: + enable_services.append(wait_online_service) + + # Add an override for the trident service to wait for the network + # interface to be online before starting. + os.setdefault("additionalFiles", []).append( + { + "destination": "/etc/systemd/system/trident.service.d/override.conf", + "content": "[Unit]\n" + f"Requires={wait_online_service}\n" + f"After={wait_online_service}\n", + } + ) + logging.info("Updating os disks device in trident.yaml") - disks = host_configuration.get("storage", {}).get("disks", []) + disks = host_configuration.get("storage").get("disks") for disk in disks: if disk["id"] == "os": disk["device"] = "/dev/sda" elif disk["id"] == "disk2": disk["device"] = "/dev/sdb" - internal_params = host_configuration.setdefault("internalParams", {}) - internal_params["waitForSystemdNetworkd"] = True + # If this is root verity, we need to set an internal param to be able to + # configure the network. + if is_root_verity(host_configuration): + logging.info( + "Detected root verity configuration, setting 'writableEtcOverlayHooks' internal param." + ) + host_configuration.setdefault("internalParams", {})[ + "writableEtcOverlayHooks" + ] = True logging.info( "Final trident_yaml content post all the updates: %s", host_configuration ) +def is_root_verity(host_configuration: dict) -> bool: + """ + Check if the host configuration is using root verity. + """ + + verity_config = host_configuration.get("storage", {}).get("verity", []) + if len(verity_config) == 0: + return False + + if len(verity_config) > 1: + raise ValueError("Multiple verity configurations found, expected only one.") + + verity = verity_config[0] + verity_id = verity.get("id") + + filesystems = host_configuration.get("storage", {}).get("filesystems", []) + verity_filesystem = None + for fs in filesystems: + if fs.get("deviceId") == verity_id: + verity_filesystem = fs + break + + if verity_filesystem is None: + return False + + mount_point = verity_filesystem.get("mountPoint") + if mount_point is None: + return False + if isinstance(mount_point, str): + return mount_point == "/" + if isinstance(mount_point, dict): + return mount_point.get("path") == "/" + + return False + + def main(): logging.basicConfig( level=logging.INFO, @@ -84,20 +148,28 @@ def main(): "--oam-mac", default=None, help="MAC address of the OAM interface." ) parser.add_argument("--use-dhcp", default=False, help="Configure DHCP.") + parser.add_argument( + "-o", + "--output", + default=None, + help="Output file path. Defaults to editing the input file.", + ) args = parser.parse_args() with open(args.trident_yaml) as f: trident_yaml_content = yaml.safe_load(f) update_trident_host_config( - trident_yaml_content, - args.oam_ip, - args.interface_name, - args.oam_gateway, - args.oam_mac, - args.use_dhcp, + host_configuration=trident_yaml_content, + interface_name=args.interface_name, + interface_ip=args.oam_ip, + interface_mac=args.oam_mac, + network_gateway=args.oam_gateway, + use_dhcp=args.use_dhcp, ) - with open(args.trident_yaml, "w") as f: + + output_path = args.output or args.trident_yaml + with open(output_path, "w") as f: yaml.dump(trident_yaml_content, f, default_flow_style=False) diff --git a/.pipelines/templates/stages/testing_common/download-test-images.yml b/.pipelines/templates/stages/testing_common/download-test-images.yml index 08f0a8edb..a5e5e8107 100644 --- a/.pipelines/templates/stages/testing_common/download-test-images.yml +++ b/.pipelines/templates/stages/testing_common/download-test-images.yml @@ -2,13 +2,13 @@ parameters: - name: installerISO displayName: "Image used for the installer, source of Trident (container vs host)" type: string - default: trident-installer-testimage + default: trident-installer # Test selection is at runtime, and template parameters cannot be validated against runtime variables. # So, do validation in task below rather than specifying this here: # values: - # - trident-installer-testimage - # - trident-split-installer-testimage - # - trident-container-installer-testimage + # - trident-installer + # - trident-split-installer + # - trident-container-installer - name: tridentTestImage displayName: "Image used the runtime OS, source of Trident (container vs host)" @@ -26,6 +26,14 @@ parameters: - trident-verity-testimage - trident-container-verity-testimage + - name: tridentTestImageUsrVerity + displayName: "Image used the verity runtime OS, source of Trident (container vs host)" + type: string + default: trident-usrverity-testimage + values: + - trident-usrverity-testimage + - trident-container-usrverity-testimage + - name: downloadTridentContainer displayName: "Download Trident container" type: boolean @@ -51,6 +59,10 @@ parameters: type: string default: "$(System.ArtifactsDirectory)/verity-testimage" + - name: testImageDirUsrVerity + type: string + default: "$(System.ArtifactsDirectory)/usrverity-testimage" + steps: - bash: | set -eux @@ -59,11 +71,11 @@ steps: # So, do validation here. # case "${{ parameters.installerISO }}" in - trident-installer-testimage|trident-split-installer-testimage|trident-container-installer-testimage) + trident-installer|trident-split-installer|trident-container-installer) # Valid image_type, do nothing ;; *) - echo "installerISO should be either 'trident-installer-testimage', 'trident-split-installer-testimage', or 'trident-container-installer-testimage'." + echo "installerISO should be either 'trident-installer', 'trident-split-installer', or 'trident-container-installer'." exit 1 ;; esac @@ -100,6 +112,13 @@ steps: artifactName: "${{ parameters.tridentTestImageVerity }}" targetPath: "${{ parameters.testImageDirVerity }}" + - task: DownloadPipelineArtifact@2 + displayName: "Download ${{ parameters.tridentTestImageUsrVerity }}" + inputs: + buildType: current + artifactName: "${{ parameters.tridentTestImageUsrVerity }}" + targetPath: "${{ parameters.testImageDirUsrVerity }}" + - task: DownloadPipelineArtifact@2 displayName: "Download go-tools" inputs: @@ -113,6 +132,14 @@ steps: targetPath: "${{ parameters.toolsDirectory }}" - bash: | + # Debug log + echo "REGULAR IMAGES:" + ls -alh "${{ parameters.testImageDir }}" + + echo "" + echo "VERITY IMAGES:" + ls -alh "${{ parameters.testImageDirVerity }}" + set -eux # Set tools to be executable @@ -120,18 +147,17 @@ steps: chmod +x ${{ parameters.toolsDirectory }}/mkcosi chmod +x ${{ parameters.toolsDirectory }}/storm-trident - # Create the target directory - mkdir -p "${{ parameters.targetDirectory }}" - - # If testImageDir is not an empty string, rename the Trident test images - if [ -n "${{ parameters.testImageDir }}" ]; then - mv "${{ parameters.testImageDir }}/${{ parameters.tridentTestImage }}.cosi" "${{ parameters.targetDirectory }}/regular.cosi" - fi - - # If testImageDirVerity is not an empty string, rename the Trident verity test images - if [ -n "${{ parameters.testImageDirVerity }}" ]; then - mv "${{ parameters.testImageDirVerity }}/${{ parameters.tridentTestImageVerity }}.cosi" "${{ parameters.targetDirectory }}/verity.cosi" - fi + # Call helper to copy and rename images! + ${{ parameters.toolsDirectory }}/storm-trident \ + helper prepare-images -a -- \ + "${{ parameters.testImageDir }}" \ + "${{ parameters.testImageDirVerity }}" \ + "${{ parameters.testImageDirUsrVerity }}" \ + "${{ parameters.tridentTestImage }}" \ + "${{ parameters.tridentTestImageVerity }}" \ + "${{ parameters.tridentTestImageUsrVerity }}" \ + "${{ parameters.targetDirectory }}" \ + -v 4 # List tools in the target directory ls -lh "${{ parameters.toolsDirectory }}" 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 index 71d801870..9a16a1788 100644 --- 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 @@ -53,31 +53,21 @@ parameters: default: 4000 steps: - - bash: | - set -eux - "$(Build.SourcesDirectory)/.pipelines/templates/stages/testing_common/scripts/transfer-update-os-image.sh" \ - "${{ parameters.sshKeyPath }}" \ - "${{ parameters.userName }}" \ - "${{ parameters.hostIp }}" \ - "${{ parameters.artifactsDirectory }}" \ - "$(destinationDirectory)" \ - "$(version)" \ - "$(verityRequired)" - displayName: "Transfer updated OS image to the host for A/B update testing" - condition: and(succeeded(), ne(variables['abActiveVolume'], 'null')) - - 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 }} > ./stage-ab-update-deployment.log 2>&1 & + + ./bin/netlisten -m $(Build.SourcesDirectory)/trident-stage-update-metrics.jsonl \ + -p ${{ parameters.netlistenPort }} \ + -s "${{ parameters.artifactsDirectory }}" > ./stage-ab-update-deployment.log 2>&1 & + echo "Running script to stage A/B update..." ./bin/storm-trident helper ab-update \ "${{ parameters.sshKeyPath }}" \ "${{ parameters.hostIp }}" \ "${{ parameters.userName }}" \ "${{ parameters.runtimeEnv }}" \ - --destination-directory $(destinationDirectory) \ --trident-config $(tridentConfigFile) \ --version $(version) \ --stage-ab-update @@ -123,14 +113,16 @@ steps: 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-finalize-update-metrics.jsonl -p ${{ parameters.netlistenPort }} > ./finalize-ab-update.log 2>&1 & + ./bin/netlisten -m $(Build.SourcesDirectory)/trident-finalize-update-metrics.jsonl \ + -p ${{ parameters.netlistenPort }} \ + -s "${{ parameters.artifactsDirectory }}" > ./finalize-ab-update.log 2>&1 & + echo "Running script to finalize A/B update..." ./bin/storm-trident helper ab-update \ "${{ parameters.sshKeyPath }}" \ "${{ parameters.hostIp }}" \ "${{ parameters.userName }}" \ "${{ parameters.runtimeEnv }}" \ - --destination-directory $(destinationDirectory) \ --trident-config $(tridentConfigFile) \ --version $(version) \ --finalize-ab-update @@ -160,6 +152,18 @@ steps: displayName: "🤝 Check SSH connection after booting into runtime 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 runtime OS B" + condition: and(succeeded(), ne(variables['abActiveVolume'], 'null')) + - template: ../testing_common/trident-metrics.yml parameters: tridentSourceDirectory: $(Build.SourcesDirectory) diff --git a/.pipelines/templates/stages/testing_common/e2e-test-run.yml b/.pipelines/templates/stages/testing_common/e2e-test-run.yml index 3273765d2..42edc1809 100644 --- a/.pipelines/templates/stages/testing_common/e2e-test-run.yml +++ b/.pipelines/templates/stages/testing_common/e2e-test-run.yml @@ -75,25 +75,6 @@ steps: workingDirectory: $(Build.SourcesDirectory)/e2e_tests displayName: "Check if Trident config requires A/B update testing" - # Check if Trident config uses verity. If yes, we need to copy the verity COSI - # to a writable directory /run on the VM. Otherwise, copy the regular COSI to - # /abupdate. - - bash: | - set -eu - verityRequired=$(sudo yq e '.storage.verity != null' "${{ parameters.tridentConfigPath }}/trident-config.yaml") - if [ "$verityRequired" == "true" ]; then - echo "Trident config requires verity runtime OS images" - echo "##vso[task.setvariable variable=verityRequired]true" - echo "##vso[task.setvariable variable=destinationDirectory]/run" - else - echo "Trident config does not require verity runtime OS images" - echo "##vso[task.setvariable variable=verityRequired]false" - echo "##vso[task.setvariable variable=destinationDirectory]/abupdate" - fi - workingDirectory: $(Build.SourcesDirectory)/e2e_tests - displayName: "Check if Trident config requires verity runtime OS images" - condition: and(succeeded(), ne(variables['abActiveVolume'], 'null')) - - bash: | $(Build.SourcesDirectory)/bin/storm-trident helper check-ssh \ "${{ parameters.sshKeyPath }}" \ @@ -123,20 +104,6 @@ steps: testRunTitle: ${{ parameters.deploymentEnvironment }}_trident_e2e_tests_${{ parameters.tridentConfigurationName }}_clean_install_$(System.JobAttempt) displayName: "Publish test results for clean install of runtime OS" - - bash: | - set -eux - chmod +x $(Build.SourcesDirectory)/.pipelines/templates/stages/testing_common/scripts/transfer-update-os-image.sh - "$(Build.SourcesDirectory)/.pipelines/templates/stages/testing_common/scripts/transfer-update-os-image.sh" \ - "${{ parameters.sshKeyPath }}" \ - "${{ parameters.userName }}" \ - "${{ parameters.hostIp }}" \ - "${{ parameters.artifactsDirectory }}" \ - "$(destinationDirectory)" \ - "$(version)" \ - "$(verityRequired)" - displayName: "Transfer update OS image to the host for A/B update testing" - condition: and(succeeded(), ne(variables['abActiveVolume'], 'null')) - # 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: | @@ -144,6 +111,7 @@ steps: ./bin/netlisten --force-color \ -m $(Build.SourcesDirectory)/trident-ab-update-metrics-runtime-os-B.jsonl \ --full-logstream ./logstream-full.log \ + -s "${{ parameters.artifactsDirectory }}" \ -p ${{ parameters.netlistenPort }} > ./stage-finalize-ab-update-runtime-os-B.log 2>&1 & echo "Running script to stage and finalize A/B update..." @@ -152,7 +120,6 @@ steps: "${{ parameters.hostIp }}" \ "${{ parameters.userName }}" \ "${{ parameters.runtimeEnv }}" \ - --destination-directory $(destinationDirectory) \ --trident-config $(tridentConfigFile) \ --version $(version) \ --stage-ab-update \ @@ -179,6 +146,18 @@ steps: deploymentLogPath: $(Build.SourcesDirectory)/logstream-full.log displayName: "📄 [TRACE] Display A/B update deployment logs for runtime OS B" + - 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-runtime-os-B.jsonl \ + --metrics-operation update1 + displayName: "Create boot metrics for booting into runtime OS B" + condition: and(succeeded(), ne(variables['abActiveVolume'], 'null')) + - template: ../testing_common/trident-metrics.yml parameters: tridentSourceDirectory: $(Build.SourcesDirectory) @@ -217,19 +196,6 @@ steps: testRunTitle: ${{ parameters.deploymentEnvironment }}_trident_e2e_tests_${{ parameters.tridentConfigurationName }}_ab_update_B_$(System.JobAttempt) displayName: "Publish test results for A/B update into runtime OS B" - - bash: | - set -eux - "$(Build.SourcesDirectory)/.pipelines/templates/stages/testing_common/scripts/transfer-update-os-image.sh" \ - "${{ parameters.sshKeyPath }}" \ - "${{ parameters.userName }}" \ - "${{ parameters.hostIp }}" \ - "${{ parameters.artifactsDirectory }}" \ - "$(destinationDirectory)" \ - "$(version)" \ - "$(verityRequired)" - displayName: "Transfer update OS image to the host for A/B update testing" - condition: and(succeeded(), ne(variables['abActiveVolume'], 'null')) - - bash: | set -eux # If there is a netlisten process, kill it so there is no port clash in the instance @@ -238,6 +204,7 @@ steps: ./bin/netlisten --force-color \ -m $(Build.SourcesDirectory)/trident-ab-update-metrics-runtime-os-A.jsonl \ --full-logstream ./logstream-full.log \ + -s "${{ parameters.artifactsDirectory }}" \ -p ${{ parameters.netlistenPort }} > ./stage-finalize-ab-update-runtime-os-A.log 2>&1 & echo "Running script to stage and finalize A/B update..." @@ -246,7 +213,6 @@ steps: "${{ parameters.hostIp }}" \ "${{ parameters.userName }}" \ "${{ parameters.runtimeEnv }}" \ - --destination-directory $(destinationDirectory) \ --trident-config $(tridentConfigFile) \ --version $(version) \ --stage-ab-update \ @@ -273,6 +239,18 @@ steps: deploymentLogPath: $(Build.SourcesDirectory)/logstream-full.log displayName: "📄 [TRACE] Display A/B update deployment logs for runtime OS A" + - 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-runtime-os-A.jsonl \ + --metrics-operation update2 + displayName: "Create boot metrics for booting into runtime OS A" + condition: and(succeeded(), ne(variables['abActiveVolume'], 'null')) + - template: ../testing_common/trident-metrics.yml parameters: tridentSourceDirectory: $(Build.SourcesDirectory) @@ -336,3 +314,4 @@ steps: tridentSourceDirectory: $(Build.SourcesDirectory) tridentConfigPath: ${{ parameters.tridentConfigPath }} deploymentEnvironment: ${{ parameters.deploymentEnvironment }} + tridentConfigurationName: ${{ parameters.tridentConfigurationName }} diff --git a/.pipelines/templates/stages/testing_common/get-tests.yml b/.pipelines/templates/stages/testing_common/get-tests.yml index 349e2b62c..8f7bc15c5 100644 --- a/.pipelines/templates/stages/testing_common/get-tests.yml +++ b/.pipelines/templates/stages/testing_common/get-tests.yml @@ -35,17 +35,13 @@ jobs: ob_artifactBaseName: select_tests_${{ parameters.deploymentEnvironment }}_${{ parameters.runtimeEnv }}_$(System.JobAttempt) steps: + - template: ../common_tasks/avoid-pypi-usage.yml - bash: | - set -eux - matrix=$( - python3 ./e2e_tests/helpers/read_target_configurations.py \ - --configurations ./e2e_tests/target-configurations.yaml \ - --env ${{ parameters.deploymentEnvironment }} \ - --runtimeEnv ${{ parameters.runtimeEnv }} \ - --purpose ${{ parameters.buildPurpose }} \ - ) - - set +x - echo "##vso[task.setvariable variable=matrixConfigurations;isOutput=true]$matrix" + python3 ./e2e_tests/helpers/read_target_configurations.py \ + --configurations ./e2e_tests/target-configurations.yaml \ + --env ${{ parameters.deploymentEnvironment }} \ + --runtimeEnv ${{ parameters.runtimeEnv }} \ + --purpose ${{ parameters.buildPurpose }} \ + --matrix-name matrixConfigurations name: setConfigurations displayName: Matrix of Trident configurations for E2E Tests diff --git a/.pipelines/templates/stages/testing_common/scripts/transfer-update-os-image.sh b/.pipelines/templates/stages/testing_common/scripts/transfer-update-os-image.sh deleted file mode 100644 index 6f8df0d13..000000000 --- a/.pipelines/templates/stages/testing_common/scripts/transfer-update-os-image.sh +++ /dev/null @@ -1,44 +0,0 @@ -#!/bin/bash -# transfer-update-os-image.sh - -set -eux - -# Arguments -SSH_KEY_PATH=$1 -USER_NAME=$2 -HOST_IP=$3 -ARTIFACTS_DIR=$4 -DESTINATION_DIR=$5 -VERSION=$6 -VERITY_REQUIRED=$7 - -# Define path to the COSI file -COSI_FILE="$ARTIFACTS_DIR/regular.cosi" -# Define path to the update COSI file, i.e. with randomized FS UUID required for A/B update testing -UPDATE_COSI_FILE="$ARTIFACTS_DIR/regular-update.cosi" -# Define path to the COSI file on the host -HOST_COSI_FILE="$DESTINATION_DIR/regular_v$VERSION.cosi" - -# If needed, change the name/path of the COSI file based on verityRequired parameter -if [ "$VERITY_REQUIRED" = "true" ]; then - echo "Transferring verity COSI file onto the host" - COSI_FILE="$ARTIFACTS_DIR/verity.cosi" - UPDATE_COSI_FILE="$ARTIFACTS_DIR/verity-update.cosi" - HOST_COSI_FILE="$DESTINATION_DIR/verity_v$VERSION.cosi" - - # Before transferring the COSI file, randomize the FS UUID - ./bin/mkcosi randomize-fs-uuid "$COSI_FILE" "$UPDATE_COSI_FILE" /boot -else - echo "Transferring regular COSI file onto the host" - # Create destination directory on the host - ssh -o StrictHostKeyChecking=no -i "$SSH_KEY_PATH" "$USER_NAME"@"$HOST_IP" "sudo mkdir -p '$DESTINATION_DIR'" - - ./bin/mkcosi randomize-fs-uuid "$COSI_FILE" "$UPDATE_COSI_FILE" / -fi - -# Prepare destination directory on the host -ssh -o StrictHostKeyChecking=no -i "$SSH_KEY_PATH" "$USER_NAME"@"$HOST_IP" "sudo chown '$USER_NAME:$USER_NAME' '$DESTINATION_DIR' && sudo chmod 755 '$DESTINATION_DIR'" - -# SCP the file onto the host -scp -o StrictHostKeyChecking=no -i "$SSH_KEY_PATH" "$UPDATE_COSI_FILE" "$USER_NAME"@"$HOST_IP":"$HOST_COSI_FILE" -echo "Transferred COSI file to the host" diff --git a/.pipelines/templates/stages/testing_common/trident-rebuild.yml b/.pipelines/templates/stages/testing_common/trident-rebuild.yml index 247089fdc..466fd4dc1 100644 --- a/.pipelines/templates/stages/testing_common/trident-rebuild.yml +++ b/.pipelines/templates/stages/testing_common/trident-rebuild.yml @@ -31,11 +31,19 @@ parameters: - bareMetal - virtualMachine + - name: tridentConfigurationName + type: string + steps: - bash: | set -eu + + # For some reason the file is owned by root, so sudo is needed to read it. raidExists=$(sudo yq e '.storage.raid != null' "${{ parameters.tridentConfigPath }}/trident-config.yaml") - if [ "$raidExists" == "true" ]; then + usrVerity=$(sudo yq e '.storage.verity[0].name == "usr"' "${{ parameters.tridentConfigPath }}/trident-config.yaml") + + # TODO (12277): Support for UKI + Rebuild + if [ "$raidExists" == "true" ] && [ "$usrVerity" != "true" ]; then echo "Trident config requires Rebuild testing" echo "##vso[task.setvariable variable=TEST_REBUILD_RAID]True" else @@ -70,23 +78,6 @@ steps: displayName: "Replace the test disk with a new disk" condition: and(succeeded(),eq(variables['TEST_REBUILD_RAID'], 'True')) - - bash: | - set -eux - echo "Dump the VM before boot order change." - until sudo virsh dumpxml virtdeploy-vm-0; do - sudo virsh list - sleep 0.1 - done - echo "Changing the boot order of the VM to boot from sda." - python3 $(Build.SourcesDirectory)/.pipelines/templates/stages/testing_vm/update-vm-bootorder.py - echo "Dump the VM after boot order change." - until sudo virsh dumpxml virtdeploy-vm-0; do - sudo virsh list - sleep 0.1 - done - displayName: "Change boot order" - condition: and(succeeded(),eq(variables['TEST_REBUILD_RAID'], 'True')) - - bash: | set -eux diff --git a/.pipelines/templates/stages/testing_functional/functional-testing.yml b/.pipelines/templates/stages/testing_functional/functional-testing.yml index a70f21b3c..fa3b95858 100644 --- a/.pipelines/templates/stages/testing_functional/functional-testing.yml +++ b/.pipelines/templates/stages/testing_functional/functional-testing.yml @@ -1,27 +1,30 @@ parameters: - - name: testingRun - displayName: "Download prebuilt test artifacts" + - name: downloadPrebuiltImage + displayName: "Download prebuilt base image" type: boolean - default: false + default: true - - name: buildPurpose + - name: rerunTests + displayName: "Rerun functional tests on the same VM" + type: boolean + default: true + + - name: functestImageArtifact type: string - default: "post_merge" - values: - - post_merge - - validation + default: "trident-functest" + + - name: functestImageArtifactPipeline + type: number + default: 3371 # trident-ci + displayName: Where to download prebuilt image from stages: - stage: FunctionalTesting displayName: Functional Testing dependsOn: - - ${{ if eq(parameters.testingRun, true) }}: - - DownloadTestingElements - - ${{ else }}: - - BuildingTools - - TridentTestImg_trident_installer_testimage - - TridentTestImg_trident_testimage - - TridentTestImg_trident_verity_testimage + - ${{ if eq(parameters.downloadPrebuiltImage, false) }}: + - TridentTestImg_trident_functest + - ${{ else }}: [] jobs: - job: FunctionalTests @@ -34,7 +37,7 @@ stages: variables: ob_outputDirectory: $(Build.SourcesDirectory)/build - argusToolkitSourceDirectory: $(Build.SourcesDirectory)/argus-toolkit + baseImageDirectory: "$(Build.SourcesDirectory)/artifacts" steps: - checkout: argus-toolkit @@ -44,11 +47,27 @@ stages: - template: ../common_tasks/cargo-auth.yml - # Download all test images for host - - template: ../testing_common/download-test-images.yml - - template: ../testing_vm/netlaunch-prep.yml + - ${{ if eq(parameters.downloadPrebuiltImage, false) }}: + - task: DownloadPipelineArtifact@2 + displayName: "Download FT Image from current build" + inputs: + buildType: current + artifactName: "${{ parameters.functestImageArtifact }}" + targetPath: "${{ variables.baseImageDirectory }}" + - ${{ else }}: + - task: DownloadPipelineArtifact@2 + displayName: "Download FT Image from latest build of ${{ parameters.functestImageArtifactPipeline}}" + inputs: + buildType: specific + project: "ECF" + definition: ${{ parameters.functestImageArtifactPipeline}} + buildVersionToDownload: latestFromBranch + branchName: "refs/heads/main" + artifactName: "${{ parameters.functestImageArtifact }}" + targetPath: "${{ variables.baseImageDirectory }}" + - bash: | set -eux @@ -58,6 +77,8 @@ stages: displayName: Install dependencies retryCountOnTaskFailure: 3 + - template: ../common_tasks/build-osmodifier.yml + - bash: | set -eux @@ -78,7 +99,7 @@ stages: # rebuilding the test binaries and invoking pytest. The regular # target is meant for local use and does extra setup not required # here. - sg libvirt "make functional-test-core INSTALLER_ISO_PATH=./artifacts/iso/trident-installer-testimage.iso ARGUS_TOOLKIT_PATH=argus-toolkit" + sg libvirt "make functional-test-core ARGUS_TOOLKIT_PATH=argus-toolkit" displayName: Execute Functional Tests - template: ../common_tasks/coverage.yml @@ -97,6 +118,6 @@ stages: - bash: | set -eux - sg libvirt "make patch-functional-test INSTALLER_ISO_PATH=$(System.ArtifactsDirectory)/trident-installer-testimg.iso ARGUS_TOOLKIT_PATH=argus-toolkit" - condition: and(succeeded(), eq('${{ parameters.buildPurpose }}', 'post_merge')) + sg libvirt "make patch-functional-test ARGUS_TOOLKIT_PATH=argus-toolkit" + condition: and(succeeded(), eq('${{ parameters.rerunTests }}', 'true')) displayName: Rerun Functional Tests diff --git a/.pipelines/templates/stages/testing_servicing/build-image.yml b/.pipelines/templates/stages/testing_servicing/build-image.yml index 2d0dfa9e8..d9a62072d 100644 --- a/.pipelines/templates/stages/testing_servicing/build-image.yml +++ b/.pipelines/templates/stages/testing_servicing/build-image.yml @@ -63,10 +63,7 @@ jobs: ob_artifactBaseName: "image-${{ parameters.label }}" steps: - - task: PipAuthenticate@1 - displayName: Provision - Authenticate Pip - inputs: - artifactFeeds: 'mariner/Mariner-Pypi-Feed' + - template: ../common_tasks/avoid-pypi-usage.yml - task: DownloadPipelineArtifact@2 inputs: diff --git a/.pipelines/templates/stages/testing_servicing/generate-ssh-keys.yml b/.pipelines/templates/stages/testing_servicing/generate-ssh-keys.yml index 63df1c68d..12f1da76c 100644 --- a/.pipelines/templates/stages/testing_servicing/generate-ssh-keys.yml +++ b/.pipelines/templates/stages/testing_servicing/generate-ssh-keys.yml @@ -10,10 +10,7 @@ jobs: ob_artifactBaseName: "ssh-keys" steps: - - task: PipAuthenticate@1 - displayName: Provision - Authenticate Pip - inputs: - artifactFeeds: 'mariner/Mariner-Pypi-Feed' + - template: ../common_tasks/avoid-pypi-usage.yml - bash: | set -eux diff --git a/.pipelines/templates/stages/testing_servicing/testing-template.yml b/.pipelines/templates/stages/testing_servicing/testing-template.yml index d0d137c65..85a1ec801 100644 --- a/.pipelines/templates/stages/testing_servicing/testing-template.yml +++ b/.pipelines/templates/stages/testing_servicing/testing-template.yml @@ -45,36 +45,6 @@ parameters: default: "trident-ubuntu-1es-pool-eastus2" jobs: - - ${{ if eq(parameters.platform, 'azure') }}: - - job: PublishAzureImage - displayName: Publish Azure Image - timeoutInMinutes: 20 - pool: - type: linux - name: ${{ parameters.pool }} - hostArchitecture: amd64 - variables: - ob_outputDirectory: $(Build.SourcesDirectory)/logs - steps: - - task: DownloadPipelineArtifact@2 - inputs: - buildType: current - artifactName: image-${{ parameters.platform }}-base - targetPath: "$(Build.ArtifactStagingDirectory)/" - displayName: Download Base Image - - bash: | - set -eux - az login --identity - ./scripts/loop-update/publish-sig-image.sh - displayName: Publish Base Image - env: - SUBSCRIPTION: 04cdc145-a4f9-42d4-9868-c46d23d0c63f # CoreOS_Mariner_BMP_Staging - IMAGE_DEFINITION: "trident-vm-verity-testimage-$(System.DefinitionId)" - ARTIFACTS: $(Build.ArtifactStagingDirectory) - STORAGE_ACCOUNT: "azlinuxbmpstagingeastus2" - RESOURCE_GROUP: "azlinux_bmp_staging_eastus2" - AZCOPY_AUTO_LOGIN_TYPE: "MSI" - - job: UpdateTesting_${{ parameters.flavor }} displayName: Update Testing - ${{ parameters.flavor }} timeoutInMinutes: ${{ parameters.updateCheckTimeoutInMinutes }} @@ -84,17 +54,17 @@ jobs: hostArchitecture: amd64 strategy: parallel: ${{ parameters.workers }} - dependsOn: - - ${{ if eq(parameters.platform, 'azure') }}: - - PublishAzureImage variables: tridentSourceDirectory: $(Build.SourcesDirectory) ob_outputDirectory: $(tridentSourceDirectory)/deployment_logs_${{ parameters.flavor }} ob_artifactBaseName: "update-testing-${{ parameters.flavor }}-$(System.JobPositionInPhase)" - IMAGE_DEFINITION: "trident-vm-verity-testimage-$(System.DefinitionId)" + IMAGE_DEFINITION: "trident-vm-grub-verity-testimage-$(System.DefinitionId)" TEST_RESOURCE_GROUP: trident-vm-servicing-validation-$(Build.BuildId)-$(System.JobPositionInPhase) SUBSCRIPTION: 04cdc145-a4f9-42d4-9868-c46d23d0c63f # CoreOS_Mariner_BMP_Staging + STORAGE_ACCOUNT: "azlinuxbmpstagingeastus2" + RESOURCE_GROUP: "azlinux_bmp_staging_eastus2" + SUBNET_ID: /subscriptions/04cdc145-a4f9-42d4-9868-c46d23d0c63f/resourceGroups/trident-vm_servicing-azure-vnet/providers/Microsoft.Network/virtualNetworks/poolpeeringvnet/subnets/default steps: - bash: | @@ -102,6 +72,14 @@ jobs: echo "##vso[task.setvariable variable=TEST_RESOURCE_GROUP;]trident-vm-servicing-validation-$(Build.BuildId)-$(printf '%03d' $(System.JobPositionInPhase))" displayName: "Set variables" + - ${{ if eq(parameters.platform, 'azure') }}: + - task: DownloadPipelineArtifact@2 + inputs: + buildType: current + artifactName: image-${{ parameters.platform }}-base + targetPath: "$(Build.ArtifactStagingDirectory)/" + displayName: Download Base Image + - ${{ if eq(parameters.platform, 'qemu') }}: - task: DownloadPipelineArtifact@2 inputs: @@ -152,72 +130,49 @@ jobs: - bash: | set -eux - if [ "$TEST_PLATFORM" == "azure" ]; then + + SUDO="sudo" + if [ "${{ parameters.platform }}" == "azure" ]; then az login --identity + SUDO="" fi - ./scripts/loop-update/deploy-vm.sh - displayName: "Deploy VM" - env: - VERBOSE: ${{ parameters.verboseLogging }} - ARTIFACTS: $(Build.ArtifactStagingDirectory) - OUTPUT: $(ob_outputDirectory) - TEST_RESOURCE_GROUP: $(TEST_RESOURCE_GROUP) - IMAGE_DEFINITION: $(IMAGE_DEFINITION) - TEST_PLATFORM: ${{ parameters.platform }} - SUBSCRIPTION: $(SUBSCRIPTION) - SECURE_BOOT: ${{ ne(parameters.flavor, 'uki') }} - VALIDATION_SUBNET_ID: /subscriptions/04cdc145-a4f9-42d4-9868-c46d23d0c63f/resourceGroups/trident-vm_servicing-azure-vnet/providers/Microsoft.Network/virtualNetworks/poolpeeringvnet/subnets/default - timeoutInMinutes: 5 - - bash: ./scripts/loop-update/check-deployment.sh - displayName: "Check that Trident can adopt the deployment" - env: - TEST_PLATFORM: ${{ parameters.platform }} + FLAGS="" + if [ "${{ parameters.verboseLogging }}" == "True" ]; then + FLAGS="$FLAGS --verbose" + fi + if [ "${{ parameters.flavor }}" != "uki" ]; then + FLAGS="$FLAGS --secure-boot" + fi - - bash: ./scripts/loop-update/loop-update.sh + $SUDO ./bin/storm-trident run servicing $FLAGS \ + --artifacts-dir $(Build.ArtifactStagingDirectory) \ + --output-path $(ob_outputDirectory) \ + --subscription $(SUBSCRIPTION) \ + --image-definition $(IMAGE_DEFINITION) \ + --storage-account $(STORAGE_ACCOUNT) \ + --storage-account-resource-group $(RESOURCE_GROUP) \ + --test-resource-group $(TEST_RESOURCE_GROUP) \ + --platform ${{ parameters.platform }} \ + --subnet-id $(SUBNET_ID) \ + --ssh-private-key-path $HOME/.ssh/id_rsa \ + --ssh-public-key-path $HOME/.ssh/id_rsa.pub \ + --retry-count ${{ parameters.updateIterationCount }} \ + --rollback-retry-count ${{ parameters.updateIterationCount }} \ + --build-id $(Build.BuildId) \ + --force-cleanup + + set +x + echo "##vso[task.setvariable variable=STORM_SCENARIO_FINISHED;]true" + + displayName: "Servicing test" env: - ARTIFACTS: $(Build.ArtifactStagingDirectory) - OUTPUT: $(ob_outputDirectory) - VERBOSE: ${{ parameters.verboseLogging }} - RETRY_COUNT: ${{ parameters.updateIterationCount }} - EXPECTED_VOLUME: "volume-b" - ROLLBACK: "false" - TEST_RESOURCE_GROUP: $(TEST_RESOURCE_GROUP) - TEST_PLATFORM: ${{ parameters.platform }} - displayName: "Check that Trident can perform A/B update" - condition: succeeded() - - # E2E rollback test: Trigger an A/B update back into runtime OS A, then cause a rollback - # by triggering an artificial reboot. Then, check that the firmware performed a rollback - # into B correctly. Finally, trigger two A/B updates, the first one using the same Host - # Configuration, and validate that they succeed. Rollback testing will only be run when - # the rollbackTesting parameter is true. The scaling test logic will set it to false. - - # TODO: reenable as part of https://dev.azure.com/mariner-org/ECF/_workitems/edit/10624 - - ${{ if ne(parameters.flavor, 'uki') }}: - - bash: ./scripts/loop-update/loop-update.sh - env: - ARTIFACTS: $(Build.ArtifactStagingDirectory) - OUTPUT: $(ob_outputDirectory) - VERBOSE: ${{ parameters.verboseLogging }} - RETRY_COUNT: 3 - EXPECTED_VOLUME: "volume-b" - ROLLBACK: "true" - TEST_RESOURCE_GROUP: $(TEST_RESOURCE_GROUP) - TEST_PLATFORM: ${{ parameters.platform }} - displayName: "Check that Trident can roll back and perform A/B update after" - condition: and(succeeded(), eq(${{ parameters.rollbackTesting }}, true)) - - # TODO add more e2e tests here (Task 8813) - + AZCOPY_AUTO_LOGIN_TYPE: "MSI" + - bash: | set -eux - ./scripts/loop-update/fetch-logs.sh $(ob_outputDirectory)/ - ./scripts/loop-update/cleanup-vm.sh - - if [ "$TEST_PLATFORM" == "qemu" ]; then - mkdir -p $(ob_outputDirectory) + if [ "${{ parameters.platform }}" == "qemu" ]; then sudo zstd -T0 $(Build.ArtifactStagingDirectory)/booted.qcow2 sudo mv $(Build.ArtifactStagingDirectory)/booted.qcow2.zst $(ob_outputDirectory)/ fi @@ -225,21 +180,42 @@ jobs: # https://learn.microsoft.com/en-us/azure/virtual-machines/linux/download-vhd?tabs=azure-cli # for Azure images workingDirectory: $(tridentSourceDirectory) - env: - TEST_RESOURCE_GROUP: $(TEST_RESOURCE_GROUP) - TEST_PLATFORM: ${{ parameters.platform }} - SUBSCRIPTION: $(SUBSCRIPTION) condition: failed() displayName: "Publish logs and OS disk on failure" timeoutInMinutes: 5 - - bash: | - set -eux - ./scripts/loop-update/cleanup-vm.sh - displayName: "Cleanup VM" - workingDirectory: $(tridentSourceDirectory) - condition: always() - env: - TEST_RESOURCE_GROUP: $(TEST_RESOURCE_GROUP) - TEST_PLATFORM: ${{ parameters.platform }} - SUBSCRIPTION: $(SUBSCRIPTION) + - ${{ if eq(parameters.platform, 'azure') }}: + - bash: | + set -ex + + # If platform is azure AND the test failed to finish, run cleanup to + # ensure there are no azure resources left behind + if [ "${STORM_SCENARIO_FINISHED}" != "true" ]; then + az login --identity + + FLAGS="" + if [ "${{ parameters.verboseLogging }}" == "True" ]; then + FLAGS="$FLAGS --verbose" + fi + + ./bin/storm-trident run servicing $FLAGS \ + --artifacts-dir $(Build.ArtifactStagingDirectory) \ + --output-path $(ob_outputDirectory) \ + --subscription $(SUBSCRIPTION) \ + --image-definition $(IMAGE_DEFINITION) \ + --storage-account $(STORAGE_ACCOUNT) \ + --storage-account-resource-group $(RESOURCE_GROUP) \ + --test-resource-group $(TEST_RESOURCE_GROUP) \ + --platform ${{ parameters.platform }} \ + --subnet-id $(SUBNET_ID) \ + --ssh-private-key-path $HOME/.ssh/id_rsa \ + --ssh-public-key-path $HOME/.ssh/id_rsa.pub \ + --retry-count ${{ parameters.updateIterationCount }} \ + --rollback-retry-count ${{ parameters.updateIterationCount }} \ + --build-id $(Build.BuildId) \ + --test-case-to-run cleanup-vm + fi + + displayName: "Cleanup even if timeout" + timeoutInMinutes: 20 + condition: always() diff --git a/.pipelines/templates/stages/testing_servicing/vm-testing.yml b/.pipelines/templates/stages/testing_servicing/vm-testing.yml index 510493456..6b2c2d954 100644 --- a/.pipelines/templates/stages/testing_servicing/vm-testing.yml +++ b/.pipelines/templates/stages/testing_servicing/vm-testing.yml @@ -96,7 +96,7 @@ stages: parameters: baseimgBuildType: ${{ parameters.baseimgBuildType }} label: "qemu-base" - makeTarget: "build/trident-vm-verity-testimage.qcow2" + makeTarget: "build/trident-vm-grub-verity-testimage.qcow2" baseimgType: qemu_guest baseimgAzlVersion: ${{ parameters.baseimgAzlVersion }} baseImagePipelineBuildId: ${{ parameters.baseImagePipelineBuildId }} @@ -108,7 +108,7 @@ stages: parameters: baseimgBuildType: ${{ parameters.baseimgBuildType }} label: "qemu-update-a" - makeTarget: "build/trident-vm-verity-testimage.cosi" + makeTarget: "build/trident-vm-grub-verity-testimage.cosi" baseimgType: qemu_guest baseimgAzlVersion: ${{ parameters.baseimgAzlVersion }} baseImagePipelineBuildId: ${{ parameters.baseImagePipelineBuildId }} @@ -120,7 +120,7 @@ stages: parameters: baseimgBuildType: ${{ parameters.baseimgBuildType }} label: "qemu-update-b" - makeTarget: "build/trident-vm-verity-testimage.cosi" + makeTarget: "build/trident-vm-grub-verity-testimage.cosi" baseimgType: qemu_guest baseimgAzlVersion: ${{ parameters.baseimgAzlVersion }} baseImagePipelineBuildId: ${{ parameters.baseImagePipelineBuildId }} @@ -140,7 +140,7 @@ stages: parameters: baseimgBuildType: ${{ parameters.baseimgBuildType }} label: "azure-base" - makeTarget: "build/trident-vm-verity-azure-testimage.vhd" + makeTarget: "build/trident-vm-grub-verity-azure-testimage.vhd" baseimgType: core_selinux baseimgAzlVersion: ${{ parameters.baseimgAzlVersion }} baseImagePipelineBuildId: ${{ parameters.baseImagePipelineBuildId }} @@ -151,7 +151,7 @@ stages: parameters: baseimgBuildType: ${{ parameters.baseimgBuildType }} label: "azure-update-a" - makeTarget: "build/trident-vm-verity-azure-testimage.cosi" + makeTarget: "build/trident-vm-grub-verity-azure-testimage.cosi" baseimgType: core_selinux baseimgAzlVersion: ${{ parameters.baseimgAzlVersion }} baseImagePipelineBuildId: ${{ parameters.baseImagePipelineBuildId }} @@ -162,7 +162,7 @@ stages: parameters: baseimgBuildType: ${{ parameters.baseimgBuildType }} label: "azure-update-b" - makeTarget: "build/trident-vm-verity-azure-testimage.cosi" + makeTarget: "build/trident-vm-grub-verity-azure-testimage.cosi" baseimgType: core_selinux baseimgAzlVersion: ${{ parameters.baseimgAzlVersion }} baseImagePipelineBuildId: ${{ parameters.baseImagePipelineBuildId }} @@ -181,7 +181,7 @@ stages: parameters: baseimgBuildType: ${{ parameters.baseimgBuildType }} label: "uki-base" - makeTarget: "build/trident-vm-verity-uki-testimage.qcow2" + makeTarget: "build/trident-vm-usr-verity-testimage.qcow2" baseimgType: qemu_guest baseimgAzlVersion: ${{ parameters.baseimgAzlVersion }} baseImagePipelineBuildId: ${{ parameters.baseImagePipelineBuildId }} @@ -193,7 +193,7 @@ stages: parameters: baseimgBuildType: ${{ parameters.baseimgBuildType }} label: "uki-update-a" - makeTarget: "build/trident-vm-verity-uki-testimage.cosi" + makeTarget: "build/trident-vm-usr-verity-testimage.cosi" baseimgType: qemu_guest baseimgAzlVersion: ${{ parameters.baseimgAzlVersion }} baseImagePipelineBuildId: ${{ parameters.baseImagePipelineBuildId }} @@ -205,7 +205,7 @@ stages: parameters: baseimgBuildType: ${{ parameters.baseimgBuildType }} label: "uki-update-b" - makeTarget: "build/trident-vm-verity-uki-testimage.cosi" + makeTarget: "build/trident-vm-usr-verity-testimage.cosi" baseimgType: qemu_guest baseimgAzlVersion: ${{ parameters.baseimgAzlVersion }} baseImagePipelineBuildId: ${{ parameters.baseImagePipelineBuildId }} diff --git a/.pipelines/templates/stages/testing_vm/netlaunch-prep.yml b/.pipelines/templates/stages/testing_vm/netlaunch-prep.yml index 0a86d0715..affd3d04e 100644 --- a/.pipelines/templates/stages/testing_vm/netlaunch-prep.yml +++ b/.pipelines/templates/stages/testing_vm/netlaunch-prep.yml @@ -58,6 +58,8 @@ steps: python3-bcrypt \ python3-jinja2 \ zstd + + sudo pip3 install virt-firmware displayName: Install virt-deploy dependencies retryCountOnTaskFailure: 3 @@ -71,4 +73,23 @@ steps: cat << EOF > ~/.config/libvirt/libvirt.conf uri_default = "qemu:///system" EOF + + sudo mkdir -p /etc/systemd/system/docker.socket.d + echo "[Socket] + SocketMode=0666" | sudo tee /etc/systemd/system/docker.socket.d/mode.conf > /dev/null + + sudo mkdir -p /etc/systemd/system/libvirtd.socket.d + echo "[Socket] + SocketMode=0666" | sudo tee /etc/systemd/system/libvirtd.socket.d/mode.conf > /dev/null + + sudo systemctl daemon-reload + + sudo systemctl stop docker.service + sudo systemctl restart docker.socket + sudo systemctl start docker.service + + sudo systemctl stop libvirtd.service + sudo systemctl restart libvirtd.socket + sudo systemctl start libvirtd.service + displayName: "Configure virt-deploy" diff --git a/.pipelines/templates/stages/testing_vm/netlaunch-testing.yml b/.pipelines/templates/stages/testing_vm/netlaunch-testing.yml index f95287084..a32db6c12 100644 --- a/.pipelines/templates/stages/testing_vm/netlaunch-testing.yml +++ b/.pipelines/templates/stages/testing_vm/netlaunch-testing.yml @@ -23,33 +23,38 @@ parameters: - container stages: + - stage: DefineTests_VM_${{ parameters.runtimeEnv }} + displayName: Test List for VM:${{ parameters.runtimeEnv }} + jobs: + - template: ../testing_common/get-tests.yml + parameters: + buildPurpose: ${{ parameters.buildPurpose }} + deploymentEnvironment: virtualMachine + runtimeEnv: ${{ parameters.runtimeEnv }} + - stage: DeploymentTesting_${{ parameters.runtimeEnv }} displayName: Deployment VM ${{ parameters.runtimeEnv }} Testing dependsOn: + - DefineTests_VM_${{ parameters.runtimeEnv }} - ${{ if eq(parameters.testingRun, true) }}: - DownloadTestingElements - ${{ else }}: - BuildingTools - ${{ if eq(parameters.runtimeEnv, 'container') }}: - BuildTridentContainerImage - - TridentTestImg_trident_container_installer_testimage + - TridentTestImg_trident_container_installer - TridentTestImg_trident_container_testimage - TridentTestImg_trident_container_verity_testimage + - TridentTestImg_trident_container_usrverity_testimage - ${{ else }}: - - TridentTestImg_trident_split_installer_testimage - - TridentTestImg_trident_installer_testimage + - TridentTestImg_trident_split_installer + - TridentTestImg_trident_installer - TridentTestImg_trident_testimage - TridentTestImg_trident_verity_testimage + - TridentTestImg_trident_usrverity_testimage jobs: - - template: ../testing_common/get-tests.yml - parameters: - buildPurpose: ${{ parameters.buildPurpose }} - deploymentEnvironment: virtualMachine - runtimeEnv: ${{ parameters.runtimeEnv }} - - job: Testing - dependsOn: DefineTests timeoutInMinutes: 50 pool: type: linux @@ -57,24 +62,28 @@ stages: hostArchitecture: amd64 strategy: - matrix: $[ dependencies.DefineTests.outputs['setConfigurations.matrixConfigurations'] ] + matrix: $[ stageDependencies.DefineTests_VM_${{ parameters.runtimeEnv }}.DefineTests.outputs['setConfigurations.matrixConfigurations'] ] variables: + # Sourced from the matrix tridentConfigurationName: $(configuration) + tridentConfigPath: $(tridentSourceDirectory)/e2e_tests/trident_configurations/$(tridentConfigurationName) tridentSourceDirectory: $(Build.SourcesDirectory) argusToolkitSourceDirectory: $(Build.SourcesDirectory)/argus-toolkit ${{ if eq(parameters.runtimeEnv, 'container') }}: - installerISOName: trident-container-installer-testimage + installerISOName: trident-container-installer testImageName: trident-container-testimage verityTestImageName: trident-container-verity-testimage + usrVerityTestImageName: trident-container-usrverity-testimage downloadTridentContainer: true ${{ else }}: - installerISOName: trident-installer-testimage + installerISOName: trident-installer testImageName: trident-testimage verityTestImageName: trident-verity-testimage + usrVerityTestImageName: trident-usrverity-testimage downloadTridentContainer: false ob_outputDirectory: $(tridentSourceDirectory)/deployment_logs @@ -85,7 +94,7 @@ stages: steps: - bash: | if [ ${{ variables['tridentConfigurationName'] }} == 'split' ]; then - splitInstallerIsoName="trident-split-installer-testimage" + splitInstallerIsoName="trident-split-installer" echo "setting variable.installerISOName to $splitInstallerIsoName" echo "##vso[task.setvariable variable=installerISOName]$splitInstallerIsoName" fi @@ -101,6 +110,7 @@ stages: tridentTestImage: ${{ variables.testImageName }} tridentTestImageVerity: ${{ variables.verityTestImageName }} downloadTridentContainer: ${{ variables.downloadTridentContainer }} + tridentTestImageUsrVerity: ${{ variables.usrVerityTestImageName }} - template: netlaunch-prep.yml @@ -112,8 +122,7 @@ stages: - bash: | set -eux - sg libvirt "./virt-deploy create --mem 12 --disks 32,32" - sg libvirt "./virt-deploy run" + ./virt-deploy create --mem 12 --disks 32,32 workingDirectory: $(argusToolkitSourceDirectory) displayName: "Creating virt-deploy VM" @@ -147,7 +156,7 @@ stages: --port ${{variables.netlaunchPort}} 2>&1 | tee ./clean-install-deployment.log workingDirectory: $(tridentSourceDirectory) displayName: "🚀 Run netlaunch for testing" - timeoutInMinutes: 15 + timeoutInMinutes: 20 - template: ../testing_common/display-deployment-logs.yml parameters: @@ -195,6 +204,18 @@ stages: condition: eq('${{ parameters.runtimeEnv }}', 'container') displayName: "📄 Display Container logs" + - bash: | + set -eux + HOST_IP=$(jq -r '.virtualmachines[0].ip' $(argusToolkitSourceDirectory)/virt-deploy-metadata.json) + ./bin/storm-trident helper boot-metrics \ + "$(tridentSourceDirectory)/e2e_tests/helpers/key" \ + "$HOST_IP" \ + "testing-user" \ + "${{ parameters.runtimeEnv }}" \ + --metrics-file $(tridentSourceDirectory)/trident-clean-install-metrics.jsonl \ + --metrics-operation install + displayName: "Create boot metrics for booting into runtime OS" + - template: ../testing_common/trident-metrics.yml parameters: tridentSourceDirectory: $(tridentSourceDirectory) diff --git a/.pipelines/templates/stages/testing_vm/update-vm-bootorder.py b/.pipelines/templates/stages/testing_vm/update-vm-bootorder.py deleted file mode 100755 index 1800d5982..000000000 --- a/.pipelines/templates/stages/testing_vm/update-vm-bootorder.py +++ /dev/null @@ -1,53 +0,0 @@ -#!/usr/bin/env python3 - -import os -import subprocess -import xml.etree.ElementTree as ET - -VM_NAME = "virtdeploy-vm-0" -XML_FILE = f"/tmp/{VM_NAME}.xml" - - -def run_command(command): - result = subprocess.run(command, shell=True, capture_output=True, text=True) - if result.returncode != 0: - raise RuntimeError(f"Command '{command}' failed with error: {result.stderr}") - return result.stdout - - -# Dump the current XML configuration to a temporary file -run_command(f"sudo virsh dumpxml {VM_NAME} > {XML_FILE}") - -# Parse the XML file -tree = ET.parse(XML_FILE) -root = tree.getroot() - -# Remove the line from the cdrom device -for disk in root.findall("./devices/disk"): - if disk.get("device") == "cdrom": - boot = disk.find("boot") - if boot is not None and boot.get("order") == "1": - disk.remove(boot) - -# Add to the sda device -for disk in root.findall("./devices/disk"): - source = disk.find("source") - if ( - disk.get("device") == "disk" - and source is not None - and source.get("file") - == "/var/lib/libvirt/images/virtdeploy-pool/virtdeploy-vm-0-0-volume.qcow2" - ): - boot = ET.Element("boot", order="1") - disk.append(boot) - -# Write the updated XML back to the file -tree.write(XML_FILE) - -# Define the updated XML configuration -run_command(f"sudo virsh define {XML_FILE}") - -# Cleanup -os.remove(XML_FILE) - -print(f"Boot order updated successfully for VM: {VM_NAME}") diff --git a/.pipelines/templates/stages/trident_rpms/build-source.yml b/.pipelines/templates/stages/trident_rpms/build-source.yml index 8701e0985..da3d990ea 100644 --- a/.pipelines/templates/stages/trident_rpms/build-source.yml +++ b/.pipelines/templates/stages/trident_rpms/build-source.yml @@ -1,4 +1,8 @@ parameters: + - name: publishToDevFeed + type: boolean + default: false + - name: tridentArtifactName type: string @@ -38,11 +42,7 @@ stages: value: "$(Build.SourcesDirectory)/trident/out" steps: - - task: PipAuthenticate@1 - displayName: Provision - Authenticate Pip - inputs: - artifactFeeds: 'mariner/Mariner-Pypi-Feed' - + - template: ../common_tasks/avoid-pypi-usage.yml - template: check.yml - job: TestTrident @@ -55,35 +55,14 @@ stages: value: "$(Build.SourcesDirectory)/trident/out" steps: - - task: PipAuthenticate@1 - displayName: Provision - Authenticate Pip - inputs: - artifactFeeds: 'mariner/Mariner-Pypi-Feed' - + - template: ../common_tasks/avoid-pypi-usage.yml # need newer rust for cargo-nextest, use rustup.yml to install - template: ../common_tasks/rustup.yml - template: ../common_tasks/cargo-auth.yml - template: unit-test.yml - - - job: Coverage - displayName: Evaluate Unit Test Code Coverage - condition: eq(${{ parameters.codeCoverage }}, true) - pool: - type: linux - - variables: - ob_outputDirectory: "$(Build.SourcesDirectory)/trident/out" - - steps: - - task: PipAuthenticate@1 - displayName: Provision - Authenticate Pip - inputs: - artifactFeeds: 'mariner/Mariner-Pypi-Feed' - - - template: ../common_tasks/cargo-auth.yml - - template: ../common_tasks/coverage.yml parameters: - codeCoverageBaseline: 70 # Unit tests + codeCoverage: ${{ parameters.codeCoverage }} + unitTestCoverageBaseline: 70 # Unit tests - job: BuildTrident displayName: Build Trident 3.0 RPMs @@ -100,11 +79,8 @@ stages: buildType: auto steps: - - task: PipAuthenticate@1 - displayName: Provision - Authenticate Pip - inputs: - artifactFeeds: 'mariner/Mariner-Pypi-Feed' - + - template: ../common_tasks/os-info.yml + - template: ../common_tasks/avoid-pypi-usage.yml - template: ../common_tasks/cargo-auth.yml - template: ../common_tasks/build-osmodifier.yml - template: release.yml @@ -112,6 +88,8 @@ stages: baseimgBuildType: ${{ parameters.baseimgBuildType }} baseImagePipelineBuildId: ${{ parameters.baseImagePipelineBuildId }} previewContainerPipeline: "[AMD64-6-OneBranch]-Prod-BuildImages" + - ${{ if eq(parameters.publishToDevFeed, true) }}: + - template: publish-dev.yml - job: BuildTridentARM64 displayName: Build Trident 3.0 RPMs for ARM64 @@ -130,11 +108,7 @@ stages: buildType: auto steps: - - task: PipAuthenticate@1 - displayName: Provision - Authenticate Pip - inputs: - artifactFeeds: 'mariner/Mariner-Pypi-Feed' - + - template: ../common_tasks/avoid-pypi-usage.yml - template: ../common_tasks/cargo-auth.yml - template: ../common_tasks/build-osmodifier.yml - template: release.yml @@ -142,28 +116,3 @@ stages: baseimgBuildType: ${{ parameters.baseimgBuildType }} baseImagePipelineBuildId: ${{ parameters.baseImageArm64PipelineBuildId }} previewContainerPipeline: "[ARM64-6-OneBranch]-Prod-BuildImages" - - - job: BuildTrident2 - displayName: Build Trident 2.0 RPMs - pool: - type: linux - - variables: - - name: ob_artifactBaseName - value: ${{ parameters.tridentArtifactName }}2 - - name: ob_outputDirectory - value: "$(Build.SourcesDirectory)/out" - - template: common/setup-registries-vars-template.yaml@platform-pipelines - parameters: - buildType: auto - - steps: - - task: PipAuthenticate@1 - displayName: Provision - Authenticate Pip - inputs: - artifactFeeds: 'mariner/Mariner-Pypi-Feed' - - - template: ../common_tasks/rustup.yml - - template: ../common_tasks/cargo-auth.yml - - template: ../common_tasks/build-osmodifier.yml - - template: release2.yml diff --git a/.pipelines/templates/stages/trident_rpms/check.yml b/.pipelines/templates/stages/trident_rpms/check.yml index 7c4e95e23..6ad8a39e8 100644 --- a/.pipelines/templates/stages/trident_rpms/check.yml +++ b/.pipelines/templates/stages/trident_rpms/check.yml @@ -5,11 +5,7 @@ steps: - template: ../common_tasks/rustup.yml - template: ../common_tasks/cargo-auth.yml - - - task: PipAuthenticate@1 - displayName: Provision - Authenticate Pip - inputs: - artifactFeeds: 'mariner/Mariner-Pypi-Feed' + - template: ../common_tasks/avoid-pypi-usage.yml - script: | set -eux diff --git a/.pipelines/templates/stages/trident_rpms/publish-dev.yml b/.pipelines/templates/stages/trident_rpms/publish-dev.yml new file mode 100644 index 000000000..4ba7d3c26 --- /dev/null +++ b/.pipelines/templates/stages/trident_rpms/publish-dev.yml @@ -0,0 +1,47 @@ +parameters: + - name: "feed" + type: string + default: TridentDev + values: [TridentDev] + - name: "packageName" + type: string + default: rpms-pr + values: [rpms-pr] + +steps: + - script: echo $(Build.BuildNumber) + displayName: "Print Trident Version" + + - script: | + set -eux + + alreadyPublished=$(./scripts/get-packages.py --debug \ + --feed '${{ parameters.feed }}' \ + --package '${{ parameters.packageName }}' \ + --version '$(Build.BuildNumber)' \ + --action=exists) + + # Save variable to know if package needs to be published + set +x + echo "##vso[task.setvariable variable=isVersionInFeed;]$alreadyPublished" + displayName: "Check if package version has already been published in the feed." + workingDirectory: $(Build.SourcesDirectory) + env: + AZURE_DEVOPS_EXT_PAT: $(System.AccessToken) + + - script: | + mkdir -p $(Build.SourcesDirectory)/staging_dir + cp $(ob_outputDirectory)/*.rpm $(Build.SourcesDirectory)/staging_dir + ls $(Build.SourcesDirectory)/staging_dir + displayName: Copy RPMs into Staging Directory + + - task: UniversalPackages@0 + displayName: "Publish RPMs Universal Package" + condition: and(succeeded(), eq(variables.isVersionInFeed, false)) + inputs: + command: publish + vstsFeedPublish: "ECF/${{ parameters.feed }}" + vstsFeedPackagePublish: "${{ parameters.packageName }}" + publishDirectory: "$(Build.SourcesDirectory)/staging_dir" + versionPublish: $(Build.BuildNumber) + versionOption: custom diff --git a/.pipelines/templates/stages/trident_rpms/release2.yml b/.pipelines/templates/stages/trident_rpms/release2.yml deleted file mode 100644 index 0731cb57a..000000000 --- a/.pipelines/templates/stages/trident_rpms/release2.yml +++ /dev/null @@ -1,60 +0,0 @@ -parameters: - - name: minimumSystemdVersion - type: string - default: '254' - -steps: - - script: | - set -eux - TRIDENT_VERSION=$(python3 ./scripts/get-version.py "$(Build.BuildNumber)" --commit) - echo "##vso[task.setvariable variable=trident_version]$TRIDENT_VERSION" - displayName: "Setting Trident version" - - - task: onebranch.pipeline.version@1 - displayName: "Set build number" - inputs: - system: "Custom" - customVersion: $(trident_version) - - - task: CopyFiles@2 - inputs: - sourceFolder: "./artifacts" - targetFolder: "/usr/src/mariner/SOURCES" - contents: "*" - displayName: Copy EMU to SOURCES - - - task: CopyFiles@2 - inputs: - sourceFolder: "./" - targetFolder: "/usr/src/mariner/SOURCES" - contents: "trident-selinuxpolicies.cil" - displayName: Copy Trident selinux policy to SOURCES - - - script: sudo tdnf install -y protobuf protobuf-c openssl-devel clang-devel rust p7zip p7zip-plugins zstd moby-buildx - displayName: Install native dependencies - retryCountOnTaskFailure: 3 - - - script: | - set -eux - full_version=$(trident_version) - - # Separate into version and prerelease identifier - # for the RPM build. - version=$(echo $full_version | cut -d'-' -f1) - prerelease=$(echo $full_version | cut -d'-' -f2-) - - # Edit the trident.spec file to not require 3.0 dependencies - sed -i 's/systemd >= 255/systemd >= ${{ parameters.minimumSystemdVersion }}/g' trident.spec - sed -i 's/Requires:[[:blank:]]*systemd-udev//g' trident.spec - - rpmbuild -bb --build-in-place trident.spec \ - --define="trident_version $full_version" \ - --define="rpm_ver $version" \ - --define="rpm_rel $prerelease" - displayName: Build 2.0 RPMs - - - task: CopyFiles@2 - inputs: - sourceFolder: "/usr/src/mariner/RPMS/x86_64" - targetFolder: "$(ob_outputDirectory)" - displayName: Copy RPM file to output diff --git a/.pipelines/templates/stages/trident_rpms/trident-stage.yml b/.pipelines/templates/stages/trident_rpms/trident-stage.yml index 0de43cd8a..9f8bc562d 100644 --- a/.pipelines/templates/stages/trident_rpms/trident-stage.yml +++ b/.pipelines/templates/stages/trident_rpms/trident-stage.yml @@ -43,6 +43,8 @@ stages: - ${{ if or(parameters.forceTridentRebuild, eq(parameters.stageType, 'pr'), eq(parameters.stageType, 'pr-e2e'), eq(parameters.stageType, 'ci'), eq(parameters.stageType, 'pr-e2e-azure')) }}: - template: build-source.yml parameters: + ${{ if eq(parameters.stageType, 'pr') }}: + publishToDevFeed: true tridentArtifactName: ${{ parameters.tridentArtifactName }} codeCoverage: ${{ parameters.codeCoverage }} baseimgBuildType: ${{ parameters.baseimgBuildType }} diff --git a/.pipelines/templates/stages/trident_rpms/unit-test.yml b/.pipelines/templates/stages/trident_rpms/unit-test.yml index 3e546949a..917133c45 100644 --- a/.pipelines/templates/stages/trident_rpms/unit-test.yml +++ b/.pipelines/templates/stages/trident_rpms/unit-test.yml @@ -1,3 +1,11 @@ +parameters: + - name: codeCoverage + type: boolean + default: true + + - name: unitTestCoverageBaseline + type: number + steps: - script: sudo tdnf install -y protobuf protobuf-c openssl-devel clang-devel rust displayName: Install native dependencies @@ -5,14 +13,41 @@ steps: - script: | set -eux - cargo install cargo-nextest --locked --version 0.9.85 + cargo install cargo-nextest --locked --version 0.9.97 + cargo install cargo-llvm-cov --locked --version 0.6.16 + # Exclude pytest_gen as Mariner Rust is currently failing to build it. See # for more details: https://dev.azure.com/mariner-org/ECF/_workitems/edit/6517 - cargo nextest run --workspace --exclude pytest_gen --profile ci - displayName: Test Debug + cargo llvm-cov nextest --remap-path-prefix --lcov --output-path target/lcov.info --workspace --exclude pytest_gen --profile ci + displayName: Run Unit Tests - task: PublishTestResults@2 condition: succeededOrFailed() inputs: testResultsFormat: "JUnit" testResultsFiles: "./target/nextest/ci/trident_unit_tests.xml" + + - task: PublishCodeCoverageResults@2 + condition: and(succeededOrFailed(), eq(${{ parameters.codeCoverage }}, true)) + inputs: + summaryFileLocation: "./target/lcov.info" + + - bash: | + set -eux + cargo llvm-cov report \ + --ignore-filename-regex 'docbuilder' \ + --summary-only --json > ./target/coverage.json + + MEASURED_COVERAGE="$(jq '.data[0].totals.lines.percent' ./target/coverage.json)" + BASELINE="${{ parameters.unitTestCoverageBaseline }}" + + if (( $(echo "$MEASURED_COVERAGE < $BASELINE" | bc -l) )); then + set +x + echo "##vso[task.logissue type=error]Code coverage ($MEASURED_COVERAGE) is below baseline ($BASELINE)" + set -x + exit 1 + else + echo "Code coverage ($MEASURED_COVERAGE) meets or exceeds baseline ($BASELINE)" + fi + displayName: Assert code coverage is above baseline + condition: and(succeededOrFailed(), eq(${{ parameters.codeCoverage }}, true)) diff --git a/.pipelines/templates/stages/trident_usb_iso/trident-usb-iso.yml b/.pipelines/templates/stages/trident_usb_iso/trident-usb-iso.yml index 16cc77e86..f3067f8a9 100644 --- a/.pipelines/templates/stages/trident_usb_iso/trident-usb-iso.yml +++ b/.pipelines/templates/stages/trident_usb_iso/trident-usb-iso.yml @@ -55,10 +55,7 @@ stages: fetchDepth: 1 path: s/test-images - - task: PipAuthenticate@1 - displayName: Provision - Authenticate Pip - inputs: - artifactFeeds: 'mariner/Mariner-Pypi-Feed' + - template: ../common_tasks/avoid-pypi-usage.yml - task: DownloadPipelineArtifact@2 inputs: diff --git a/.pipelines/templates/stages/validate_makefile/dev-build.yml b/.pipelines/templates/stages/validate_makefile/dev-build.yml index 1c1e7b752..3f9db059a 100644 --- a/.pipelines/templates/stages/validate_makefile/dev-build.yml +++ b/.pipelines/templates/stages/validate_makefile/dev-build.yml @@ -15,11 +15,7 @@ stages: ob_outputDirectory: $(Build.SourcesDirectory)/build steps: - - task: PipAuthenticate@1 - displayName: Provision - Authenticate Pip - inputs: - artifactFeeds: 'mariner/Mariner-Pypi-Feed' - + - template: ../common_tasks/avoid-pypi-usage.yml - template: ../common_tasks/build-osmodifier.yml - script: | diff --git a/.pipelines/trident-pr-e2e.yml b/.pipelines/trident-pr-e2e.yml index a1aeb09c6..a781a9a90 100644 --- a/.pipelines/trident-pr-e2e.yml +++ b/.pipelines/trident-pr-e2e.yml @@ -12,10 +12,10 @@ trigger: none resources: repositories: - - repository: templates - type: git - name: OneBranch.Pipelines/GovernedTemplates - ref: refs/heads/main + # - repository: templates + # type: git + # name: OneBranch.Pipelines/GovernedTemplates + # ref: refs/heads/main - repository: argus-toolkit type: git @@ -44,24 +44,28 @@ resources: variables: - group: bot # added for access to dom0 RPMs +# Preserving old OneBranch Pipelines template for reference. +# extends: +# template: v2/OneBranch.NonOfficial.CrossPlat.yml@templates # https://aka.ms/obpipelines/templates +# parameters: +# globalSdl: # https://aka.ms/obpipelines/sdl +# # tsa: +# # enabled: true # SDL results of non-official builds aren't uploaded to TSA by default. +# credscan: +# suppressionsFile: $(Build.SourcesDirectory)\.config\CredScanSuppressions.json +# policheck: +# break: true # always break the build on policheck issues. You can disable it by setting to 'false' +# # suppression: +# # suppressionFile: $(Build.SourcesDirectory)\.gdn\global.gdnsuppress +# cg: +# alertWarningLevel: Medium +# featureFlags: +# runOnHost: true +# EnableCDPxPAT: false + extends: - template: v2/OneBranch.NonOfficial.CrossPlat.yml@templates # https://aka.ms/obpipelines/templates + template: templates/MockOB.yml parameters: - globalSdl: # https://aka.ms/obpipelines/sdl - # tsa: - # enabled: true # SDL results of non-official builds aren't uploaded to TSA by default. - credscan: - suppressionsFile: $(Build.SourcesDirectory)\.config\CredScanSuppressions.json - policheck: - break: true # always break the build on policheck issues. You can disable it by setting to 'false' - # suppression: - # suppressionFile: $(Build.SourcesDirectory)\.gdn\global.gdnsuppress - cg: - alertWarningLevel: Medium - featureFlags: - runOnHost: true - EnableCDPxPAT: false - stages: - template: templates/pipeline-selector.yml parameters: diff --git a/.vscode/settings.json b/.vscode/settings.json index 4a9f2a999..e44a7dd52 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,6 +1,6 @@ { "editor.formatOnSave": true, - "rust-analyzer.checkOnSave.command": "clippy", + "rust-analyzer.check.command": "clippy", "rust-analyzer.cargo.features": "all", "black-formatter.args": [], "[python]": { @@ -17,5 +17,5 @@ "editor.insertSpaces": true, "editor.tabSize": 2, "prettier.tabWidth": 2, - } + }, } \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index d6c4ab6c2..c62aedf48 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -454,6 +454,15 @@ dependencies = [ "libc", ] +[[package]] +name = "crc32fast" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a97769d94ddab943e4510d138150169a2758b5ef3eb191a9ee688de3e23ef7b3" +dependencies = [ + "cfg-if", +] + [[package]] name = "crossbeam-deque" version = "0.8.5" @@ -548,7 +557,9 @@ dependencies = [ "serde_yaml", "setsail", "strum", + "svg", "tera", + "textwrap", "trident", "trident_api", ] @@ -707,6 +718,16 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" +[[package]] +name = "flate2" +version = "1.0.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c936bfdafb507ebbf50b8074c54fa31c5be9a1e7e5f467dd659697041407d07c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + [[package]] name = "fnv" version = "1.0.7" @@ -856,6 +877,17 @@ dependencies = [ "walkdir", ] +[[package]] +name = "goblin" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daa0a64d21a7eb230583b4c5f4e23b7e4e57974f96620f42a7e75e08ae66d745" +dependencies = [ + "log", + "plain", + "scroll", +] + [[package]] name = "h2" version = "0.4.7" @@ -928,6 +960,12 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fbf6a919d6cf397374f7dfeeea91d974c7c0a7221d0d0f4f20d859d329e53fcc" +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + [[package]] name = "home" version = "0.5.9" @@ -1632,6 +1670,8 @@ dependencies = [ "duct", "enumflags2", "glob", + "goblin", + "hex", "hostname", "indoc", "inventory", @@ -1847,6 +1887,12 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "953ec861398dccce10c670dfeaf3ec4911ca479e9c02154b3a215178c5f566f2" +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + [[package]] name = "ppv-lite86" version = "0.2.20" @@ -1885,6 +1931,31 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "procfs" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc5b72d8145275d844d4b5f6d4e1eef00c8cd889edb6035c21675d1bb1f45c9f" +dependencies = [ + "bitflags", + "chrono", + "flate2", + "hex", + "procfs-core", + "rustix", +] + +[[package]] +name = "procfs-core" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "239df02d8349b06fc07398a3a1697b06418223b1c7725085e801e7c0fc6a12ec" +dependencies = [ + "bitflags", + "chrono", + "hex", +] + [[package]] name = "prost" version = "0.13.4" @@ -2244,6 +2315,26 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "scroll" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ab8598aa408498679922eff7fa985c25d58a90771bd6be794434c5277eab1a6" +dependencies = [ + "scroll_derive", +] + +[[package]] +name = "scroll_derive" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1783eabc414609e28a5ba76aee5ddd52199f7107a0b24c2e9746a1ecc34a683d" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "security-framework" version = "2.11.1" @@ -2462,6 +2553,12 @@ dependencies = [ "syn", ] +[[package]] +name = "smawk" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c388c1b5e93756d0c740965c41e8822f866621d41acbdf6336a6a168f8840c" + [[package]] name = "socket2" version = "0.5.8" @@ -2531,6 +2628,12 @@ dependencies = [ "syn", ] +[[package]] +name = "svg" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94afda9cd163c04f6bee8b4bf2501c91548deae308373c436f36aeff3cf3c4a3" + [[package]] name = "syn" version = "2.0.90" @@ -2674,6 +2777,17 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "textwrap" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057" +dependencies = [ + "smawk", + "unicode-linebreak", + "unicode-width", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -2961,6 +3075,7 @@ dependencies = [ "netplan-types", "nix", "osutils", + "procfs", "prost", "pytest", "pytest_gen", @@ -3093,12 +3208,24 @@ version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "adb9e6ca4f869e1180728b7950e35922a7fc6397f7b641499e8f3ef06e50dc83" +[[package]] +name = "unicode-linebreak" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" + [[package]] name = "unicode-segmentation" version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +[[package]] +name = "unicode-width" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" + [[package]] name = "unicode-xid" version = "0.2.6" diff --git a/Cargo.toml b/Cargo.toml index 747f2e350..e84727e96 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,6 +26,7 @@ nix = { version = "0.29.0", default-features = false, features = [ "fs", "user", ] } +procfs = "0.17.0" rayon = "1.10" regex = "1.11.1" reqwest = { version = "0.12.9", default-features = false, features = [ diff --git a/Dockerfile.azl2 b/Dockerfile.azl2 deleted file mode 100644 index a5955355f..000000000 --- a/Dockerfile.azl2 +++ /dev/null @@ -1,23 +0,0 @@ -FROM mcr.microsoft.com/cbl-mariner/base/rust:1 - -RUN tdnf install -y rpmdevtools openssl-devel clang-devel protobuf-devel - -WORKDIR /work - -COPY trident.spec . -COPY systemd ./systemd -COPY bin/trident ./target/release/trident -COPY artifacts/osmodifier /usr/src/mariner/SOURCES/osmodifier -COPY trident-selinuxpolicies.cil /usr/src/mariner/SOURCES/trident-selinuxpolicies.cil - -ARG TRIDENT_VERSION=dev-build -ARG RPM_VER=0.1.0 -ARG RPM_REL=1 - -RUN \ - sed -i "s/cargo build/#cargo build/g" trident.spec && \ - rpmbuild -bb --build-in-place trident.spec \ - --define="trident_version $TRIDENT_VERSION" \ - --define="rpm_ver $RPM_VER" \ - --define="rpm_rel $RPM_REL" && \ - tar -czvf trident-rpms.tar.gz -C /usr/src/mariner ./RPMS diff --git a/Dockerfile.azl3 b/Dockerfile.azl3 index ec6f476bc..1ca3dd430 100644 --- a/Dockerfile.azl3 +++ b/Dockerfile.azl3 @@ -1,6 +1,8 @@ +### This file is primarily used to build Trident RPMs for local testing. + FROM mcr.microsoft.com/azurelinux/base/core:3.0 -RUN tdnf install -y rpmdevtools openssl-devel clang-devel protobuf-devel rust sed +RUN tdnf install -y rpmdevtools openssl-devel clang-devel protobuf-devel rust sed selinux-policy-devel WORKDIR /work @@ -8,7 +10,10 @@ COPY trident.spec . COPY systemd ./systemd COPY bin/trident ./target/release/trident COPY artifacts/osmodifier /usr/src/azl/SOURCES/osmodifier -COPY trident-selinuxpolicies.cil /usr/src/azl/SOURCES/trident-selinuxpolicies.cil +COPY selinux-policy-trident/trident.te /usr/src/azl/SOURCES/trident.te +COPY selinux-policy-trident/trident.fc /usr/src/azl/SOURCES/trident.fc +COPY selinux-policy-trident/trident.if /usr/src/azl/SOURCES/trident.if +COPY packaging/static-pcrlock-files/ /usr/src/azl/SOURCES/static-pcrlock-files/ ARG TRIDENT_VERSION=dev-build ARG RPM_VER=0.1.0 diff --git a/Dockerfile.full b/Dockerfile.full index 1969c0eb1..37109074d 100644 --- a/Dockerfile.full +++ b/Dockerfile.full @@ -1,13 +1,18 @@ +### This file is used in pipelines to build Trident RPMs. + FROM mcr.microsoft.com/azurelinux/base/core:3.0 -RUN tdnf install -y rpmdevtools openssl-devel clang-devel protobuf-devel rust sed ca-certificates perl build-essential +RUN tdnf install -y rpmdevtools openssl-devel clang-devel protobuf-devel rust sed ca-certificates perl build-essential selinux-policy-devel WORKDIR /work COPY trident.spec . COPY systemd ./systemd COPY artifacts/osmodifier /usr/src/azl/SOURCES/osmodifier -COPY trident-selinuxpolicies.cil /usr/src/azl/SOURCES/trident-selinuxpolicies.cil +COPY selinux-policy-trident/trident.te /usr/src/azl/SOURCES/trident.te +COPY selinux-policy-trident/trident.fc /usr/src/azl/SOURCES/trident.fc +COPY selinux-policy-trident/trident.if /usr/src/azl/SOURCES/trident.if +COPY packaging/static-pcrlock-files/ /usr/src/azl/SOURCES/static-pcrlock-files/ COPY .cargo/config.toml ./.cargo/config.toml COPY build.rs . @@ -29,7 +34,10 @@ ARG RPM_REL=1 # This entry needs to exist in the config.toml file to allow cargo to use the # token from the environment variable. -# RUN printf '[registry]\nglobal-credential-providers = ["cargo:token"]\n' >> ./.cargo/config.toml +ARG CARGO_REGISTRIES_FROM_ENV=false +RUN if [ "$CARGO_REGISTRIES_FROM_ENV" = "true" ]; then \ + printf '[registry]\nglobal-credential-providers = ["cargo:token"]\n' >> ./.cargo/config.toml; \ +fi RUN --mount=type=secret,id=registry_token \ export CARGO_REGISTRIES_BMP_PUBLICPACKAGES_TOKEN=$(cat /run/secrets/registry_token) && \ diff --git a/Dockerfile.runtime b/Dockerfile.runtime index 6c39e20ae..f385cdb38 100644 --- a/Dockerfile.runtime +++ b/Dockerfile.runtime @@ -1,3 +1,5 @@ +### This file is used to build a container image with Trident inside. + FROM mcr.microsoft.com/azurelinux/base/core:3.0 RUN tdnf -y install \ @@ -21,6 +23,8 @@ RUN \ --mount=type=bind,source=./bin/RPMS,target=/trident \ tdnf install -y \ /trident/x86_64/trident-0*.rpm && \ + tdnf install -y \ + /trident/x86_64/trident-static-pcrlock-files-0*.rpm && \ tdnf clean all ENV DOCKER_ENVIRONMENT=true diff --git a/Makefile b/Makefile index 16bcbbeee..0efb851a6 100644 --- a/Makefile +++ b/Makefile @@ -14,7 +14,7 @@ NETLAUNCH_CONFIG ?= input/netlaunch.yaml OVERRIDE_RUST_FEED ?= true .PHONY: all -all: format check test build-api-docs bin/trident-rpms-azl2.tar.gz bin/trident-rpms-azl3.tar.gz docker-build build-functional-test coverage validate-configs generate-mermaid-diagrams +all: format check test build-api-docs bin/trident-rpms.tar.gz docker-build build-functional-test coverage validate-configs generate-mermaid-diagrams .PHONY: check check: @@ -62,10 +62,16 @@ check-sh: fi @echo "NOTICE: Created local .cargo/config file." -.PHONY: build -build: .cargo/config +.PHONY: version-vars +version-vars: $(eval TRIDENT_CARGO_VERSION := $(shell python3 ./scripts/get-version.py "$(shell date +%Y%m%d).99")) $(eval GIT_COMMIT := $(shell git rev-parse --short HEAD)$(shell git diff --quiet || echo '.dirty')) + $(eval LOCAL_BUILD_TRIDENT_VERSION=$(TRIDENT_CARGO_VERSION)-dev.$(GIT_COMMIT)) + @echo "TRIDENT_CARGO_VERSION=$(TRIDENT_CARGO_VERSION)" + @echo "GIT_COMMIT=$(GIT_COMMIT)" + +.PHONY: build +build: .cargo/config version-vars @OPENSSL_STATIC=1 \ OPENSSL_LIB_DIR=$(shell dirname `whereis libssl.a | cut -d" " -f2`) \ OPENSSL_INCLUDE_DIR=/usr/include/openssl \ @@ -117,22 +123,30 @@ bin/trident: build @mkdir -p bin @cp -u target/release/trident bin/ -bin/trident-rpms-azl2.tar.gz: Dockerfile.azl2 systemd/*.service trident.spec artifacts/osmodifier bin/trident - @docker build --quiet -t trident/trident-build:latest \ - --build-arg TRIDENT_VERSION="$(TRIDENT_CARGO_VERSION)-dev.$(GIT_COMMIT)" \ - --build-arg RPM_VER="$(TRIDENT_CARGO_VERSION)" \ - --build-arg RPM_REL="dev.$(GIT_COMMIT)" \ - -f Dockerfile.azl2 \ - . +# This will do a proper build on azl3, exactly as the pipelines would, with the custom registry and all. +bin/trident-rpms-azl3.tar.gz: Dockerfile.full systemd/*.service trident.spec artifacts/osmodifier selinux-policy-trident/* version-vars + $(eval CARGO_REGISTRIES_BMP_PUBLICPACKAGES_TOKEN := $(shell az account get-access-token --query "join(' ', ['Bearer', accessToken])" --output tsv)) + + @export CARGO_REGISTRIES_BMP_PUBLICPACKAGES_TOKEN="$(CARGO_REGISTRIES_BMP_PUBLICPACKAGES_TOKEN)" &&\ + docker build -t trident/trident-build:latest \ + --secret id=registry_token,env=CARGO_REGISTRIES_BMP_PUBLICPACKAGES_TOKEN \ + --build-arg CARGO_REGISTRIES_FROM_ENV="true" \ + --build-arg TRIDENT_VERSION="$(LOCAL_BUILD_TRIDENT_VERSION)" \ + --build-arg RPM_VER="$(TRIDENT_CARGO_VERSION)" \ + --build-arg RPM_REL="dev.$(GIT_COMMIT)" \ + -f Dockerfile.full \ + . + @mkdir -p bin/ @id=$$(docker create trident/trident-build:latest) && \ - docker cp -q $$id:/work/trident-rpms.tar.gz $@ && \ + docker cp -q $$id:/work/trident-rpms.tar.gz $@ || \ docker rm -v $$id - @rm -rf bin/RPMS/x86_64 + @rm -rf bin/RPMS/ @tar xf $@ -C bin/ -bin/trident-rpms-azl3.tar.gz: Dockerfile.azl3 systemd/*.service trident.spec artifacts/osmodifier bin/trident +# This one does a fast trick-build where we build locally and inject the binary into the container to add it to the RPM. +bin/trident-rpms.tar.gz: Dockerfile.azl3 systemd/*.service trident.spec artifacts/osmodifier bin/trident selinux-policy-trident/* @docker build -t trident/trident-build:latest \ - --build-arg TRIDENT_VERSION="$(TRIDENT_CARGO_VERSION)-dev.$(GIT_COMMIT)" \ + --build-arg TRIDENT_VERSION="$(LOCAL_BUILD_TRIDENT_VERSION)" \ --build-arg RPM_VER="$(TRIDENT_CARGO_VERSION)" \ --build-arg RPM_REL="dev.$(GIT_COMMIT)" \ -f Dockerfile.azl3 \ @@ -141,14 +155,25 @@ bin/trident-rpms-azl3.tar.gz: Dockerfile.azl3 systemd/*.service trident.spec art @id=$$(docker create trident/trident-build:latest) && \ docker cp -q $$id:/work/trident-rpms.tar.gz $@ || \ docker rm -v $$id - @rm -rf bin/RPMS/x86_64 + @rm -rf bin/RPMS/ @tar xf $@ -C bin/ -bin/trident-rpms.tar.gz: bin/trident-rpms-azl3.tar.gz - cp $< $@ +STEAMBOAT_RPMS_DIR ?= /tmp/mariner/uki/out/RPMS + +.PHONY: copy-rpms-to-steamboat +copy-rpms-to-steamboat: bin/trident-rpms-azl3.tar.gz + @echo "Cleaning up old Trident RPMs in Steamboat..." + @rm -f $(STEAMBOAT_RPMS_DIR)/trident-* + @echo "Copying Trident RPMs to Steamboat..." + @mkdir -p $(STEAMBOAT_RPMS_DIR) + @find bin/RPMS -type f -name 'trident-*.rpm' -exec cp {} $(STEAMBOAT_RPMS_DIR) \; + @echo "Trident RPMs copied to Steamboat directory: $(STEAMBOAT_RPMS_DIR)" + @ls -alh $(STEAMBOAT_RPMS_DIR)/trident-*.rpm + +# Grabs bin/trident-rpms.tar.gz from the local build directory and builds a Docker image with it. .PHONY: docker-build -docker-build: Dockerfile.runtime bin/trident-rpms-azl3.tar.gz +docker-build: Dockerfile.runtime bin/trident-rpms.tar.gz @docker build --quiet -f Dockerfile.runtime -t trident/trident:latest . artifacts/test-image/trident-container.tar.gz: docker-build @@ -193,6 +218,7 @@ TRIDENT_API_HC_EXAMPLE_FILE := docs/Reference/Host-Configuration/Sample-Host-Con TRIDENT_API_HC_EXAMPLE_YAML := docs/Reference/Host-Configuration/sample-host-configuration.yaml TRIDENT_API_HC_STORAGE_RULES_FILES := docs/Reference/Host-Configuration/Storage-Rules.md TRIDENT_API_CLI_DOC := docs/Reference/Trident-CLI.md +TRIDENT_ARCH_INSTALL_SVG := docs/resources/trident-install.svg target/trident-api-docs: mkdir -p target/trident-api-docs @@ -224,6 +250,9 @@ build-api-docs: build-api-schema docbuilder $(DOCBUILDER_BIN) trident-cli -o $(TRIDENT_API_CLI_DOC) @echo Wrote CLI docs to $(TRIDENT_API_CLI_DOC) + $(DOCBUILDER_BIN) trident-arch install > $(TRIDENT_ARCH_INSTALL_SVG) + @echo Wrote install diagram to $(TRIDENT_ARCH_INSTALL_SVG) + # This target is meant to be used by CI to ensure that the API schema is up to date. @@ -269,28 +298,45 @@ build-functional-test-cc: .cargo/config cargo build --target-dir $(TRIDENT_COVERAGE_TARGET) --lib --tests --features functional-test --all .PHONY: functional-test -functional-test: bin/trident-mos.iso bin/trident artifacts/osmodifier artifacts/test-image/regular.cosi bin/netlaunch +functional-test: artifacts/trident-functest.qcow2 cp $(PLATFORM_TESTS_PATH)/tools/marinerhci_test_tools/node_interface.py functional_tests/ cp $(PLATFORM_TESTS_PATH)/tools/marinerhci_test_tools/ssh_node.py functional_tests/ - cp bin/trident artifacts/test-image/ - cp artifacts/osmodifier artifacts/test-image/ $(MAKE) functional-test-core # A target for pipelines that skips all setup and building steps that are not # required in the pipeline environment. .PHONY: functional-test-core -functional-test-core: build-functional-test-cc generate-functional-test-manifest -# Check if INSTALLER_ISO_PATH is set, if not, check if the installer iso is present in the bin directory -ifndef INSTALLER_ISO_PATH -ifeq ($(wildcard bin/trident-mos.iso),) - $(error INSTALLER_ISO_PATH is not set and bin/trident-mos.iso is not present in the bin directory) -endif -endif - python3 -u -m pytest functional_tests/test_setup.py functional_tests/$(FILTER) --keep-duplicates -v -o junit_logging=all --junitxml $(FUNCTIONAL_TEST_JUNIT_XML) ${FUNCTIONAL_TEST_EXTRA_PARAMS} --keep-environment --test-dir $(FUNCTIONAL_TEST_DIR) --build-output $(BUILD_OUTPUT) --force-upload +functional-test-core: artifacts/osmodifier build-functional-test-cc generate-functional-test-manifest artifacts/trident-functest.qcow2 + python3 -u -m \ + pytest --color=yes \ + --log-level=INFO \ + --force-upload \ + functional_tests/test_setup.py \ + functional_tests/$(FILTER) \ + --keep-duplicates \ + -v \ + -o junit_logging=all \ + --junitxml $(FUNCTIONAL_TEST_JUNIT_XML) \ + ${FUNCTIONAL_TEST_EXTRA_PARAMS} \ + --keep-environment \ + --test-dir $(FUNCTIONAL_TEST_DIR) \ + --build-output $(BUILD_OUTPUT) .PHONY: patch-functional-test -patch-functional-test: build-functional-test-cc generate-functional-test-manifest - ARGUS_TOOLKIT_PATH=$(ARGUS_TOOLKIT_PATH) python3 -u -m pytest functional_tests/$(FILTER) -v -o junit_logging=all --junitxml $(FUNCTIONAL_TEST_JUNIT_XML) ${FUNCTIONAL_TEST_EXTRA_PARAMS} --keep-environment --test-dir $(FUNCTIONAL_TEST_DIR) --build-output $(BUILD_OUTPUT) --reuse-environment +patch-functional-test: artifacts/osmodifier build-functional-test-cc generate-functional-test-manifest + python3 -u -m \ + pytest --color=yes \ + --log-level=INFO \ + --force-upload \ + functional_tests/$(FILTER) \ + -v \ + -o junit_logging=all \ + --junitxml $(FUNCTIONAL_TEST_JUNIT_XML) \ + ${FUNCTIONAL_TEST_EXTRA_PARAMS} \ + --keep-environment \ + --test-dir $(FUNCTIONAL_TEST_DIR) \ + --build-output $(BUILD_OUTPUT) \ + --reuse-environment .PHONY: generate-functional-test-manifest generate-functional-test-manifest: .cargo/config @@ -299,10 +345,10 @@ generate-functional-test-manifest: .cargo/config .PHONY: validate-configs validate-configs: bin/trident - $(eval DETECTED_HC_FILES := $(shell grep -R 'storage:' . --include '*.yaml' --exclude-dir=trident-mos --exclude-dir=target --exclude-dir=dev --exclude-dir=azure-linux-image-tools -l)) + $(eval DETECTED_HC_FILES := $(shell grep -R 'storage:' . --include '*.yaml' --exclude-dir=trident-mos --exclude-dir=target --exclude-dir=dev --exclude-dir=azure-linux-image-tools --exclude-dir=docbuilder -l)) @for file in $(DETECTED_HC_FILES); do \ echo "Validating $$file"; \ - $< validate $$file || exit 1; \ + $< validate $$file -v info || exit 1; \ done .PHONY: generate-mermaid-diagrams @@ -339,8 +385,8 @@ bin/mkcosi: tools/cmd/mkcosi/* tools/go.sum tools/pkg/* tools/cmd/mkcosi/**/* bin/storm-trident: $(shell find storm -type f) tools/go.sum @mkdir -p bin - cd storm && go generate suites/trident/e2e/discover.go - cd storm && go build -o ../bin/storm-trident ./cmd/storm-trident/main.go + cd tools && go generate storm/e2e/discover.go + cd tools && go build -o ../bin/storm-trident ./cmd/storm-trident/main.go .PHONY: validate validate: $(TRIDENT_CONFIG) bin/trident @@ -374,12 +420,12 @@ run-netlaunch: $(NETLAUNCH_CONFIG) $(TRIDENT_CONFIG) $(NETLAUNCH_ISO) bin/netlau run-netlaunch-container-images: \ validate \ $(NETLAUNCH_CONFIG) \ - artifacts/trident-container-installer-testimage.iso \ + artifacts/trident-container-installer.iso \ artifacts/test-image/trident-container.tar.gz \ $(TRIDENT_CONFIG) \ bin/netlaunch @bin/netlaunch \ - --iso artifacts/trident-container-installer-testimage.iso \ + --iso artifacts/trident-container-installer.iso \ $(if $(NETLAUNCH_PORT),--port $(NETLAUNCH_PORT)) \ --config $(NETLAUNCH_CONFIG) \ --trident $(TRIDENT_CONFIG) \ @@ -412,6 +458,30 @@ run-netlaunch-sample: build-api-docs yq '.os.users += [{"name": "$(shell whoami)", "sshPublicKeys": ["$(shell cat ~/.ssh/id_rsa.pub)"], "sshMode": "key-only", "secondaryGroups": ["wheel"]}] | (.. | select(tag == "!!str")) |= sub("file:///trident_cdrom/data", "http://NETLAUNCH_HOST_ADDRESS/files") | del(.storage.encryption.recoveryKeyUrl) | (.storage.filesystems[] | select(has("source")) | .source).sha256 = "ignored" | .storage.verityFilesystems[].dataImage.sha256 = "ignored" | .storage.verityFilesystems[].hashImage.sha256 = "ignored"' docs/Reference/Host-Configuration/Samples/$(HOST_CONFIG) > $(TMP) TRIDENT_CONFIG=$(TMP) make run-netlaunch +# Downloads the latest Trident functional test image from the Azure DevOps pipeline. +artifacts/trident-functest.qcow2: + $(eval BRANCH ?= main) + $(eval RUN_ID ?= $(shell az pipelines runs list \ + --org "https://dev.azure.com/mariner-org" \ + --project "ECF" \ + --pipeline-ids 3371 \ + --branch $(BRANCH) \ + --query-order QueueTimeDesc \ + --result succeeded \ + --reason triggered \ + --top 1 \ + --query '[0].id')) + @echo PIPELINE RUN ID: $(RUN_ID) + + mkdir -p artifacts + rm -f $@ + az pipelines runs artifact download \ + --org 'https://dev.azure.com/mariner-org' \ + --project "ECF" \ + --run-id $(RUN_ID) \ + --path artifacts/ \ + --artifact-name 'trident-functest' + # Downloads regular, verity, and container COSI images from the latest successful # pipeline run. The images are downloaded to ./artifacts/test-image. .PHONY: download-runtime-images @@ -428,6 +498,12 @@ download-runtime-images: --top 1 \ --query '[0].id')) @echo PIPELINE RUN ID: $(RUN_ID) + +# Clean & create artifacts dir + rm -rf ./artifacts/test-image + mkdir -p ./artifacts/test-image + +# Get regular image $(eval DOWNLOAD_DIR := $(shell mktemp -d)) az pipelines runs artifact download \ --org 'https://dev.azure.com/mariner-org' \ @@ -436,15 +512,28 @@ download-runtime-images: --path $(DOWNLOAD_DIR) \ --artifact-name 'trident-testimage' -# Clean & create artifacts dir - rm -rf ./artifacts/test-image - mkdir -p ./artifacts/test-image -# Move regular COSI image - mv $(DOWNLOAD_DIR)/*.cosi ./artifacts/test-image/regular.cosi +# Move COSI images + mv $(DOWNLOAD_DIR)/*_0.cosi ./artifacts/test-image/regular.cosi + mv $(DOWNLOAD_DIR)/*_1.cosi ./artifacts/test-image/regular_v2.cosi +# Clean temp dir + rm -rf $(DOWNLOAD_DIR) + +# Get usr-verity image + $(eval DOWNLOAD_DIR := $(shell mktemp -d)) + az pipelines runs artifact download \ + --org 'https://dev.azure.com/mariner-org' \ + --project "ECF" \ + --run-id $(RUN_ID) \ + --path $(DOWNLOAD_DIR) \ + --artifact-name 'trident-usrverity-testimage' + +# Move COSI images + mv $(DOWNLOAD_DIR)/*_0.cosi ./artifacts/test-image/usrverity.cosi + mv $(DOWNLOAD_DIR)/*_1.cosi ./artifacts/test-image/usrverity_v2.cosi # Clean temp dir rm -rf $(DOWNLOAD_DIR) -# Get verity image +# Get root-verity image $(eval DOWNLOAD_DIR := $(shell mktemp -d)) az pipelines runs artifact download \ --org 'https://dev.azure.com/mariner-org' \ @@ -453,8 +542,9 @@ download-runtime-images: --path $(DOWNLOAD_DIR) \ --artifact-name 'trident-verity-testimage' -# Move verity COSI image - mv $(DOWNLOAD_DIR)/*.cosi ./artifacts/test-image/verity.cosi +# Move COSI images + mv $(DOWNLOAD_DIR)/*_0.cosi ./artifacts/test-image/verity.cosi + mv $(DOWNLOAD_DIR)/*_1.cosi ./artifacts/test-image/verity_v2.cosi # Clean temp dir rm -rf $(DOWNLOAD_DIR) @@ -467,8 +557,9 @@ download-runtime-images: --path $(DOWNLOAD_DIR) \ --artifact-name 'trident-container-testimage' -# Move container COSI image - mv $(DOWNLOAD_DIR)/*.cosi ./artifacts/test-image/container.cosi +# Move COSI images + mv $(DOWNLOAD_DIR)/*_0.cosi ./artifacts/test-image/container.cosi + mv $(DOWNLOAD_DIR)/*_1.cosi ./artifacts/test-image/container_v2.cosi # Clean temp dir rm -rf $(DOWNLOAD_DIR) @@ -497,7 +588,7 @@ endif --project "ECF" \ --run-id $(RUN_ID) \ --path artifacts/ \ - --artifact-name 'trident-installer-testimage' + --artifact-name 'trident-installer' .PHONY: download-trident-container-installer-iso download-trident-container-installer-iso: @@ -519,11 +610,11 @@ download-trident-container-installer-iso: --project "ECF" \ --run-id $(RUN_ID) \ --path artifacts/ \ - --artifact-name 'trident-container-installer-testimage' + --artifact-name 'trident-container-installer' -artifacts/trident-container-installer-testimage.iso: +artifacts/trident-container-installer.iso: $(MAKE) download-trident-container-installer-iso; \ - ls -l artifacts/trident-container-installer-testimage.iso + ls -l artifacts/trident-container-installer.iso # Copies locally built runtime images from ../test-images/build to ./artifacts/test-image. # Expects that both the regular and verity Trident test images have been built. @@ -597,7 +688,7 @@ artifacts/imagecustomizer: @chmod +x artifacts/imagecustomizer @touch artifacts/imagecustomizer -bin/trident-mos.iso: artifacts/baremetal.vhdx artifacts/imagecustomizer systemd/trident-install.service trident-mos/iso.yaml trident-mos/files/* trident-mos/post-install.sh +bin/trident-mos.iso: artifacts/baremetal.vhdx artifacts/imagecustomizer systemd/trident-install.service trident-mos/iso.yaml trident-mos/files/* trident-mos/post-install.sh selinux-policy-trident/* @mkdir -p bin BUILD_DIR=`mktemp -d` && \ trap 'sudo rm -rf $$BUILD_DIR' EXIT; \ diff --git a/azure-linux-image-tools b/azure-linux-image-tools index c60e9422d..e52c4572c 160000 --- a/azure-linux-image-tools +++ b/azure-linux-image-tools @@ -1 +1 @@ -Subproject commit c60e9422d312c0a8af1844d3e954dcf4858da644 +Subproject commit e52c4572cf1b8d98738a2ba40d17006752b7d0e4 diff --git a/azurepipelines-coverage.yml b/azurepipelines-coverage.yml new file mode 100644 index 000000000..716200487 --- /dev/null +++ b/azurepipelines-coverage.yml @@ -0,0 +1,5 @@ +coverage: + status: # Code coverage status will be posted to pull requests based on targets defined below. + comments: on # Off by default. When on, details about coverage for each file changed will be posted as a pull request comment. + diff: # diff coverage is code coverage only for the lines changed in a pull request. + target: 50% # set this to a desired %. Default is 50% \ No newline at end of file diff --git a/dev-docs/diagrams/overall-testing-on-baremetal.mmd b/dev-docs/diagrams/overall-testing-on-baremetal.mmd index f685424ac..e27ef239e 100644 --- a/dev-docs/diagrams/overall-testing-on-baremetal.mmd +++ b/dev-docs/diagrams/overall-testing-on-baremetal.mmd @@ -16,7 +16,7 @@ F0 --> D["Strategy Matrix"] subgraph Job to execute the test - trident_baremetal_tests D --> E[Loop for each test from the list of E2E Tests] E --> G[Run E2E Test] - subgraph Seperate step for each test in Strategy Matrix + subgraph Separate step for each test in Strategy Matrix G --> H[Checkout Repositories] H --> I[Install prerequisites for BM Host communication - baremetal-prep.yml] I --> J[Create SSH key and install prerequisites required for the E2E tests - trident-prep.yml] diff --git a/dev-docs/prerequisites.md b/dev-docs/prerequisites.md index 5d696272e..f44bede76 100644 --- a/dev-docs/prerequisites.md +++ b/dev-docs/prerequisites.md @@ -28,6 +28,7 @@ - Install `build-essential`, `pkg-config`, `libssl-dev`, `libclang-dev`, and `protobuf-compiler`. E.g. `sudo apt install build-essential pkg-config libssl-dev libclang-dev protobuf-compiler`. +- Install the `virt-firmware` Python package: `sudo pip3 install virt-firmware`. - Clone the [Trident repository](https://mariner-org@dev.azure.com/mariner-org/ECF/_git/trident): `git clone https://mariner-org@dev.azure.com/mariner-org/ECF/_git/trident`. diff --git a/dev-docs/specs/Composable-OS-Image.md b/dev-docs/specs/Composable-OS-Image.md index 19f4a759d..dbd5202ed 100644 --- a/dev-docs/specs/Composable-OS-Image.md +++ b/dev-docs/specs/Composable-OS-Image.md @@ -2,9 +2,10 @@ ## Revision Summary -| Revision | Date | Comment | -| -------- | ---------- | ---------------- | -| 1.0 | 2024-10-09 | Initial version. | +| Revision | Spec Date | +| ------------------- | ---------- | +| [1.1](#revision-11) | TBD | +| [1.0](#revision-10) | 2024-10-09 | ## Table of Contents @@ -21,15 +22,22 @@ - [Metadata JSON File](#metadata-json-file) - [Schema](#schema) - [Root Object](#root-object) - - [`Image` Object](#image-object) + - [`Filesystem` Object](#filesystem-object) - [`VerityConfig` Object](#verityconfig-object) - [`ImageFile` Object](#imagefile-object) - [`OsArchitecture` Enum](#osarchitecture-enum) - [`OsPackage` Object](#ospackage-object) + - [`Bootloader` Object](#bootloader-object) + - [`BootloaderType` Enum](#bootloadertype-enum) + - [`SystemDBoot` Object](#systemdboot-object) + - [`SystemDBootEntry` Object](#systemdbootentry-object) + - [`SystemDBootEntryType` Enum](#systemdbootentrytype-enum) - [Samples](#samples) - [Simple Image](#simple-image) - - [Verity Image](#verity-image) - - [Packages](#packages) + - [Verity Image with UKI](#verity-image-with-uki) + - [Changelog](#changelog) + - [Revision 1.1](#revision-11) + - [Revision 1.0](#revision-10) - [FAQ and Notes](#faq-and-notes) ## Background @@ -134,71 +142,68 @@ tarball. The metadata file MUST be a valid JSON file. The metadata file MUST contain a JSON object with the following fields: -| Field | Type | Required | Description | -| ------------ | -------------------------------------- | -------- | ------------------------------------------------------ | -| `version` | string `MAJOR.MINOR` | Yes | The version of the metadata schema. | -| `osArch` | [OsArchitecture](#osarchitecture-enum) | Yes | The architecture of the OS. | -| `osRelease` | string | Yes | The contents of `/etc/os-release` verbatim. | -| `images` | [Image](#image-object)[] | Yes | Metadata of partition images that contain filesystems. | -| `osPackages` | [OsPackage](#ospackage-object)[] | No | The list of packages installed in the OS. | -| `id` | UUID (string, case insensitive) | No | A unique identifier for the COSI file. | - -To allow for future extensions, the object MAY contain other fields, but Trident -MUST ignore them. The object SHOULD NOT contain any extra fields that will not -be used by Trident. - -##### `Image` Object - -| Field | Type | Required | Description | -| ------------ | ------------------------------------ | -------- | ----------------------------------------- | -| `image` | [ImageFile](#imagefile-object) | Yes | Details of the image file in the tarball. | -| `mountPoint` | string | Yes | The mount point of the partition. | -| `fsType` | string | Yes | The filesystem type of the partition. [1] | -| `fsUuid` | string | Yes | The UUID of the filesystem. [2] | -| `partType` | UUID (string, case insensitive) | Yes | The GPT partition type. [3] [4] [5] | -| `verity` | [VerityConfig](#verityconfig-object) | No | The verity metadata of the partition. | +| Field | Type | Added in | Required | Description | +| ------------ | -------------------------------------- | -------- | --------------- | ------------------------------------------------ | +| `version` | string `MAJOR.MINOR` | 1.0 | Yes (since 1.0) | The version of the metadata schema. | +| `osArch` | [OsArchitecture](#osarchitecture-enum) | 1.0 | Yes (since 1.0) | The architecture of the OS. | +| `osRelease` | string | 1.0 | Yes (since 1.0) | The contents of `/etc/os-release` verbatim. | +| `images` | [Filesystem](#filesystem-object)[] | 1.0 | Yes (since 1.0) | Filesystem metadata. | +| `osPackages` | [OsPackage](#ospackage-object)[] | 1.0 | Yes (since 1.1) | The list of packages installed in the OS. | +| `bootloader` | [Bootloader](#bootloader-object) | 1.1 | Yes (since 1.1) | Information about the bootloader used by the OS. | +| `id` | UUID (string, case insensitive) | 1.0 | No | A unique identifier for the COSI file. | + +If the object contains other fields, readers MUST ignore them. A writer SHOULD +NOT add any other files to the object. + +##### `Filesystem` Object + +This object carries information about a filesystem and the partition it comes +from in a virtual disk. + +| Field | Type | Added in | Required | Description | +| ------------ | ------------------------------------ | -------- | ---------------- | ----------------------------------------- | +| `image` | [ImageFile](#imagefile-object) | 1.0 | Yes (since 1.0) | Details of the image file in the tarball. | +| `mountPoint` | string | 1.0 | Yes (since 1.0) | The mount point of the filesystem. | +| `fsType` | string | 1.0 | Yes (since 1.0) | The filesystem's type. [1] | +| `fsUuid` | string | 1.0 | Yes (since 1.0) | The UUID of the filesystem. [2] | +| `partType` | UUID (string, case insensitive) | 1.0 | Yes (since 1.0) | The GPT partition type. [3] [4] [5] | +| `verity` | [VerityConfig](#verityconfig-object) | 1.0 | Conditionally[6] | The verity metadata of the filesystem. | _Notes:_ -- **[1]** It MUST use the name recognized by the kernel. For example, `ext4` for ext4 filesystems, - `vfat` for FAT32 filesystems, etc. - -- **[2]** It MUST be unique across all filesystems in the COSI tarball. Additionally, volumes in an - A/B volume pair MUST have unique filesystem UUIDs. +- **[1]** It MUST use the name recognized by the kernel. For example, `ext4` for + ext4 filesystems, `vfat` for FAT32 filesystems, etc. +- **[2]** It MUST be unique across all filesystems in the COSI tarball. + Additionally, volumes in an A/B volume pair MUST have unique filesystem UUIDs. - **[3]** It MUST be a UUID defined by the [Discoverable Partition Specification - (DPS)](https://uapi-group.org/specifications/specs/discoverable_partitions_specification/) when - the applicable type exists in the DPS. Other partition types MAY be used for types not defined - in DPS (e.g. Windows partitions). - -- **[4]** The EFI Sytem Partition (ESP) MUST be identified with the UUID established by the DPS: - `c12a7328-f81f-11d2-ba4b-00a0c93ec93b`. - -- **[5]** Should default to `0fc63daf-8483-4772-8e79-3d69d8477de4` (Generic Linux Data) if the - partition type cannot be determined. + (DPS)](https://uapi-group.org/specifications/specs/discoverable_partitions_specification/) + when the applicable type exists in the DPS. Other partition types MAY be + used for types not defined in DPS (e.g. Windows partitions). +- **[4]** The EFI Sytem Partition (ESP) MUST be identified with the UUID + established by the DPS: `c12a7328-f81f-11d2-ba4b-00a0c93ec93b`. +- **[5]** Should default to `0fc63daf-8483-4772-8e79-3d69d8477de4` (Generic + Linux Data) if the partition type cannot be determined. +- **[6]** The `verity` field MUST be specified if the OS is configured to open this + filesystem with `dm-verity`. Otherwise, it MUST be omitted OR set to `null`. ##### `VerityConfig` Object The `VerityConfig` object contains information required to set up a verity -device on top of a data partition. +device on top of a data device. -| Field | Type | Required | Description | -| ---------- | ------------------------------ | -------- | -------------------------------------------------------- | -| `image` | [ImageFile](#imagefile-object) | Yes | Details of the hash partition image file in the tarball. | -| `roothash` | string | Yes | Verity root hash. | +| Field | Type | Added in | Required | Description | +| ---------- | ------------------------------ | -------- | --------------- | -------------------------------------------------------- | +| `image` | [ImageFile](#imagefile-object) | 1.0 | Yes (since 1.0) | Details of the hash partition image file in the tarball. | +| `roothash` | string | 1.0 | Yes (since 1.0) | Verity root hash. | ##### `ImageFile` Object -| Field | Type | Required | Description | -| ------------------ | ------ | -------- | ----------------------------------------------------------------------------------------- | -| `path` | string | Yes | Absolute path of the compressed image file inside the tarball. MUST start with `images/`. | -| `compressedSize` | number | Yes | Size of the compressed image in bytes. | -| `uncompressedSize` | number | Yes | Size of the raw uncompressed image in bytes. | -| `sha384` | string | No[5] | SHA-384 hash of the compressed hash image. | - -_Notes:_ - -- **[5]** The `sha384` field is optional, but it is RECOMMENDED to include it - for integrity verification. +| Field | Type | Added in | Required | Description | +| ------------------ | ------ | -------- | --------------- | ----------------------------------------------------------------------------------------- | +| `path` | string | 1.0 | Yes (since 1.0) | Absolute path of the compressed image file inside the tarball. MUST start with `images/`. | +| `compressedSize` | number | 1.0 | Yes (since 1.0) | Size of the compressed image in bytes. | +| `uncompressedSize` | number | 1.0 | Yes (since 1.0) | Size of the raw uncompressed image in bytes. | +| `sha384` | string | 1.0 | Yes (since 1.1) | SHA-384 hash of the compressed hash image. | ##### `OsArchitecture` Enum @@ -216,19 +221,15 @@ The `osArch` field is case-insensitive. ##### `OsPackage` Object -When present, the `osPackages` field in the root object MUST contain an array of -`OsPackage` objects. Each object represents a package installed in the OS. - -The field is strictly optional, but recommended. Trident MAY use this field to -figure out if the new OS is compatible with the Host Configuration by, for -example, identifying missing dependencies. +The `osPackages` field in the root object MUST contain an array of `OsPackage` +objects. Each object represents a package installed in the OS. -| Field | Type | Required | Description | -| --------- | ------ | -------- | ------------------------------------- | -| `name` | string | Yes | The name of the package. | -| `version` | string | Yes | The version of the package installed. | -| `release` | string | No | The release of the package. | -| `arch` | string | No | The architecture of the package. | +| Field | Type | Added in | Required | Description | +| --------- | ------ | -------- | --------------- | ------------------------------------- | +| `name` | string | 1.0 | Yes (since 1.0) | The name of the package. | +| `version` | string | 1.0 | Yes (since 1.0) | The version of the package installed. | +| `release` | string | 1.0 | Yes (since 1.1) | The release of the package. | +| `arch` | string | 1.0 | Yes (since 1.1) | The architecture of the package. | A suggested way to obtain this information is by running: @@ -236,13 +237,65 @@ A suggested way to obtain this information is by running: rpm -qa --queryformat "%{NAME} %{VERSION} %{RELEASE} %{ARCH}\n" ``` +##### `Bootloader` Object + +| Field | Type | Added in | Required | Description | +| ------------- | ---------------------------------------- | -------- | -------------------------------- | --------------------------- | +| `type` | [`BootloaderType`](#bootloadertype-enum) | 1.1 | Yes (since 1.1) | The type of the bootloader. | +| `systemdBoot` | [`SystemDBoot`](#systemdboot-object) | 1.1 | When `type` == `systemd-boot`[7] | systemd-boot configuration. | + +_Notes:_ + +- **[7]** The `systemd-boot` field is required if the `type` field is set to + `systemd-boot`. It MUST be omitted OR set to `null` if the `type` + field is set to any other value. + +##### `BootloaderType` Enum + +A string that represents the primary bootloader used in the contained OS. These +are the valid values for the `type` field in the `bootloader` object: + +| Value | Description | +| -------------- | --------------------------------------------------- | +| `systemd-boot` | The system is using systemd-boot as the bootloader. | +| `grub` | The system is using GRUB as the bootloader. | + +##### `SystemDBoot` Object + +This object contains metadata about how systemd-boot is configured in the OS. + +| Field | Type | Added in | Required | Description | +| --------- | ------------------------------------------------ | -------- | --------------- | ------------------------------------------------------------------------------------ | +| `entries` | [`SystemDBootEntry`](#systemdbootentry-object)[] | 1.1 | Yes (since 1.1) | The contents of the `loader/entries/*.conf` files in the systemd-boot EFI partition. | + +##### `SystemDBootEntry` Object + +This object contains metadata about a specific systemd-boot entry. + +| Field | Type | Added in | Required | Description | +| --------- | ---------------------------------------------------- | -------- | --------------- | ------------------------------------------------------ | +| `type` | [`SystemDBootEntryType`](#systemdbootentrytype-enum) | 1.1 | Yes (since 1.1) | The type of the entry. | +| `path` | string | 1.1 | Yes (since 1.1) | Absolute path (from the root FS) to the UKI or config. | +| `cmdline` | string | 1.1 | Yes (since 1.1) | The kernel command line. | +| `kernel` | string | 1.1 | Yes (since 1.1) | Kernel release as a string. | + +##### `SystemDBootEntryType` Enum + +A string that represents the type of the systemd-boot entry. + +| Value | Description | +| ---------------- | ------------------------------------------------------------------ | +| `uki-standalone` | The entry is a bare UKI file in the ESP. | +| `uki-config` | The entry is a config file with a UKI. | +| `config` | The entry is a config file with a kernel, initrd and command line. | + #### Samples ##### Simple Image ```json { - "version": "1.0", + "version": "1.1", "images": [ { "image": { @@ -271,15 +324,39 @@ rpm -qa --queryformat "%{NAME} %{VERSION} %{RELEASE} %{ARCH}\n" "verity": null } ], - "osRelease": "NAME=\"Microsoft Azure Linux\"\nVERSION=\"3.0.20240824\"\nID=azurelinux\nVERSION_ID=\"3.0\"\nPRETTY_NAME=\"Microsoft Azure Linux 3.0\"\nANSI_COLOR=\"1;34\"\nHOME_URL=\"https://aka.ms/azurelinux\"\nBUG_REPORT_URL=\"https://aka.ms/azurelinux\"\nSUPPORT_URL=\"https://aka.ms/azurelinux\"\n" + "osRelease": "NAME=\"Microsoft Azure Linux\"\nVERSION=\"3.0.20240824\"\nID=azurelinux\nVERSION_ID=\"3.0\"\nPRETTY_NAME=\"Microsoft Azure Linux 3.0\"\nANSI_COLOR=\"1;34\"\nHOME_URL=\"https://aka.ms/azurelinux\"\nBUG_REPORT_URL=\"https://aka.ms/azurelinux\"\nSUPPORT_URL=\"https://aka.ms/azurelinux\"\n", + "bootloader": { + "type": "grub" + }, + "osPackages": [ + { + "name": "bash", + "version": "5.1.8", + "release": "1.azl3", + "arch": "x86_64" + }, + { + "name": "coreutils", + "version": "8.32", + "release": "1.azl3", + "arch": "x86_64" + }, + { + "name": "systemd", + "version": "255", + "release": "20.azl3", + "arch": "x86_64" + }, + // More packages... + ] } ``` -##### Verity Image +##### Verity Image with UKI ```json { - "version": "1.0", + "version": "1.1", "images": [ { "image": { @@ -304,37 +381,45 @@ rpm -qa --queryformat "%{NAME} %{VERSION} %{RELEASE} %{ARCH}\n" }, // More images... ], - "osRelease": "NAME=\"Microsoft Azure Linux\"\nVERSION=\"3.0.20240824\"\nID=azurelinux\nVERSION_ID=\"3.0\"\nPRETTY_NAME=\"Microsoft Azure Linux 3.0\"\nANSI_COLOR=\"1;34\"\nHOME_URL=\"https://aka.ms/azurelinux\"\nBUG_REPORT_URL=\"https://aka.ms/azurelinux\"\nSUPPORT_URL=\"https://aka.ms/azurelinux\"\n" -} -``` - -##### Packages - -```json -{ - "version": "1.0", - "images": [ - // Images... - ], - "osRelease": "", + "osRelease": "NAME=\"Microsoft Azure Linux\"\nVERSION=\"3.0.20240824\"\nID=azurelinux\nVERSION_ID=\"3.0\"\nPRETTY_NAME=\"Microsoft Azure Linux 3.0\"\nANSI_COLOR=\"1;34\"\nHOME_URL=\"https://aka.ms/azurelinux\"\nBUG_REPORT_URL=\"https://aka.ms/azurelinux\"\nSUPPORT_URL=\"https://aka.ms/azurelinux\"\n", + "bootloader": { + "type": "systemd-boot", + "systemdBoot": { + "entries": [ + { + "type": "uki-standalone", + "path": "/boot/efi/EFI/Linux/azurelinux-uki.efi", + "cmdline": "root=/dev/disk/by-partuuid/88d2fa9b-7a32-450a-a9f8-aa9c3de79298 ro", + "kernel": "6.6.78.1-3.azl3" + } + ] + } + }, "osPackages": [ - { - "name": "bash", - "version": "5.1.8" - }, - { - "name": "coreutils", - "version": "8.32" - }, { "name": "systemd", - "version": "255" + "version": "255", + "release": "20.azl3", + "arch": "x86_64" }, // More packages... ] } ``` +## Changelog + +### Revision 1.1 + +- Added `bootloader` field to the root object. +- Root field `osPackages` is now required. +- Field `sha384` in `ImageFile` object is now required. +- Fields `release` and `arch` in `OsPackage` object are now required. + +### Revision 1.0 + +- Initial revision + ## FAQ and Notes **Why tar?** diff --git a/dev-docs/testing.md b/dev-docs/testing.md index d3122132d..46331d6da 100644 --- a/dev-docs/testing.md +++ b/dev-docs/testing.md @@ -22,7 +22,7 @@ more details in the following sections. - [Deploy Baremetal Environment - baremetal-deploy.yml](#deploy-baremetal-environment---baremetal-deployyml) - [Run End-to-End Tests on the BM Host - e2e-test-run.yml](#run-end-to-end-tests-on-the-bm-host---e2e-test-runyml) - [Update trident.yaml to reflect the OAM IP, HTTP server and SSH Key Details - baremetal-update-trident-host-config.yml](#update-tridentyaml-to-reflect-the-oam-ip-http-server-and-ssh-key-details---baremetal-update-trident-host-configyml) - - [Boot baremetal lab machine - .pipelines/stages/testing\_baremetal/deploy\_on\_bm.py](#boot-baremetal-lab-machine---pipelinesstagestesting_baremetaldeploy_on_bmpy) + - [Boot baremetal lab machine - .pipelines/templates/stages/testing\_baremetal/deploy\_on\_bm.py](#boot-baremetal-lab-machine---pipelinestemplatesstagestesting_baremetaldeploy_on_bmpy) ## Unit Tests @@ -89,9 +89,9 @@ Functional tests should: Functional tests are structured as follows: - `/functional_tests`: Contains the functional test code, leveraging `pytest` - and common SSH interface from `platform-tests` repo. `pytest` creates the test VM - using is Fixtures concept and while currently only a single VM is created to - run all the tests, this could be easily extended to support seperate VMs for + and common SSH interface from `platform-tests` repo. `pytest` fixtures create + the test VM using virt-deploy. Currently, only one VM is created to run all + the tests, but this could be easily extended to support separate VMs for different tests. Most of the time, no changes will be required to this layer while developing functional tests. - `/functional_tests/trident-setup.yaml`: Contains the initial host @@ -139,10 +139,9 @@ deployment. The tests are started on the deployed OS through SSH connection. In the functional test environment, tests are run on top of block device /dev/sda. As a result, any changes made by the testing logic should **not** -modify this block device. E.g., this block device should not be reformatted to -a clean filesystem, be mounted/unmounted, etc. On the other hand, **/dev/sdb** -is available for any modifications that are needed for functional testing -purposes. +modify this block device. E.g., this block device should not be reformatted to a +clean filesystem, be mounted/unmounted, etc. On the other hand, **/dev/sdb** is +available for any modifications that are needed for functional testing purposes. ### Functional Test Building and Execution @@ -156,12 +155,9 @@ targets: - `make functional-test`: This will build the tests locally with code coverage profile (using internal `build-functional-test-cc` target), a new - `virt-deploy` VM will be created and deployed using `netlaunch`. Afterwards, + `virt-deploy` VM will be created using a prebuilt qcow image. Afterwards, tests will be uploaded into the VM, executed and code coverage will be - downloaded for later viewing. To note, this will also execute all UTs. If you - want to iterate on the tests without recreating the VM, but do want to - redeploy the OS, you can: `make functional-test - FUNCTIONAL_TEST_EXTRA_PARAMS="--reuse-environment --redeploy"`. + downloaded for later viewing. - `make patch-functional-test`: This will build the tests locally with code coverage profile (using internal `build-functional-test-cc` target), upload @@ -169,17 +165,16 @@ targets: code coverage for later viewing. This is useful when you want to iterate on the tests and don't want to wait for the VM to be deployed again. It is important to note that only tests that have changed will be re-uploaded. This - is determined based on `cargo build` output. To note, this will also execute - all UTs. - -To execute the functional tests, ensure that `platform-tests` and `argus-toolkit` of -recent version are checked out side by side with the `trident` repo. -Additionally, the following dependencies are required for the Ubuntu based -pipelines, so you might need to install them on your development machine as well -(note that this set is different per Ubuntu version and is provided just as an -illustration of what works for [pipelines](.pipelines/netlaunch-testing.yml), so -if you are on 22.04 or newer, you might not need to for example reinstall -`python3-openssl`): + is determined based on `cargo build` output. + +To execute the functional tests, ensure that `platform-tests` and +`argus-toolkit` of recent version are checked out side by side with the +`trident` repo. Additionally, the following dependencies are required for the +Ubuntu based pipelines, so you might need to install them on your development +machine as well (note that this set is different per Ubuntu version and is +provided just as an illustration of what works for +[pipelines](.pipelines/netlaunch-testing.yml), so if you are on 22.04 or newer, +you might not need to for example reinstall `python3-openssl`): ```bash sudo apt install -y protobuf-compiler clang-7 bc @@ -215,11 +210,11 @@ aggregated with the locally produced coverage results. ### Additional Notes -`functional-test` target depends on `platform-tests` and `argus-toolkit` repos to be -checked out side by side with the `trident` repo. This is because `platform-tests` -repo contains the common logic to execute test logic over SSH connection and -`argus-toolkit` repo contains the `netlaunch` and `virt-deploy` binaries, along -with logic to generate the OS deployment ISO. +`functional-test` target depends on `platform-tests` and `argus-toolkit` repos +to be checked out side by side with the `trident` repo. This is because +`platform-tests` repo contains the common logic to execute test logic over SSH +connection and `argus-toolkit` repo contains the `netlaunch` and `virt-deploy` +binaries, along with logic to generate the OS deployment ISO. Both `functional-test` and `patch-functional-test` targets leverage `pytest`. To get more detailed logs or do any changes to the `pytest` logic, you can modify @@ -240,15 +235,16 @@ code. ### Selective Test Execution -The functional test `make` targets support the variable `FILTER`. This is meant to -be used to filter the tests that are executed. For example, if you want to execute -only the tests from a certain rust crate, you can do: +The functional test `make` targets support the variable `FILTER`. This is meant +to be used to filter the tests that are executed. For example, if you want to +execute only the tests from a certain rust crate, you can do: ```bash make functional-test FILTER=ft.json:: ``` -You can narrow down the filter by adding the modules, up to each individual test. +You can narrow down the filter by adding the modules, up to each individual +test. ```bash make functional-test FILTER=ft.json:::::: @@ -294,11 +290,13 @@ End to end tests should: #### Install prerequisites for BM Host communication - baremetal-prep.yml -![Prerequisite Installation - BM Host](./diagrams/install-prerequisites-for-bm-host.png) +![Prerequisite Installation - BM +Host](./diagrams/install-prerequisites-for-bm-host.png) #### Create prerequisites required for the E2E tests - trident-prep.yml -![Prerequisite Installation - E2E](./diagrams/install-prerequisites-for-e2e-tests.png) +![Prerequisite Installation - +E2E](./diagrams/install-prerequisites-for-e2e-tests.png) #### Deploy Baremetal Environment - baremetal-deploy.yml @@ -310,7 +308,8 @@ End to end tests should: #### Update trident.yaml to reflect the OAM IP, HTTP server and SSH Key Details - baremetal-update-trident-host-config.yml -![Update trident.yaml to reflect the OAM IP, HTTP server and SSH Key Details](./diagrams/update-trident-yaml.png) +![Update trident.yaml to reflect the OAM IP, HTTP server and SSH Key +Details](./diagrams/update-trident-yaml.png) #### Boot baremetal lab machine - .pipelines/templates/stages/testing_baremetal/deploy_on_bm.py diff --git a/dev-docs/validating-container.md b/dev-docs/validating-container.md index 884a94970..7975ff5e2 100644 --- a/dev-docs/validating-container.md +++ b/dev-docs/validating-container.md @@ -100,7 +100,6 @@ validating an image running Trident from a container. ```bash ./virt-deploy create --mem 11 - ./virt-deploy run ``` Note that at least 11GB of RAM is necessary to run Trident in a container, diff --git a/docbuilder/Cargo.toml b/docbuilder/Cargo.toml index a05165e83..5d811450b 100644 --- a/docbuilder/Cargo.toml +++ b/docbuilder/Cargo.toml @@ -20,7 +20,9 @@ serde = { version = "1.0.215", features = ["derive"] } serde_json = "1.0" serde_yaml = "0.9.34" strum = "0.26.3" +svg = "0.18.0" tera = { version = "1.20.0" } +textwrap = "0.16.2" setsail = { path = "../setsail" } diff --git a/docbuilder/src/main.rs b/docbuilder/src/main.rs index cb9c653e8..bdb5ed57a 100644 --- a/docbuilder/src/main.rs +++ b/docbuilder/src/main.rs @@ -1,7 +1,7 @@ use std::path::PathBuf; use anyhow::{Context, Error}; -use clap::{Args, Parser, Subcommand}; +use clap::{Args, Parser, Subcommand, ValueEnum}; use log::info; use crate::schema_renderer::SchemaDocSettings; @@ -10,6 +10,7 @@ mod host_config; mod markdown; mod schema_renderer; mod setsail; +mod trident_arch; mod trident_cli; #[derive(Parser, Debug)] @@ -28,6 +29,9 @@ enum Commands { /// Output documentation for Trident's CLI TridentCli(TridentCliOpts), + + /// Output a Trident arch diagram + TridentArch(TridentArchOpts), } #[derive(Args, Debug)] @@ -48,12 +52,31 @@ struct TridentCliOpts { output: Option, } -#[derive(Parser, Debug)] +#[derive(Args, Debug)] struct HostConfigCli { #[clap(subcommand)] command: HostConfigCommands, } +#[derive(Args, Debug)] +struct TridentArchOpts { + /// Optional output file + /// + /// If not specified, will print to stdout. + #[clap(short, long)] + output: Option, + + /// Arch diagram to output + selected: TridentArchSelection, +} + +#[derive(Debug, ValueEnum, Clone, Copy)] +#[clap(rename_all = "kebab-case")] +enum TridentArchSelection { + Install, + Update, +} + #[derive(Subcommand, Debug)] enum HostConfigCommands { /// Build markdown docs for Host Configuration @@ -145,6 +168,9 @@ fn main() -> Result<(), Error> { Commands::TridentCli(opts) => { build_tricent_cli_docs(opts).context("Failed to build CLI docs") } + Commands::TridentArch(opts) => { + build_trident_arch_diagram(opts).context("Failed to build arch diagram") + } } } @@ -208,3 +234,25 @@ fn build_tricent_cli_docs(opts: TridentCliOpts) -> Result<(), Error> { Ok(()) } + +fn build_trident_arch_diagram(opts: TridentArchOpts) -> Result<(), Error> { + info!("Building trident arch diagram"); + + let diagram = trident_arch::build_arch_diagram(opts.selected) + .context("Failed to build trident arch diagram")?; + + if let Some(output) = opts.output { + let parent = output.parent().context("Failed to get parent directory")?; + std::fs::create_dir_all(parent).context(format!( + "Failed to create parent directory {}", + parent.display() + ))?; + + std::fs::write(&output, diagram) + .context(format!("Failed to write to file {}", output.display()))?; + } else { + println!("{}", diagram); + } + + Ok(()) +} diff --git a/docbuilder/src/trident_arch/diagrams/install.yaml b/docbuilder/src/trident_arch/diagrams/install.yaml new file mode 100644 index 000000000..43ca0c7ee --- /dev/null +++ b/docbuilder/src/trident_arch/diagrams/install.yaml @@ -0,0 +1,135 @@ +legends: + verb: + friendly: Trident Invocation Verb + background: "#b03e00" + border: "#5a1f00" + operation: + friendly: Operation + background: "#023c57" + border: "#042433" + subsystem: + friendly: Subsystem + background: "#4EA72E" + border: "#1C440D" + step: + friendly: Step + background: "#A02B93" + border: "#410C3B" + hook: + friendly: Script Hook + background: "#FF5757" + border: "#C00000" + system: + friendly: System Action + background: "#9c2403" + border: "#4d1101" + storage: + friendly: Storage Configuration + background: "#01523c" + border: "#00241a" + mount: + friendly: Mount + background: "#324a02" + border: "#1b2901" +root: + - name: install + legend: verb + children: + - name: Static Validation + comment: "Context-free validation of Host Config" + - name: Safety Check + - name: Stage + legend: operation + children: + - name: Load COSI + - name: "Pre-servicing Script Hook" + legend: hook + - name: "Dynamic Validation" + legend: step + - name: Prepare + legend: step + children: + - name: MOS Config + legend: subsystem + comment: "Changes to current OS" + - name: Hooks + legend: subsystem + comment: Stage addtl. files & scripts + - name: Block Device Creation + legend: storage + children: + - name: Close pre-existing devices + children: + - name: Close verity + - name: Close Encrypted Volumes + - name: Close RAID + - name: Create partitions + - name: Create RAID + - name: Create Encrypted Volumes + - name: Block Device Initialization + legend: storage + children: + - name: Deploy Images + - name: Create Filesystems + - name: Create swap + - name: Open dm-verity devices + - name: New OS Mount + legend: mount + children: + - name: Provision + legend: step + children: + - name: Boot + legend: subsystem + comment: ESP Deployment + - name: Hooks + legend: subsystem + children: + - name: "Post-provision Script Hook" + legend: hook + - name: | + Configure + (chroot) + legend: step + children: + - name: Storage + legend: subsystem + comment: "Regenerate fstab, crypttab, mdadm.conf" + - name: Boot + legend: subsystem + comment: "Update grub config when in use" + - name: Network + legend: subsystem + comment: "Write network config" + - name: OS Config + legend: subsystem + comment: Enact OS config changes + - name: Hooks + legend: subsystem + children: + - name: Post-configure Script Hook + legend: hook + - name: Initrd + legend: subsystem + comment: "Regenerate initrd when not UKI" + - name: SELinux + legend: subsystem + comment: "Relabel filesystems when enabled" + - name: "Finalize" + legend: operation + children: + - name: "Boot Entry Configuration" + children: + - name: Insert/Update Boot Entry + comment: "Label depends on active volume" + - name: "Set Boot Order" + comment: "New entry is first" + - name: "Set NextBoot" + - name: Trigger Reboot + - name: "" + legend: system + - name: "commit" + legend: verb + children: + - name: "Success/Rollback Detection" + comment: "Update is finalized on success" diff --git a/docbuilder/src/trident_arch/mod.rs b/docbuilder/src/trident_arch/mod.rs new file mode 100644 index 000000000..f36a6ca29 --- /dev/null +++ b/docbuilder/src/trident_arch/mod.rs @@ -0,0 +1,37 @@ +use std::path::PathBuf; + +use anyhow::{Context, Error, Ok}; + +use crate::TridentArchSelection; + +mod nodes; +mod render; + +use nodes::Diagram; + +fn get_diagram_base(selected: TridentArchSelection) -> Result { + let file = match selected { + TridentArchSelection::Install => "install.yaml", + TridentArchSelection::Update => "update.yaml", + }; + + let full_path = PathBuf::from(file!()) + .parent() + .context("Failed to get parent directory")? + .join("diagrams") + .join(file); + + let yaml = std::fs::read_to_string(&full_path) + .with_context(|| format!("Failed to read diagram file: {:?}", full_path))?; + + serde_yaml::from_str(&yaml) + .with_context(|| format!("Failed to parse YAML for diagram '{:?}'", selected)) +} + +pub(super) fn build_arch_diagram(selected: TridentArchSelection) -> Result { + let diag = get_diagram_base(selected).context("Failed to get diagram base")?; + + let svg = render::render(diag).context("Failed to render diagram")?; + + Ok(svg.to_string()) +} diff --git a/docbuilder/src/trident_arch/nodes.rs b/docbuilder/src/trident_arch/nodes.rs new file mode 100644 index 000000000..4be450560 --- /dev/null +++ b/docbuilder/src/trident_arch/nodes.rs @@ -0,0 +1,75 @@ +use serde::{de::Visitor, Deserialize, Deserializer}; + +#[derive(Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +pub(super) struct Diagram { + #[serde(default, deserialize_with = "deserialize_legends")] + pub legends: Vec, + + pub root: Vec, +} + +#[derive(Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +pub(super) struct Legend { + #[serde(skip)] + pub id: String, + + #[serde(default)] + pub friendly: Option, + + #[serde(default)] + pub background: Option, + + #[serde(default)] + pub border: Option, + + #[serde(default)] + pub text: Option, +} + +#[derive(Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +pub(super) struct DiagramNode { + pub name: String, + + #[serde(default)] + pub children: Vec, + + #[serde(default)] + pub comment: Option, + + #[serde(default)] + pub legend: Option, +} + +fn deserialize_legends<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + struct LegendMapVisitor; + impl<'de> Visitor<'de> for LegendMapVisitor { + type Value = Vec; + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + formatter.write_str("a map of legends") + } + + fn visit_map(self, mut map: V) -> Result + where + V: serde::de::MapAccess<'de>, + { + let mut legends = Vec::new(); + while let Some((key, value)) = map.next_entry::()? { + let mut legend = value; + legend.id = key; + legends.push(legend); + } + Ok(legends) + } + } + + deserializer + .deserialize_map(LegendMapVisitor) + .map_err(serde::de::Error::custom) +} diff --git a/docbuilder/src/trident_arch/render.rs b/docbuilder/src/trident_arch/render.rs new file mode 100644 index 000000000..900817964 --- /dev/null +++ b/docbuilder/src/trident_arch/render.rs @@ -0,0 +1,288 @@ +use std::{cmp::max, collections::HashMap}; + +use anyhow::{bail, Context, Error}; +use svg::{ + node::element::{Group, Path, Rectangle, Style, TSpan, Text}, + Document, Node, +}; +use textwrap::Options; + +use super::{ + nodes::{DiagramNode, Legend}, + Diagram, +}; + +const MARGIN: u32 = 10; +const AXIS_WIDTH: u32 = 30; +const DEFAULT_FILL: &str = "#008492"; +const DEFAULT_STROKE: &str = "#003F46"; +const DEFAULT_TEXT: &str = "#FFFFFF"; + +struct NodeGroup { + group: Group, + height: u32, + width: u32, +} + +type Legends<'a> = HashMap<&'a str, &'a Legend>; + +pub(super) fn render(diagram: Diagram) -> Result { + let legends = generate_legends(&diagram); + let axis_legend = time_axis().translated(legends.width as i32, 0); + + let annotations = Group::new() + .add(axis_legend) + .add(legends.group.translated(MARGIN as i32, MARGIN as i32)); + + let legend_map = diagram + .legends + .iter() + .map(|legend| (legend.id.as_str(), legend)) + .collect::(); + + let root_node = render_children(&legend_map, diagram.root.iter())? + .translated((MARGIN + legends.width + AXIS_WIDTH) as i32, MARGIN as i32); + + let width = MARGIN + legends.width + AXIS_WIDTH + root_node.width + MARGIN; + let height = root_node.height + 2 * MARGIN; + + Ok(Document::new() + .set("width", width) + .set("height", height) + .set("font-family", "Aptos,Aptos_MSFontService,sans-serif") + .set("viewBox", (0, 0, width, height)) + .add(Style::new(".caption { fill: white; }")) + .add( + Rectangle::new() + .set("x", 0) + .set("y", 0) + .set("width", "100%") + .set("height", "100%") + .set("fill", "white") + .set("stroke", "#000000") + .set("stroke-width", 2), + ) + .add(annotations) + .add(root_node.group)) +} + +fn render_children<'a>( + legends: &Legends, + children: impl Iterator, +) -> Result { + let mut children_group = Group::new(); + let mut y_pos: u32 = 0; + let mut child_width: u32 = 0; + for child in children { + if has_children(&children_group) { + y_pos += MARGIN; // Add some space between nodes + } + + let child_node = render_node(legends, child)?.translated(0, y_pos as i32); + child_width = max(child_width, child_node.width); + + y_pos += child_node.height; + children_group.append(child_node.group); + } + + Ok(NodeGroup { + group: children_group, + height: y_pos, + width: child_width, + }) +} + +fn render_node(legends: &Legends, node: &DiagramNode) -> Result { + if node.comment.is_some() && !node.children.is_empty() { + bail!( + "Only leaf nodes can have comments, but {} has both", + node.name + ); + } + + let box_width = 100; + + let children = + render_children(legends, node.children.iter())?.translated((box_width + MARGIN) as i32, 0); + + let wrapped_name = textwrap::wrap(&node.name, Options::new(20)); + let text_height = wrapped_name.len() as u32 * 10; // Approximate height of text + + let height = max(children.height, text_height + 10); + + let mut self_box = Rectangle::new() + .set("x", 0) + .set("y", 0) + .set("width", box_width) + .set("height", height); + + let mut self_text = Text::new("") + .set("x", box_width / 2) + .set("y", height / 2 - (wrapped_name.len() - 1) as u32 * 5) + .set("text-anchor", "middle") + .set("dominant-baseline", "middle") + .set("class", "caption"); + + apply_legend(legends, node, &mut self_box, &mut self_text) + .with_context(|| format!("Failed to apply legend for node {}", node.name))?; + + for (i, line) in wrapped_name.iter().enumerate() { + let mut span = TSpan::new(line.to_string()) + .set("x", box_width / 2) + .set("text-anchor", "middle") + .set("dominant-baseline", "middle") + .set("font-size", "10px"); + if i > 0 { + span.assign("dy", "12px"); + } + self_text = self_text.add(span); + } + + let mut width = box_width; + + let mut self_group = Group::new().add(self_box).add(self_text); + + // If we have children, add a margin and translate the group + // to the right of the box with. + if has_children(&children.group) { + width += MARGIN + children.width; + self_group.append(children.group); + } + + if let Some(comment) = &node.comment { + width += MARGIN; + let comment_text = Text::new(comment.clone()) + .set("x", width) + .set("y", height / 2) + .set("text-anchor", "start") + .set("dominant-baseline", "middle") + .set("font-size", "7px"); + self_group = self_group.add(comment_text); + width += comment.len() as u32 * 10 / 3; // Approximate width of comment + } + + Ok(NodeGroup { + group: self_group, + height, + width, + }) +} + +/// Trait to add transformations to SVG elements. +trait Transform { + fn translated(self, x: i32, y: i32) -> Self; +} + +impl Transform for Group { + fn translated(self, x: i32, y: i32) -> Self { + self.set("transform", format!("translate({}, {})", x, y)) + } +} + +impl Transform for NodeGroup { + fn translated(self, x: i32, y: i32) -> Self { + NodeGroup { + group: self.group.translated(x, y), + height: self.height, + width: self.width, + } + } +} + +fn generate_legends(diagram: &Diagram) -> NodeGroup { + let mut legend_width = 0; + let mut legend_y = 0; + let mut legend_group = Group::new(); + let square_size = 10; + let text_start = square_size + 2; + for legend in &diagram.legends { + let rect = Rectangle::new() + .set("x", 0) + .set("y", 0) + .set("width", square_size) + .set("height", square_size) + .set("stroke-width", 2) + .set("fill", legend.background.as_deref().unwrap_or(DEFAULT_FILL)) + .set("stroke", legend.border.as_deref().unwrap_or(DEFAULT_STROKE)); + + let display_text = legend.friendly.as_ref().unwrap_or(&legend.id); + + let text = Text::new(display_text) + .set("x", text_start) + .set("y", square_size / 2) + .set("text-anchor", "start") + .set("dominant-baseline", "middle") + .set("font-size", "10px"); + + legend_group.append( + Group::new() + .add(rect) + .add(text) + .translated(0, legend_y as i32), + ); + + legend_y += square_size + MARGIN; + legend_width = max( + legend_width, + text_start + display_text.len() as u32 * 12 / 3 + text_start + MARGIN, + ); + } + + NodeGroup { + group: legend_group, + height: legend_y, + width: legend_width, + } +} + +fn time_axis() -> Group { + Group::new() + .add( + Path::new().set("d", "M1641.94 1436.5 1641.94 1838.22 1635.06 1838.22 1635.06 1436.5ZM1652.25 1833.63 1638.5 1861.13 1624.75 1833.63Z") + .set("fill", "#7F7F7F") + .set("transform", "translate(-145,-135) scale(0.1)"), + ) + .add( + Text::new("Time") + .set("fill", "#7F7F7F") + .set("font-size", "10px") + .set("text-anchor", "start") + .set("transform", "translate(15,30) rotate(-90)") + ) +} + +fn apply_legend( + legends: &Legends, + node: &DiagramNode, + rect: &mut Rectangle, + text: &mut Text, +) -> Result<(), Error> { + let legend = node + .legend + .as_ref() + .map(|id| { + legends.get(id.as_str()).with_context(|| { + format!("Legend with ID '{}' not found for node {}", id, node.name) + }) + }) + .transpose()?; + + if let Some(legend) = legend { + rect.assign("fill", legend.background.as_deref().unwrap_or(DEFAULT_FILL)); + rect.assign("stroke", legend.border.as_deref().unwrap_or(DEFAULT_STROKE)); + text.assign("fill", legend.text.as_deref().unwrap_or(DEFAULT_TEXT)); + } else { + rect.assign("fill", DEFAULT_FILL); + rect.assign("stroke", DEFAULT_STROKE); + text.assign("fill", DEFAULT_TEXT); + } + + Ok(()) +} + +fn has_children(node: &impl Node) -> bool { + match node.get_children() { + Some(children) => !children.is_empty(), + None => false, + } +} diff --git a/docs/Explanation/Install-Flow.md b/docs/Explanation/Install-Flow.md new file mode 100644 index 000000000..fb8cf00b5 --- /dev/null +++ b/docs/Explanation/Install-Flow.md @@ -0,0 +1,3 @@ +# Install Flow + +![Install Flow](../resources/trident-install.svg) diff --git a/docs/Reference/Host-Configuration/API-Reference/.order b/docs/Reference/Host-Configuration/API-Reference/.order index 473873a10..348f9aca1 100644 --- a/docs/Reference/Host-Configuration/API-Reference/.order +++ b/docs/Reference/Host-Configuration/API-Reference/.order @@ -34,7 +34,7 @@ ServicingTypeSelection SoftwareRaidArray SshMode Storage -SwapDevice +Swap Trident User VerityCorruptionOption diff --git a/docs/Reference/Host-Configuration/API-Reference/Storage.md b/docs/Reference/Host-Configuration/API-Reference/Storage.md index e037e7c3c..0e4cc4a44 100644 --- a/docs/Reference/Host-Configuration/API-Reference/Storage.md +++ b/docs/Reference/Host-Configuration/API-Reference/Storage.md @@ -77,12 +77,12 @@ Swap device configuration. - Items of the array must have the type: - | Characteristic | Value | - | ---------------- | ----------------------------- | - | Type | `SwapDevice` | - | Link | [SwapDevice](./SwapDevice.md) | - | Shorthand Type | `string` | - | Shorthand Format | `Block Device ID` | + | Characteristic | Value | + | ---------------- | ----------------- | + | Type | `Swap` | + | Link | [Swap](./Swap.md) | + | Shorthand Type | `string` | + | Shorthand Format | `Block Device ID` | ### `verity` (optional) diff --git a/docs/Reference/Host-Configuration/API-Reference/SwapDevice.md b/docs/Reference/Host-Configuration/API-Reference/Swap.md similarity index 85% rename from docs/Reference/Host-Configuration/API-Reference/SwapDevice.md rename to docs/Reference/Host-Configuration/API-Reference/Swap.md index b5238c306..1cda0cdb5 100644 --- a/docs/Reference/Host-Configuration/API-Reference/SwapDevice.md +++ b/docs/Reference/Host-Configuration/API-Reference/Swap.md @@ -1,6 +1,6 @@ -# SwapDevice +# Swap | Characteristic | Value | | -------------- | -------- | @@ -10,7 +10,7 @@ ### `deviceId` **(required)** -The ID of the block device to use for this swap device. +The ID of the block device to use for this swap area. | Characteristic | Value | | -------------- | ----------------- | diff --git a/docs/Reference/Host-Configuration/Storage-Rules.md b/docs/Reference/Host-Configuration/Storage-Rules.md index d32dfc086..aef8ab09f 100644 --- a/docs/Reference/Host-Configuration/Storage-Rules.md +++ b/docs/Reference/Host-Configuration/Storage-Rules.md @@ -36,7 +36,6 @@ configuration, along with their descriptions. | ab-volume | An A/B volume | | encrypted-volume | An encrypted volume | | verity-device | A verity device | -| swap-device | A swap partition | ## Referrer Description @@ -49,7 +48,7 @@ configuration, along with their descriptions. | ab-volume | An A/B volume | | encrypted-volume | An encrypted volume | | verity-device | A verity device | -| swap-device | A swap device | +| swap-device | A swap mount | | filesystem-new | A new filesystem | | filesystem-image | A filesystem from an image | | filesystem-esp | An ESP/EFI filesystem | @@ -65,17 +64,17 @@ that can be referenced. A single cell in the table represents whether a referrer of a certain type can reference a block device of a certain type. -| Referrer ╲ Device | disk | partition | adopted-partition | raid-array | ab-volume | encrypted-volume | verity-device | swap-device | -| ------------------- | ---- | --------- | ----------------- | ---------- | --------- | ---------------- | ------------- | ----------- | -| raid-array | No | Yes | No | No | No | No | No | No | -| ab-volume | No | Yes | No | Yes | No | Yes | No | No | -| encrypted-volume | No | Yes | No | Yes | No | No | No | No | -| verity-device | No | Yes | No | Yes | Yes | No | No | No | -| swap-device | No | Yes | No | No | No | Yes | No | No | -| filesystem-new | No | Yes | No | Yes | Yes | Yes | No | No | -| filesystem-image | No | Yes | No | Yes | Yes | Yes | Yes | No | -| filesystem-esp | No | Yes | Yes | Yes | No | No | No | No | -| filesystem-adopted | No | No | Yes | No | No | No | No | No | +| Referrer ╲ Device | disk | partition | adopted-partition | raid-array | ab-volume | encrypted-volume | verity-device | +| ------------------- | ---- | --------- | ----------------- | ---------- | --------- | ---------------- | ------------- | +| raid-array | No | Yes | No | No | No | No | No | +| ab-volume | No | Yes | No | Yes | No | Yes | No | +| encrypted-volume | No | Yes | No | Yes | No | No | No | +| verity-device | No | Yes | No | Yes | Yes | No | No | +| swap-device | No | Yes | No | No | No | Yes | No | +| filesystem-new | No | Yes | No | Yes | Yes | Yes | No | +| filesystem-image | No | Yes | No | Yes | Yes | Yes | Yes | +| filesystem-esp | No | Yes | Yes | Yes | No | No | No | +| filesystem-adopted | No | No | Yes | No | No | No | No | ## Reference Count diff --git a/docs/Trident.md b/docs/Trident.md index fbba9b2b3..7c412ec19 100644 --- a/docs/Trident.md +++ b/docs/Trident.md @@ -32,3 +32,78 @@ preview: - UKI - Encryption with PCR sealing --> + +# Trident + +Trident is a tool for managing the lifecycle of Azure Linux systems. + +## Feature Matrix + +Legend: + +- ✅: Fully supported. +- â˜‘ī¸: In preview or partially supported. +- 🔜: Planned feature. Not implemented yet. +- âš ī¸: Refer to relevant notes for details. +- ❌: Not supported. + +### Servicing Features + +| Category | Feature | Install | VM-Init | Update | +| --------------- | --------------------------------------- | ------- | ------- | ------ | +| 🚀 Runtime | Native binary | ✅ | ✅ | ✅ | +| 🚀 Runtime | Containerized | ✅ | ❌ | ✅ | +| âš™ī¸ Bootloader | UEFI [1] | ✅ | ✅ | ✅ | +| âš™ī¸ Bootloader | GPT partitioning | ✅ | ✅ | ✅ | +| âš™ī¸ Bootloader | Grub2 | ✅ | ✅ | ✅ | +| âš™ī¸ Bootloader | Systemd-boot | â˜‘ī¸ | â˜‘ī¸ | â˜‘ī¸ | +| 🔄 Lifecycle | Onboard system for updates | ✅ | ✅ | ✅ | +| 🔄 Lifecycle | Rollback (grub) | ✅ | ✅ | ✅ | +| 🔄 Lifecycle | Rollback (systemd-boot/UKI) | 🔜 | 🔜 | 🔜 | +| 🔏 Integrity | Secure boot | ✅ | ✅ | ✅ | +| 🔏 Integrity | UKI | â˜‘ī¸ | â˜‘ī¸ | â˜‘ī¸ | +| 🔏 Integrity | Root verity (grub) | âš ī¸[2] | âš ī¸[2] | âš ī¸[2] | +| 🔏 Integrity | Root verity (UKI) | â˜‘ī¸ | â˜‘ī¸ | â˜‘ī¸ | +| 🔏 Integrity | User verity (UKI) | â˜‘ī¸ | â˜‘ī¸ | â˜‘ī¸ | +| đŸ’Ŋ Storage | Block device creation | ✅ | 🔜 | ❌ | +| đŸ’Ŋ Storage | Image streaming (local) | ✅ | 🔜 | ✅ | +| đŸ’Ŋ Storage | Image streaming (HTTPS) | ✅ | 🔜 | ✅ | +| đŸ’Ŋ Storage | Multiboot | â˜‘ī¸ | ❌ | ✅[3] | +| đŸ’Ŋ Storage | Partition adoption | â˜‘ī¸ | ❌ | ✅[3] | +| đŸ’Ŋ Storage | Software RAID | ✅ | ❌ | ✅[3] | +| đŸ’Ŋ Storage | ESP redundancy | ✅ | ❌ | ✅[3] | +| đŸ’Ŋ Storage | Encryption with secure boot PCR sealing | ✅ | 🔜 | ✅[3] | +| đŸ’Ŋ Storage | Encryption with OS PCR sealing | 🔜[4] | 🔜 | ✅[3] | +| 📝 OS Config | Network configuration | ✅ | ❌ | ✅ | +| 📝 OS Config | Hostname configuration | ✅[5] | ❌ | ✅[5] | +| 📝 OS Config | User configuration | ✅[5] | ❌ | ✅[5] | +| 📝 OS Config | SSH configuration | ✅[5] | ❌ | ✅[5] | +| 📝 OS Config | Initrd regeneration (grub) | ✅ | ❌ | ✅ | +| 📝 OS Config | Initrd regeneration (UKI) | ❌ | ❌ | ❌ | +| đŸ›Ąī¸ Security | SELinux Configuration | ✅ | ❌ | ✅ | +| đŸĒ› Customization | User provided-scripts | ✅ | ❌ | ✅ | +| đŸ› ī¸ Development | Offline validation | ✅ | 🔜 | 🔜 | +| đŸ› ī¸ Development | Debugging log | ✅ | ✅ | ✅ | + +_Notes:_ + +- [1] Trident exclusively supports UEFI booting. BIOS booting is not supported. +- [2] Root verity is supported with grub, but support for this feature + will be deprecated soon. +- [3] A system installed with these features can be updated, but the features + themselves cannot be activated during an update. +- [4] Currently, only PCR 7 is supported. Sealing against other PCRs is + planned for a future release. +- [5] These feature cannot be used in conjunction with root verity. + +### Out-of-Band Features + +These are features that exist outside of the normal servicing flows in Trident. + +| Category | Feature | Status | Notes | +| --------- | ------------ | ------ | ----------------------------------------------------------------- | +| đŸ’Ŋ Storage | RAID Rebuild | ✅ | Rebuild a software RAID array after a physical drive replacement. | + +## Subpages + +[[_TOSP_]] \ No newline at end of file diff --git a/docs/resources/trident-install.svg b/docs/resources/trident-install.svg new file mode 100644 index 000000000..3e5339dd5 --- /dev/null +++ b/docs/resources/trident-install.svg @@ -0,0 +1,469 @@ + + + + + + + +Time + + + + + + +Trident Invocation Verb + + + + + +Operation + + + + + +Subsystem + + + + + +Step + + + + + +Script Hook + + + + + +System Action + + + + + +Storage Configuration + + + + + +Mount + + + + + + + + + +install + + + + + + +Static Validation + + +Context-free validation of Host Config + + + + + + +Safety Check + + + + + + +Stage + + + + + + +Load COSI + + + + + + +Pre-servicing Script +Hook + + + + + + +Dynamic Validation + + + + + + +Prepare + + + + + + +MOS Config + + +Changes to current OS + + + + + + +Hooks + + +Stage addtl. files & scripts + + + + + + + + +Block Device +Creation + + + + + + +Close pre-existing +devices + + + + + + +Close verity + + + + + + +Close Encrypted +Volumes + + + + + + +Close RAID + + + + + + + + +Create partitions + + + + + + +Create RAID + + + + + + +Create Encrypted +Volumes + + + + + + + + +Block Device +Initialization + + + + + + +Deploy Images + + + + + + +Create Filesystems + + + + + + +Create swap + + + + + + +Open dm-verity +devices + + + + + + + + +New OS Mount + + + + + + +Provision + + + + + + +Boot + + +ESP Deployment + + + + + + +Hooks + + + + + + +Post-provision +Script Hook + + + + + + + + + + +Configure +(chroot) + + + + + + + +Storage + + +Regenerate fstab, crypttab, mdadm.conf + + + + + + +Boot + + +Update grub config when in use + + + + + + +Network + + +Write network config + + + + + + +OS Config + + +Enact OS config changes + + + + + + +Hooks + + + + + + +Post-configure +Script Hook + + + + + + + + +Initrd + + +Regenerate initrd when not UKI + + + + + + +SELinux + + +Relabel filesystems when enabled + + + + + + + + + + + + +Finalize + + + + + + +Boot Entry +Configuration + + + + + + +Insert/Update Boot +Entry + + +Label depends on active volume + + + + + + +Set Boot Order + + +New entry is first + + + + + + +Set NextBoot + + + + + + + + +Trigger Reboot + + + + + + + + + + +<System Reboot> + + + + + + +commit + + + + + + +Success/Rollback +Detection + + +Update is finalized on success + + + + + + diff --git a/e2e_tests/encryption_test.py b/e2e_tests/encryption_test.py index a32f3987e..b846fc0a4 100644 --- a/e2e_tests/encryption_test.py +++ b/e2e_tests/encryption_test.py @@ -442,9 +442,19 @@ def check_crypsetup_luks_dump(conn: fabric.Connection, cryptDevPath: str) -> Non } } """ + # Running this command requires additional SELinux permission for lvm_t, so temporarily switch to Permissive mode + # Missing permission: allow lvm_t initrc_runtime_t:dir { read } + enforcing = sudo(conn, "getenforce").strip() == "Enforcing" + if enforcing: + sudo(conn, "setenforce 0") + stdout = sudo(conn, f"cryptsetup luksDump --dump-json-metadata {cryptDevPath}") dump = json.loads(stdout) + # Revert to Enforcing mode + if enforcing: + sudo(conn, "setenforce 1") + actual = dump["digests"]["0"]["type"] expected = "pbkdf2" assert ( @@ -470,10 +480,10 @@ def check_crypsetup_luks_dump(conn: fabric.Connection, cryptDevPath: str) -> Non ), f"Expected one TPM keyslot, got {len(dump['tokens'][0]['keyslots'])}" actual = dump["tokens"]["0"]["tpm2-pcrs"][0] - expectedInt = 7 + expectedInt = [0, 7] assert ( - actual == expectedInt - ), f"Expected TPM2 PCR to be {expected!r}, got {actual!r}" + actual in expectedInt + ), f"Expected TPM2 PCR to be in '{expectedInt}', got {actual}" assert ( len(dump["tokens"]["0"]["tpm2-pcrs"]) == 1 diff --git a/e2e_tests/helpers/read_target_configurations.py b/e2e_tests/helpers/read_target_configurations.py index c97b29953..1343760a9 100755 --- a/e2e_tests/helpers/read_target_configurations.py +++ b/e2e_tests/helpers/read_target_configurations.py @@ -1,10 +1,12 @@ import argparse +from pathlib import Path import sys import yaml +import json +import logging - -def format_matrix(configurations): - return {directory: {"configuration": directory} for directory in configurations} +logging.basicConfig(level=logging.INFO) +log = logging.getLogger("read_target_configurations") def main(): @@ -17,7 +19,7 @@ def main(): parser.add_argument( "-c", "--configurations", - type=str, + type=Path, required=True, help="File path to the YAML that contains the configurations for the E2E testing.", ) @@ -44,9 +46,22 @@ def main(): choices=["host", "container"], help="The runtime environment of Trident (e.g., host or container).", ) + parser.add_argument( + "--matrix-name", + type=str, + required=True, + help="Name of the ADO variable to write the matrix to.", + ) args = parser.parse_args() - with open(args.configurations, "r") as file: + log.info( + f"Reading target configurations from '{args.configurations}' for '{args.env}' " + f"with purpose '{args.purpose}' and runtime environment '{args.runtimeEnv}'." + ) + + configurations_file: Path = args.configurations.absolute() + + with open(configurations_file, "r") as file: target_configurations = yaml.safe_load(file) if args.env not in target_configurations: @@ -61,11 +76,16 @@ def main(): sys.exit( f"Build purpose {args.purpose} not found in {args.configurations} for {args.env} and {args.runtimeEnv}." ) - else: - matrix = format_matrix( - target_configurations[args.env][args.runtimeEnv][args.purpose] - ) - print(matrix) + + configurations = target_configurations[args.env][args.runtimeEnv][args.purpose] + + matrix = {name: {"configuration": name} for name in configurations} + + log.info(f"Matrix:\n{json.dumps(matrix, indent=4)}") + + print( + f"##vso[task.setvariable variable={args.matrix_name};isOutput=true]{json.dumps(matrix)}" + ) if __name__ == "__main__": diff --git a/e2e_tests/pytest.ini b/e2e_tests/pytest.ini index 41f0d1b9c..29ba7bea1 100644 --- a/e2e_tests/pytest.ini +++ b/e2e_tests/pytest.ini @@ -3,6 +3,7 @@ junit_suite_name = trident_e2e_tests markers = base: Tests designed to verify the fundamental operations of Trident. verity: Tests designed to verify the 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. diff --git a/e2e_tests/target-configurations.yaml b/e2e_tests/target-configurations.yaml index 14a21e8bc..bf35e8283 100644 --- a/e2e_tests/target-configurations.yaml +++ b/e2e_tests/target-configurations.yaml @@ -5,38 +5,41 @@ bareMetal: - combined - encrypted-partition - split + - usr-verity validation: - base - combined - - raid-big - - raid-resync-small - encrypted-partition - encrypted-raid - encrypted-swap - memory-constraint-combined - misc + - raid-big - raid-mirrored + - raid-resync-small - rerun + - root-verity - simple - split - - verity - - verity-raid + - usr-verity + - usr-verity-raid weekly: - base - combined - - raid-big - - raid-resync-small - encrypted-partition - encrypted-raid - encrypted-swap - memory-constraint-combined - misc + - raid-big - raid-mirrored + - raid-resync-small - rerun + - root-verity - simple - split - - verity - - verity-raid + - usr-verity + - usr-verity-raid container: daily: - base @@ -46,12 +49,13 @@ bareMetal: - encrypted-swap - misc - raid-mirrored - - raid-small - raid-resync-small + - raid-small - rerun + - root-verity - simple - - verity - - verity-raid + - usr-verity + - usr-verity-raid validation: - base - combined @@ -60,12 +64,13 @@ bareMetal: - encrypted-swap - misc - raid-mirrored - - raid-small - raid-resync-small + - raid-small - rerun + - root-verity - simple - - verity - - verity-raid + - usr-verity + - usr-verity-raid weekly: - base - combined @@ -74,12 +79,13 @@ bareMetal: - encrypted-swap - misc - raid-mirrored - - raid-small - raid-resync-small + - raid-small - rerun + - root-verity - simple - - verity - - verity-raid + - usr-verity + - usr-verity-raid virtualMachine: host: daily: @@ -89,15 +95,16 @@ virtualMachine: - encrypted-raid - encrypted-swap - memory-constraint-combined - - raid-mirrored - misc - - raid-small + - raid-mirrored - raid-resync-small + - raid-small - rerun + - root-verity - simple - split - - verity - - verity-raid + - usr-verity + - usr-verity-raid post_merge: - base - combined @@ -105,15 +112,16 @@ virtualMachine: - encrypted-raid - encrypted-swap - memory-constraint-combined - - raid-mirrored - misc - - raid-small + - raid-mirrored - raid-resync-small + - raid-small - rerun + - root-verity - simple - split - - verity - - verity-raid + - usr-verity + - usr-verity-raid pullrequest: - base - combined @@ -122,7 +130,8 @@ virtualMachine: - rerun - simple - split - - verity-raid + - usr-verity + - usr-verity-raid validation: - base - combined @@ -132,13 +141,14 @@ virtualMachine: - memory-constraint-combined - misc - raid-mirrored - - raid-small - raid-resync-small + - raid-small - rerun + - root-verity - simple - split - - verity - - verity-raid + - usr-verity + - usr-verity-raid weekly: - base - combined @@ -148,13 +158,14 @@ virtualMachine: - memory-constraint-combined - misc - raid-mirrored - - raid-small - raid-resync-small + - raid-small - rerun + - root-verity - simple - split - - verity - - verity-raid + - usr-verity + - usr-verity-raid container: daily: - base @@ -164,12 +175,13 @@ virtualMachine: - encrypted-swap - misc - raid-mirrored - - raid-small - raid-resync-small + - raid-small - rerun + - root-verity - simple - - verity - - verity-raid + - usr-verity + - usr-verity-raid post_merge: - base - combined @@ -178,12 +190,13 @@ virtualMachine: - encrypted-swap - misc - raid-mirrored - - raid-small - raid-resync-small + - raid-small - rerun + - root-verity - simple - - verity - - verity-raid + - usr-verity + - usr-verity-raid pullrequest: - base - combined @@ -194,7 +207,8 @@ virtualMachine: - raid-resync-small - rerun - simple - - verity-raid + - usr-verity + - usr-verity-raid validation: - base - combined @@ -203,12 +217,13 @@ virtualMachine: - encrypted-swap - misc - raid-mirrored - - raid-small - raid-resync-small + - raid-small - rerun + - root-verity - simple - - verity - - verity-raid + - usr-verity + - usr-verity-raid weekly: - base - combined @@ -217,9 +232,10 @@ virtualMachine: - encrypted-swap - misc - raid-mirrored - - raid-small - raid-resync-small + - raid-small - rerun + - root-verity - simple - - verity - - verity-raid + - usr-verity + - usr-verity-raid diff --git a/e2e_tests/trident_configurations/combined/test-selection.yaml b/e2e_tests/trident_configurations/combined/test-selection.yaml index a1d3d4ad0..bb3138711 100644 --- a/e2e_tests/trident_configurations/combined/test-selection.yaml +++ b/e2e_tests/trident_configurations/combined/test-selection.yaml @@ -1,4 +1,4 @@ compatible: - base - - verity + - usr_verity - encryption diff --git a/e2e_tests/trident_configurations/combined/trident-config.yaml b/e2e_tests/trident_configurations/combined/trident-config.yaml index 3e49d8f35..81c96b0e8 100644 --- a/e2e_tests/trident_configurations/combined/trident-config.yaml +++ b/e2e_tests/trident_configurations/combined/trident-config.yaml @@ -1,5 +1,8 @@ +internalParams: + uki: true + overrideEncryptionPcrs: [0] image: - url: http://NETLAUNCH_HOST_ADDRESS/files/verity.cosi + url: http://NETLAUNCH_HOST_ADDRESS/files/usrverity.cosi sha384: ignored storage: disks: @@ -16,67 +19,44 @@ storage: - id: boot-b type: xbootldr size: 200M - - id: root-data-a-1 - type: root - size: 4G - - id: root-data-b-1 - type: root - size: 4G - - id: root-data-a-2 - type: root - size: 4G - - id: root-data-b-2 - type: root - size: 4G - - id: root-hash-a-1 - type: root-verity - size: 1G - - id: root-hash-b-1 - type: root-verity - size: 1G - - id: root-hash-a-2 - type: root-verity - size: 1G - - id: root-hash-b-2 - type: root-verity - size: 1G + - id: root-a-1 + size: 2G + - id: root-b-1 + size: 2G + - id: usr-data-a-1 + size: 2G + - id: usr-data-b-1 + size: 2G + - id: usr-hash-a-1 + size: 256M + - id: usr-hash-b-1 + size: 256M - id: web-a-e-1 - type: linux-generic - size: 1G - - id: web-a-e-2 - type: linux-generic size: 1G - id: web-b-e-1 - type: linux-generic size: 1G - - id: web-b-e-2 - type: linux-generic - size: 1G - - id: home - type: linux-generic - size: 100M - - id: trident-overlay-a-1 - type: linux-generic - size: 100M - - id: trident-overlay-b-1 - type: linux-generic - size: 100M - - id: trident-overlay-a-2 - type: linux-generic - size: 100M - - id: trident-overlay-b-2 - type: linux-generic - size: 100M - id: trident - type: linux-generic size: 500M - - id: var - type: linux-generic - size: 1G - id: disk2 device: /dev/disk/by-path/pci-0000:00:1f.2-ata-3 partitionTableType: gpt - partitions: [] + partitions: + - id: root-a-2 + size: 2G + - id: root-b-2 + size: 2G + - id: usr-data-a-2 + size: 2G + - id: usr-data-b-2 + size: 2G + - id: usr-hash-a-2 + size: 256M + - id: usr-hash-b-2 + size: 256M + - id: web-a-e-2 + size: 1G + - id: web-b-e-2 + size: 1G encryption: volumes: - id: web-a @@ -87,115 +67,100 @@ storage: deviceId: web-b-e raid: software: - - id: web-a-e - name: web-a-e + - id: root-a + name: root-a level: raid1 devices: - - web-a-e-1 - - web-a-e-2 - - id: web-b-e - name: web-b-e + - root-a-1 + - root-a-2 + - id: root-b + name: root-b level: raid1 devices: - - web-b-e-1 - - web-b-e-2 - - id: root-data-a - name: root-data-a + - root-b-1 + - root-b-2 + + - id: usr-data-a + name: usr-data-a level: raid1 devices: - - root-data-a-1 - - root-data-a-2 - - id: root-data-b - name: root-data-b + - usr-data-a-1 + - usr-data-a-2 + - id: usr-data-b + name: usr-data-b level: raid1 devices: - - root-data-b-1 - - root-data-b-2 - - id: root-hash-a - name: root-hash-a + - usr-data-b-1 + - usr-data-b-2 + - id: usr-hash-a + name: usr-hash-a level: raid1 devices: - - root-hash-a-1 - - root-hash-a-2 - - id: root-hash-b - name: root-hash-b + - usr-hash-a-1 + - usr-hash-a-2 + - id: usr-hash-b + name: usr-hash-b level: raid1 devices: - - root-hash-b-1 - - root-hash-b-2 - - id: trident-overlay-a - name: trident-overlay-a + - usr-hash-b-1 + - usr-hash-b-2 + + - id: web-a-e + name: web-a-e level: raid1 devices: - - trident-overlay-a-1 - - trident-overlay-a-2 - - id: trident-overlay-b - name: trident-overlay-b + - web-a-e-1 + - web-a-e-2 + - id: web-b-e + name: web-b-e level: raid1 devices: - - trident-overlay-b-1 - - trident-overlay-b-2 + - web-b-e-1 + - web-b-e-2 abUpdate: volumePairs: - id: boot volumeAId: boot-a volumeBId: boot-b - - id: root-data - volumeAId: root-data-a - volumeBId: root-data-b - - id: root-hash - volumeAId: root-hash-a - volumeBId: root-hash-b - - id: trident-overlay - volumeAId: trident-overlay-a - volumeBId: trident-overlay-b + - id: root + volumeAId: root-a + volumeBId: root-b + - id: usr-data + volumeAId: usr-data-a + volumeBId: usr-data-b + - id: usr-hash + volumeAId: usr-hash-a + volumeBId: usr-hash-b - id: web volumeAId: web-a volumeBId: web-b verity: - - id: root - name: root - dataDeviceId: root-data - hashDeviceId: root-hash + - id: usr + name: usr + dataDeviceId: usr-data + hashDeviceId: usr-hash filesystems: - - deviceId: web - source: new - mountPoint: - path: /web - options: defaults - - deviceId: home - source: new - mountPoint: /home - deviceId: esp mountPoint: path: /boot/efi options: umask=0077 - - deviceId: trident-overlay - source: new - mountPoint: /var/lib/trident-overlay - deviceId: boot mountPoint: /boot - - deviceId: trident - source: new - mountPoint: /var/lib/trident - - deviceId: var - mountPoint: /var - deviceId: root + mountPoint: / + - deviceId: usr mountPoint: - path: / + path: /usr options: defaults,ro -scripts: - postConfigure: - - name: overlay - runOn: - - clean-install - - ab-update - content: | - mkdir -p /var/lib/trident-overlay/etc-rw/upper - mkdir -p /var/lib/trident-overlay/etc-rw/work + - deviceId: web + source: new + mountPoint: /web + - deviceId: trident + source: new + mountPoint: /var/lib/trident os: selinux: - mode: permissive + mode: enforcing network: version: 2 ethernets: diff --git a/e2e_tests/trident_configurations/memory-constraint-combined/test-selection.yaml b/e2e_tests/trident_configurations/memory-constraint-combined/test-selection.yaml index a1d3d4ad0..bb3138711 100644 --- a/e2e_tests/trident_configurations/memory-constraint-combined/test-selection.yaml +++ b/e2e_tests/trident_configurations/memory-constraint-combined/test-selection.yaml @@ -1,4 +1,4 @@ compatible: - base - - verity + - usr_verity - encryption diff --git a/e2e_tests/trident_configurations/memory-constraint-combined/trident-config.yaml b/e2e_tests/trident_configurations/memory-constraint-combined/trident-config.yaml index cafb4c278..a1e980af4 100644 --- a/e2e_tests/trident_configurations/memory-constraint-combined/trident-config.yaml +++ b/e2e_tests/trident_configurations/memory-constraint-combined/trident-config.yaml @@ -1,5 +1,8 @@ +internalParams: + uki: true + overrideEncryptionPcrs: [0] image: - url: http://NETLAUNCH_HOST_ADDRESS/files/verity.cosi + url: http://NETLAUNCH_HOST_ADDRESS/files/usrverity.cosi sha384: ignored storage: disks: @@ -16,67 +19,44 @@ storage: - id: boot-b type: xbootldr size: 200M - - id: root-data-a-1 - type: root - size: 4G - - id: root-data-b-1 - type: root - size: 4G - - id: root-data-a-2 - type: root - size: 4G - - id: root-data-b-2 - type: root - size: 4G - - id: root-hash-a-1 - type: root-verity - size: 1G - - id: root-hash-b-1 - type: root-verity - size: 1G - - id: root-hash-a-2 - type: root-verity - size: 1G - - id: root-hash-b-2 - type: root-verity - size: 1G + - id: root-a-1 + size: 2G + - id: root-b-1 + size: 2G + - id: usr-data-a-1 + size: 2G + - id: usr-data-b-1 + size: 2G + - id: usr-hash-a-1 + size: 256M + - id: usr-hash-b-1 + size: 256M - id: web-a-e-1 - type: linux-generic - size: 1G - - id: web-a-e-2 - type: linux-generic size: 1G - id: web-b-e-1 - type: linux-generic size: 1G - - id: web-b-e-2 - type: linux-generic - size: 1G - - id: home - type: linux-generic - size: 100M - - id: trident-overlay-a-1 - type: linux-generic - size: 100M - - id: trident-overlay-b-1 - type: linux-generic - size: 100M - - id: trident-overlay-a-2 - type: linux-generic - size: 100M - - id: trident-overlay-b-2 - type: linux-generic - size: 100M - id: trident - type: linux-generic - size: 100M - - id: var - type: linux-generic - size: 1G + size: 500M - id: disk2 device: /dev/disk/by-path/pci-0000:00:1f.2-ata-3 partitionTableType: gpt - partitions: [] + partitions: + - id: root-a-2 + size: 2G + - id: root-b-2 + size: 2G + - id: usr-data-a-2 + size: 2G + - id: usr-data-b-2 + size: 2G + - id: usr-hash-a-2 + size: 256M + - id: usr-hash-b-2 + size: 256M + - id: web-a-e-2 + size: 1G + - id: web-b-e-2 + size: 1G encryption: volumes: - id: web-a @@ -87,128 +67,117 @@ storage: deviceId: web-b-e raid: software: - - id: web-a-e - name: web-a-e + - id: root-a + name: root-a level: raid1 devices: - - web-a-e-1 - - web-a-e-2 - - id: web-b-e - name: web-b-e + - root-a-1 + - root-a-2 + - id: root-b + name: root-b level: raid1 devices: - - web-b-e-1 - - web-b-e-2 - - id: root-data-a - name: root-data-a + - root-b-1 + - root-b-2 + + - id: usr-data-a + name: usr-data-a level: raid1 devices: - - root-data-a-1 - - root-data-a-2 - - id: root-data-b - name: root-data-b + - usr-data-a-1 + - usr-data-a-2 + - id: usr-data-b + name: usr-data-b level: raid1 devices: - - root-data-b-1 - - root-data-b-2 - - id: root-hash-a - name: root-hash-a + - usr-data-b-1 + - usr-data-b-2 + - id: usr-hash-a + name: usr-hash-a level: raid1 devices: - - root-hash-a-1 - - root-hash-a-2 - - id: root-hash-b - name: root-hash-b + - usr-hash-a-1 + - usr-hash-a-2 + - id: usr-hash-b + name: usr-hash-b level: raid1 devices: - - root-hash-b-1 - - root-hash-b-2 - - id: trident-overlay-a - name: trident-overlay-a + - usr-hash-b-1 + - usr-hash-b-2 + + - id: web-a-e + name: web-a-e level: raid1 devices: - - trident-overlay-a-1 - - trident-overlay-a-2 - - id: trident-overlay-b - name: trident-overlay-b + - web-a-e-1 + - web-a-e-2 + - id: web-b-e + name: web-b-e level: raid1 devices: - - trident-overlay-b-1 - - trident-overlay-b-2 + - web-b-e-1 + - web-b-e-2 abUpdate: volumePairs: - id: boot volumeAId: boot-a volumeBId: boot-b - - id: root-data - volumeAId: root-data-a - volumeBId: root-data-b - - id: root-hash - volumeAId: root-hash-a - volumeBId: root-hash-b - - id: trident-overlay - volumeAId: trident-overlay-a - volumeBId: trident-overlay-b + - id: root + volumeAId: root-a + volumeBId: root-b + - id: usr-data + volumeAId: usr-data-a + volumeBId: usr-data-b + - id: usr-hash + volumeAId: usr-hash-a + volumeBId: usr-hash-b - id: web volumeAId: web-a volumeBId: web-b verity: - - id: root - name: root - dataDeviceId: root-data - hashDeviceId: root-hash + - id: usr + name: usr + dataDeviceId: usr-data + hashDeviceId: usr-hash filesystems: - - deviceId: web - source: new - mountPoint: - path: /web - options: defaults - - deviceId: home - source: new - mountPoint: /home - deviceId: esp mountPoint: path: /boot/efi options: umask=0077 - - deviceId: trident-overlay - source: new - mountPoint: /var/lib/trident-overlay - deviceId: boot mountPoint: /boot - - deviceId: trident - source: new - mountPoint: /var/lib/trident - - deviceId: var - mountPoint: /var - deviceId: root + mountPoint: / + - deviceId: usr mountPoint: - path: / + path: /usr options: defaults,ro + - deviceId: web + source: new + mountPoint: /web + - deviceId: trident + source: new + mountPoint: /var/lib/trident scripts: preServicing: + # Script to restart trident with a memory limit. The last recorded peak on a clean install on June 2025 was: + # trident-install.service: Consumed 34.583s CPU time, 64.7M memory peak, 0B memory swap peak. - name: rerun-trident-with-memory-limit runOn: - clean-install content: | - systemctl status trident | grep Memory + set -eux + systemctl status trident-install.service | grep Memory if [ ! -f "/run/already-run" ]; then echo "Setting memory limit for trident-install.service" - systemctl set-property trident-install.service MemoryMax=64M + systemctl set-property trident-install.service MemoryMax=100M systemd-run --property=After=trident-install.service --no-block systemctl start trident-install.service touch "/run/already-run" exit 1 fi - postConfigure: - - name: overlay - runOn: - - clean-install - - ab-update - content: | - mkdir -p /var/lib/trident-overlay/etc-rw/upper - mkdir -p /var/lib/trident-overlay/etc-rw/work os: selinux: - mode: permissive + mode: enforcing network: version: 2 ethernets: diff --git a/e2e_tests/trident_configurations/misc/trident-config.yaml b/e2e_tests/trident_configurations/misc/trident-config.yaml index 5b0ffdef1..2a902e7fe 100644 --- a/e2e_tests/trident_configurations/misc/trident-config.yaml +++ b/e2e_tests/trident_configurations/misc/trident-config.yaml @@ -59,10 +59,7 @@ os: "option2": "value2" services: enable: - - sshd - cron - disable: - - sshd kernelCommandLine: extraCommandLine: - param1=value diff --git a/e2e_tests/trident_configurations/rerun/test-selection.yaml b/e2e_tests/trident_configurations/rerun/test-selection.yaml index a1d3d4ad0..bb3138711 100644 --- a/e2e_tests/trident_configurations/rerun/test-selection.yaml +++ b/e2e_tests/trident_configurations/rerun/test-selection.yaml @@ -1,4 +1,4 @@ compatible: - base - - verity + - usr_verity - encryption diff --git a/e2e_tests/trident_configurations/rerun/trident-config.yaml b/e2e_tests/trident_configurations/rerun/trident-config.yaml index d36e68214..917d01798 100644 --- a/e2e_tests/trident_configurations/rerun/trident-config.yaml +++ b/e2e_tests/trident_configurations/rerun/trident-config.yaml @@ -1,5 +1,8 @@ +internalParams: + uki: true + overrideEncryptionPcrs: [0] image: - url: http://NETLAUNCH_HOST_ADDRESS/files/verity.cosi + url: http://NETLAUNCH_HOST_ADDRESS/files/usrverity.cosi sha384: ignored storage: disks: @@ -11,122 +14,99 @@ storage: type: esp size: 1G - id: boot-a - type: xbootldr size: 200M - id: boot-b - type: xbootldr size: 200M - - id: root-data-a-1 - type: root + - id: root-a-1 size: 4G - - id: root-data-b-1 - type: root + - id: root-b-1 size: 4G - - id: root-data-a-2 - type: root - size: 4G - - id: root-data-b-2 - type: root - size: 4G - - id: root-hash-a-1 - type: root-verity - size: 1G - - id: root-hash-b-1 - type: root-verity - size: 1G - - id: root-hash-a-2 - type: root-verity + - id: usr-data-a-1 + size: 2G + - id: usr-data-b-1 + size: 2G + - id: usr-hash-a-1 size: 1G - - id: root-hash-b-2 - type: root-verity + - id: usr-hash-b-1 size: 1G - id: home-a-e-1 - type: linux-generic - size: 100M - - id: home-a-e-2 - type: linux-generic size: 100M - id: home-b-e-1 - type: linux-generic - size: 100M - - id: home-b-e-2 - type: linux-generic - size: 100M - - id: trident-overlay-a-1 - type: linux-generic - size: 100M - - id: trident-overlay-b-1 - type: linux-generic - size: 100M - - id: trident-overlay-a-2 - type: linux-generic - size: 100M - - id: trident-overlay-b-2 - type: linux-generic size: 100M - id: trident - type: linux-generic size: 500M - id: srv-e - type: linux-generic - size: 1G - - id: var - type: linux-generic size: 1G - id: disk2 device: /dev/disk/by-path/pci-0000:00:1f.2-ata-3 partitionTableType: gpt - partitions: [] + partitions: + - id: root-a-2 + size: 4G + - id: root-b-2 + size: 4G + - id: usr-data-a-2 + size: 2G + - id: usr-data-b-2 + size: 2G + - id: usr-hash-a-2 + size: 1G + - id: usr-hash-b-2 + size: 1G + - id: home-a-e-2 + size: 100M + - id: home-b-e-2 + size: 100M raid: software: - - id: home-a-e - name: home-a-e + - id: usr-data-a + name: usr-data-a level: raid1 devices: - - home-a-e-1 - - home-a-e-2 - - id: home-b-e - name: home-b-e + - usr-data-a-1 + - usr-data-a-2 + - id: usr-data-b + name: usr-data-b level: raid1 devices: - - home-b-e-1 - - home-b-e-2 - - id: root-data-a - name: root-data-a + - usr-data-b-1 + - usr-data-b-2 + - id: usr-hash-a + name: usr-hash-a level: raid1 devices: - - root-data-a-1 - - root-data-a-2 - - id: root-data-b - name: root-data-b + - usr-hash-a-1 + - usr-hash-a-2 + - id: usr-hash-b + name: usr-hash-b level: raid1 devices: - - root-data-b-1 - - root-data-b-2 - - id: root-hash-a - name: root-hash-a + - usr-hash-b-1 + - usr-hash-b-2 + - id: root-a + name: root-a level: raid1 devices: - - root-hash-a-1 - - root-hash-a-2 - - id: root-hash-b - name: root-hash-b + - root-a-1 + - root-a-2 + - id: root-b + name: root-b level: raid1 devices: - - root-hash-b-1 - - root-hash-b-2 - - id: trident-overlay-a - name: trident-overlay-a + - root-b-1 + - root-b-2 + - id: home-a-e + name: home-a-e level: raid1 devices: - - trident-overlay-a-1 - - trident-overlay-a-2 - - id: trident-overlay-b - name: trident-overlay-b + - home-a-e-1 + - home-a-e-2 + - id: home-b-e + name: home-b-e level: raid1 devices: - - trident-overlay-b-1 - - trident-overlay-b-2 + - home-b-e-1 + - home-b-e-2 encryption: volumes: - id: srv @@ -143,52 +123,45 @@ storage: - id: boot volumeAId: boot-a volumeBId: boot-b - - id: root-data - volumeAId: root-data-a - volumeBId: root-data-b - - id: root-hash - volumeAId: root-hash-a - volumeBId: root-hash-b - - id: trident-overlay - volumeAId: trident-overlay-a - volumeBId: trident-overlay-b + - id: usr-data + volumeAId: usr-data-a + volumeBId: usr-data-b + - id: usr-hash + volumeAId: usr-hash-a + volumeBId: usr-hash-b + - id: root + volumeAId: root-a + volumeBId: root-b - id: home volumeAId: home-a volumeBId: home-b verity: - - id: root - name: root - dataDeviceId: root-data - hashDeviceId: root-hash + - id: usr + name: usr + dataDeviceId: usr-data + hashDeviceId: usr-hash filesystems: - - deviceId: trident - source: new - mountPoint: - path: /var/lib/trident - options: defaults - - deviceId: srv - source: new - mountPoint: - path: /srv - options: defaults - - deviceId: var - mountPoint: /var - - deviceId: home - source: new - mountPoint: /home - deviceId: esp mountPoint: path: /boot/efi options: umask=0077 - deviceId: boot mountPoint: /boot - - deviceId: trident-overlay - source: new - mountPoint: /var/lib/trident-overlay - deviceId: root + mountPoint: / + - deviceId: usr mountPoint: - path: / + path: /usr options: defaults,ro + - deviceId: trident + source: new + mountPoint: /var/lib/trident + - deviceId: srv + source: new + mountPoint: /srv + - deviceId: home + source: new + mountPoint: /home scripts: postProvision: - name: rerun-trident @@ -213,16 +186,9 @@ scripts: trap "umount /tmp/var" EXIT echo fail-on-first-run exit 1 - - name: overlay - runOn: - - clean-install - - ab-update - content: | - mkdir -p /var/lib/trident-overlay/etc-rw/upper - mkdir -p /var/lib/trident-overlay/etc-rw/work os: selinux: - mode: permissive + mode: enforcing network: version: 2 ethernets: diff --git a/e2e_tests/trident_configurations/verity-raid/test-selection.yaml b/e2e_tests/trident_configurations/root-verity/test-selection.yaml similarity index 100% rename from e2e_tests/trident_configurations/verity-raid/test-selection.yaml rename to e2e_tests/trident_configurations/root-verity/test-selection.yaml diff --git a/e2e_tests/trident_configurations/verity/trident-config.yaml b/e2e_tests/trident_configurations/root-verity/trident-config.yaml similarity index 70% rename from e2e_tests/trident_configurations/verity/trident-config.yaml rename to e2e_tests/trident_configurations/root-verity/trident-config.yaml index 96a3d5842..4dc9ac23e 100644 --- a/e2e_tests/trident_configurations/verity/trident-config.yaml +++ b/e2e_tests/trident_configurations/root-verity/trident-config.yaml @@ -7,27 +7,39 @@ storage: device: /dev/disk/by-path/pci-0000:00:1f.2-ata-2 partitionTableType: gpt partitions: - - id: boot + - id: esp + type: esp + size: 1G + - id: boot-a type: xbootldr size: 200M - - id: root-data + - id: boot-b + type: xbootldr + size: 200M + - id: root-data-a + type: root + size: 4G + - id: root-data-b type: root - size: 8G - - id: root-hash + size: 4G + - id: root-hash-a type: root-verity size: 1G - - id: esp - type: esp + - id: root-hash-b + type: root-verity size: 1G - - id: trident + - id: home type: linux-generic - size: 500M - - id: trident-overlay + size: 100M + - id: trident-overlay-a type: linux-generic size: 100M - - id: home + - id: trident-overlay-b type: linux-generic size: 100M + - id: trident + type: linux-generic + size: 500M - id: var type: linux-generic size: 1G @@ -35,9 +47,33 @@ storage: device: /dev/disk/by-path/pci-0000:00:1f.2-ata-3 partitionTableType: gpt partitions: [] + abUpdate: + volumePairs: + - id: boot + volumeAId: boot-a + volumeBId: boot-b + - id: root-data + volumeAId: root-data-a + volumeBId: root-data-b + - id: root-hash + volumeAId: root-hash-a + volumeBId: root-hash-b + - id: trident-overlay + volumeAId: trident-overlay-a + volumeBId: trident-overlay-b + verity: + - id: root + name: root + dataDeviceId: root-data + hashDeviceId: root-hash filesystems: - - deviceId: var - mountPoint: /var + - deviceId: home + source: new + mountPoint: /home + - deviceId: esp + mountPoint: + path: /boot/efi + options: umask=0077 - deviceId: trident-overlay source: new mountPoint: /var/lib/trident-overlay @@ -46,22 +82,12 @@ storage: - deviceId: trident source: new mountPoint: /var/lib/trident - - deviceId: home - source: new - mountPoint: /home - - deviceId: esp - mountPoint: - path: /boot/efi - options: umask=0077 + - deviceId: var + mountPoint: /var - deviceId: root mountPoint: path: / options: defaults,ro - verity: - - id: root - name: root - dataDeviceId: root-data - hashDeviceId: root-hash scripts: postConfigure: - name: overlay diff --git a/e2e_tests/trident_configurations/verity/test-selection.yaml b/e2e_tests/trident_configurations/usr-verity-raid/test-selection.yaml similarity index 58% rename from e2e_tests/trident_configurations/verity/test-selection.yaml rename to e2e_tests/trident_configurations/usr-verity-raid/test-selection.yaml index e14974f13..9cdb5a029 100644 --- a/e2e_tests/trident_configurations/verity/test-selection.yaml +++ b/e2e_tests/trident_configurations/usr-verity-raid/test-selection.yaml @@ -1,3 +1,3 @@ compatible: - base - - verity + - usr_verity diff --git a/e2e_tests/trident_configurations/usr-verity-raid/trident-config.yaml b/e2e_tests/trident_configurations/usr-verity-raid/trident-config.yaml new file mode 100644 index 000000000..bc8493996 --- /dev/null +++ b/e2e_tests/trident_configurations/usr-verity-raid/trident-config.yaml @@ -0,0 +1,99 @@ +internalParams: + uki: true +image: + url: http://NETLAUNCH_HOST_ADDRESS/files/usrverity.cosi + sha384: ignored +storage: + disks: + - id: os + device: /dev/disk/by-path/pci-0000:00:1f.2-ata-2 + partitionTableType: gpt + partitions: + - id: esp + type: esp + size: 1G + - id: boot + size: 200M + - id: root-1 + size: 4G + - id: usr-data-1 + size: 2G + - id: usr-hash-1 + size: 1G + - id: trident-1 + size: 1G + - id: disk2 + device: /dev/disk/by-path/pci-0000:00:1f.2-ata-3 + partitionTableType: gpt + partitions: + - id: root-2 + size: 4G + - id: usr-data-2 + size: 2G + - id: usr-hash-2 + size: 1G + - id: trident-2 + size: 1G + raid: + software: + - id: root + name: root + level: raid1 + devices: + - root-1 + - root-2 + - id: usr-data + name: usr-data + level: raid1 + devices: + - usr-data-1 + - usr-data-2 + - id: usr-hash + name: usr-hash + level: raid1 + devices: + - usr-hash-1 + - usr-hash-2 + - id: trident + name: trident + level: raid1 + devices: + - trident-1 + - trident-2 + verity: + - id: usr + name: usr + dataDeviceId: usr-data + hashDeviceId: usr-hash + filesystems: + - deviceId: esp + mountPoint: + path: /boot/efi + options: umask=0077 + - deviceId: boot + mountPoint: /boot + - deviceId: root + mountPoint: / + - deviceId: usr + mountPoint: + path: /usr + options: defaults,ro + - deviceId: trident + source: new + mountPoint: /var/lib/trident +os: + selinux: + mode: enforcing + network: + version: 2 + ethernets: + vmeths: + match: + name: enp* + dhcp4: true + users: + - name: testing-user + sshPublicKeys: [] + secondaryGroups: + - wheel + sshMode: key-only diff --git a/e2e_tests/trident_configurations/usr-verity/test-selection.yaml b/e2e_tests/trident_configurations/usr-verity/test-selection.yaml new file mode 100644 index 000000000..9cdb5a029 --- /dev/null +++ b/e2e_tests/trident_configurations/usr-verity/test-selection.yaml @@ -0,0 +1,3 @@ +compatible: + - base + - usr_verity diff --git a/e2e_tests/trident_configurations/usr-verity/trident-config.yaml b/e2e_tests/trident_configurations/usr-verity/trident-config.yaml new file mode 100644 index 000000000..0ba061068 --- /dev/null +++ b/e2e_tests/trident_configurations/usr-verity/trident-config.yaml @@ -0,0 +1,96 @@ +internalParams: + uki: true +image: + url: http://NETLAUNCH_HOST_ADDRESS/files/usrverity.cosi + sha384: ignored +trident: + selfUpgrade: false +storage: + disks: + - id: os + device: /dev/disk/by-path/pci-0000:00:1f.2-ata-2 + partitionTableType: gpt + partitions: + - id: esp + size: 1G + type: esp + - id: boot-a + size: 250M + - id: boot-b + size: 250M + - id: root-a + size: 5G + type: root + - id: root-b + size: 5G + type: root + - id: usr-data-a + size: 5G + type: usr + - id: usr-data-b + size: 5G + type: usr + - id: usr-hash-a + size: 1G + type: usr-verity + - id: usr-hash-b + size: 1G + type: usr-verity + - id: trident + type: linux-generic + size: 1G + - id: disk2 + device: /dev/disk/by-path/pci-0000:00:1f.2-ata-3 + partitionTableType: gpt + partitions: [] + abUpdate: + volumePairs: + - id: boot + volumeAId: boot-a + volumeBId: boot-b + - id: root + volumeAId: root-a + volumeBId: root-b + - id: usr-data + volumeAId: usr-data-a + volumeBId: usr-data-b + - id: usr-hash + volumeAId: usr-hash-a + volumeBId: usr-hash-b + verity: + - id: usr + name: usr + dataDeviceId: usr-data + hashDeviceId: usr-hash + filesystems: + - deviceId: esp + mountPoint: + path: /boot/efi + options: umask=0077 + - deviceId: boot + mountPoint: /boot + - deviceId: root + mountPoint: / + - deviceId: usr + mountPoint: + path: /usr + options: ro + - deviceId: trident + source: new + mountPoint: /var/lib/trident +os: + selinux: + mode: enforcing + network: + version: 2 + ethernets: + vmeths: + match: + name: enp* + dhcp4: true + users: + - name: testing-user + sshPublicKeys: [] + secondaryGroups: + - wheel + sshMode: key-only diff --git a/e2e_tests/trident_configurations/verity-raid/trident-config.yaml b/e2e_tests/trident_configurations/verity-raid/trident-config.yaml deleted file mode 100644 index 92be42f2c..000000000 --- a/e2e_tests/trident_configurations/verity-raid/trident-config.yaml +++ /dev/null @@ -1,113 +0,0 @@ -image: - url: http://NETLAUNCH_HOST_ADDRESS/files/verity.cosi - sha384: ignored -storage: - disks: - - id: os - device: /dev/disk/by-path/pci-0000:00:1f.2-ata-2 - partitionTableType: gpt - partitions: - - id: esp - type: esp - size: 1G - - id: boot - size: 200M - - id: root-data-1 - size: 4G - - id: root-hash-1 - size: 1G - - id: trident - size: 500M - - id: trident-overlay-1 - size: 100M - - id: home - size: 100M - - id: var - size: 1G - - id: run - size: 1G - - id: disk2 - device: /dev/disk/by-path/pci-0000:00:1f.2-ata-3 - partitionTableType: gpt - partitions: - - id: root-data-2 - size: 4G - - id: root-hash-2 - size: 1G - - id: trident-overlay-2 - size: 100M - raid: - software: - - id: root-data - name: root-data - level: raid1 - devices: - - root-data-1 - - root-data-2 - - id: root-hash - name: root-hash - level: raid1 - devices: - - root-hash-1 - - root-hash-2 - - id: trident-overlay - name: trident-overlay - level: raid1 - devices: - - trident-overlay-1 - - trident-overlay-2 - verity: - - id: root - name: root - dataDeviceId: root-data - hashDeviceId: root-hash - filesystems: - - deviceId: trident-overlay - source: new - mountPoint: /var/lib/trident-overlay - - deviceId: home - source: new - mountPoint: /home - - deviceId: var - mountPoint: /var - - deviceId: boot - mountPoint: /boot - - deviceId: trident - source: new - mountPoint: /var/lib/trident - - deviceId: run - source: new - mountPoint: /run - - deviceId: esp - mountPoint: - path: /boot/efi - options: umask=0077 - - deviceId: root - mountPoint: - path: / - options: defaults,ro -scripts: - postConfigure: - - name: overlay - runOn: - - clean-install - - ab-update - content: | - mkdir -p /var/lib/trident-overlay/etc-rw/upper - mkdir -p /var/lib/trident-overlay/etc-rw/work -os: - selinux: - mode: permissive - network: - version: 2 - ethernets: - vmeths: - match: - name: enp* - dhcp4: true - users: - - name: testing-user - sshPublicKeys: [] - secondaryGroups: - - wheel - sshMode: key-only diff --git a/functional_tests/conftest.py b/functional_tests/conftest.py index 47d3ca25e..cbf8bbcba 100644 --- a/functional_tests/conftest.py +++ b/functional_tests/conftest.py @@ -21,10 +21,6 @@ """Location of the Trident repository.""" TRIDENT_REPO_DIR_PATH = Path(__file__).resolve().parent.parent -NETLAUNCH_BIN_REL_PATH = Path("bin/netlaunch") - -NETLAUNCH_BIN_PATH = TRIDENT_REPO_DIR_PATH / NETLAUNCH_BIN_REL_PATH - def __get_argus_toolkit_path(): """Returns the path to the argus-toolkit repository.""" @@ -42,30 +38,15 @@ def __get_argus_toolkit_path(): user specified in the trident-setup.yaml.""" TEST_USER = "testuser" -"""The name of the file containing the remote address of the VM.""" -REMOTE_ADDR_FILENAME = "remote-addr" - """The name of the file containing the known hosts for SSH connections.""" KNOWN_HOSTS_FILENAME = "known_hosts" VM_SSH_NODE_CACHE_KEY = "vm_ssh_node" +FT_BASE_IMAGE = TRIDENT_REPO_DIR_PATH / "artifacts" / "trident-functest.qcow2" -def __get_installer_iso_path(): - """Returns the path to the installer ISO.""" - envvar = os.environ.get("INSTALLER_ISO_PATH", None) - if envvar: - return Path(envvar).resolve() - return TRIDENT_REPO_DIR_PATH / "bin" / "trident-mos.iso" - - -"""Location of the installer ISO. -Defined in the makefile. -""" -INSTALLER_ISO_PATH = __get_installer_iso_path() - -"""Location of the directory netlaunch will serve from""" -NETLAUNCH_SERVE_DIRECTORY = TRIDENT_REPO_DIR_PATH / "artifacts" / "test-image" +"""Target location of the osmodifier binary in the test host.""" +OS_MODIFIER_BIN_TARGET_PATH = Path("/usr/bin/osmodifier") def pytest_addoption(parser): @@ -104,7 +85,10 @@ def pytest_addoption(parser): ) parser.addoption( - "--redeploy", action="store_true", help="Redeploy OS using Trident." + "--osmodifier", + help="Path to the osmodifier binary to copy into the test host.", + default=TRIDENT_REPO_DIR_PATH / "artifacts" / "osmodifier", + type=Path, ) @@ -269,14 +253,24 @@ def argus_runcmd(cmd, check=True, **kwargs): subprocess.run(cmd, check=check, cwd=ARGUS_REPO_DIR_PATH, **kwargs) -def upload_test_binaries(build_output_path: Path, force_upload, ssh_node): +def upload_test_binaries(build_output_path: Path, force_upload, ssh_node: SshNode): """Uploads all test binaries to the VM. Unless force_upload is set, only binaries that are not fresh are uploaded. You need to make sure that you dont rebuild the test binaries between the build and the upload, as the freshness is indicated by the cargo build output. """ ssh_node.execute("mkdir -p tests") - for line in open(build_output_path): + with open(build_output_path, "r") as f: + lines = f.readlines() + + logging.info(f"Found {len(lines)} lines in build output.") + + if not lines: + raise ValueError( + f"No test binaries found in {build_output_path}. Please ensure the build output is correct." + ) + + for line in lines: report = json.loads(line) if ( "target" in report @@ -285,12 +279,17 @@ def upload_test_binaries(build_output_path: Path, force_upload, ssh_node): and "executable" in report and report["executable"] ): - if force_upload or not report["fresh"]: - test_binary = report["executable"] - filename = os.path.basename(test_binary) - stripped_name = filename.split("-", 2)[0] - ssh_node.copy(test_binary, "tests/{}".format(stripped_name)) - ssh_node.execute("chmod +x tests/{}".format(stripped_name)) + if report["fresh"] and not force_upload: + continue + + test_binary = Path(report["executable"]) + stripped_name = test_binary.name.split("-", 2)[0] + remote_path = Path("tests/") / stripped_name + logging.info( + f"Uploading {test_binary} as {remote_path} ({test_binary.stat().st_size} bytes)" + ) + ssh_node.copy(test_binary, remote_path) + ssh_node.execute(f"chmod +x {remote_path}") @pytest.fixture(scope="session") @@ -299,16 +298,11 @@ def ssh_key_path(request) -> Path: @pytest.fixture(scope="session") -def ssh_key_public(request) -> Path: +def ssh_key_public(request) -> str: with open(request.config.getoption("--ssh-key"), "r") as f: return f.read().strip() -@pytest.fixture(scope="session") -def redeploy(request) -> bool: - return bool(request.config.getoption("--redeploy")) - - @pytest.fixture(scope="session") def reuse_environment(request) -> bool: return bool(request.config.getoption("--reuse-environment")) @@ -344,14 +338,9 @@ def test_dir_path(request, reuse_environment) -> Optional[Path]: @pytest.fixture(scope="session") -def remote_addr_path(test_dir_path) -> Path: - return test_dir_path / REMOTE_ADDR_FILENAME - - -@pytest.fixture(scope="session") -def known_hosts_path(test_dir_path, reuse_environment, redeploy) -> Path: +def known_hosts_path(test_dir_path, reuse_environment) -> Path: kh = test_dir_path / KNOWN_HOSTS_FILENAME - if reuse_environment and not redeploy: + if reuse_environment: if not kh.is_file(): pytest.fail( "No known hosts file found in test directory. You might need to recreate the test environment using make functional-test" @@ -370,6 +359,8 @@ def vm(request, ssh_key_path, known_hosts_path) -> SshNode: if ssh_node_address is None: pytest.skip("VM not setup!") + logging.info(f"Using VM at {ssh_node_address}") + priv_key = ssh_key_path.with_suffix("") logging.info(f"Using SSH key {priv_key}") @@ -382,6 +373,13 @@ def vm(request, ssh_key_path, known_hosts_path) -> SshNode: known_hosts_path=known_hosts_path, ) + # Upload OS modifier binary to the VM. + osmodifier_path = request.config.getoption("--osmodifier") + logging.info(f"Copying osmodifier from {osmodifier_path} to VM") + ssh_node.copy(osmodifier_path, Path("osmodifier")) + ssh_node.execute("chmod +x osmodifier") + ssh_node.execute(f"sudo mv osmodifier {OS_MODIFIER_BIN_TARGET_PATH}") + if build_output: upload_test_binaries(build_output, force_upload, ssh_node) diff --git a/functional_tests/custom/test_trident_e2e.py b/functional_tests/custom/test_trident_e2e.py index 99a5b5f63..061357653 100644 --- a/functional_tests/custom/test_trident_e2e.py +++ b/functional_tests/custom/test_trident_e2e.py @@ -1,129 +1,141 @@ -import yaml -import os -import pytest +## NOTE: +## These tests are currently disabled as they don't work with the new fastVM +## implementation because it is not deployed by Trident. +## +## They are being kept here for reference and can be re-enabled if trident is +## copied into the machine. +## +## All these tests, expect for test_trident_start_network, are currently +## covered by some other test, either FT or E2E. SO it is possible that we will +## just fully remove this file. -from assertpy import assert_that # type: ignore -from functional_tests.tools.trident import TridentTool -from functional_tests.conftest import TRIDENT_REPO_DIR_PATH +# import yaml +# import os +# import pytest +# from assertpy import assert_that # type: ignore -class HostStatusSafeLoader(yaml.SafeLoader): - def accept_image(self, node): - return self.construct_mapping(node) +# from functional_tests.tools.trident import TridentTool +# from functional_tests.conftest import TRIDENT_REPO_DIR_PATH -HostStatusSafeLoader.add_constructor("!image", HostStatusSafeLoader.accept_image) +# class HostStatusSafeLoader(yaml.SafeLoader): +# def accept_image(self, node): +# return self.construct_mapping(node) -@pytest.mark.functional -@pytest.mark.core -def test_trident_update(vm): - """Basic Trident run validation.""" - trident = TridentTool(vm) - result = trident.commit() - assert_that(result.exit_code).is_equal_to(0) +# HostStatusSafeLoader.add_constructor("!image", HostStatusSafeLoader.accept_image) - result = trident.commit(False) - assert_that(result.exit_code).is_equal_to(2) - assert_that( - result.stderr.index("Failed to run due to missing root privileges") != -1 - ) - pass +# @pytest.mark.functional +# @pytest.mark.core +# def test_trident_update(vm): +# """Basic Trident run validation.""" +# trident = TridentTool(vm) +# result = trident.commit() +# assert_that(result.exit_code).is_equal_to(0) +# result = trident.commit(False) +# assert_that(result.exit_code).is_equal_to(2) +# assert_that( +# result.stderr.index("Failed to run due to missing root privileges") != -1 +# ) -@pytest.mark.functional -@pytest.mark.core -def test_trident_get(vm): - """Basic trident get validation.""" - trident = TridentTool(vm) +# pass - host_status = trident.get() - host_status = yaml.load(host_status, Loader=HostStatusSafeLoader) - # TODO remove the placeholder logic by patching the template with the actual - # values, which we can fetch using lsblk, sfdisk and information about the - # images we put into the HostConfiguraion. - del host_status["spec"] - placeholder = "placeholder" - for id in host_status["partitionPaths"]: - host_status["partitionPaths"][id] = placeholder - host_status["diskUuids"] = {placeholder: placeholder} - with open( - TRIDENT_REPO_DIR_PATH / "functional_tests/host-status-template.yaml", "r" - ) as file: - host_status_expected = yaml.load(file, Loader=HostStatusSafeLoader) - assert host_status == host_status_expected - pass +# @pytest.mark.functional +# @pytest.mark.core +# def test_trident_get(vm): +# """Basic trident get validation.""" +# trident = TridentTool(vm) +# host_status = trident.get() +# host_status = yaml.load(host_status, Loader=HostStatusSafeLoader) +# # TODO remove the placeholder logic by patching the template with the actual +# # values, which we can fetch using lsblk, sfdisk and information about the +# # images we put into the HostConfiguraion. +# del host_status["spec"] +# placeholder = "placeholder" +# for id in host_status["partitionPaths"]: +# host_status["partitionPaths"][id] = placeholder +# host_status["diskUuids"] = {placeholder: placeholder} +# with open( +# TRIDENT_REPO_DIR_PATH / "functional_tests/host-status-template.yaml", "r" +# ) as file: +# host_status_expected = yaml.load(file, Loader=HostStatusSafeLoader) +# assert host_status == host_status_expected -@pytest.mark.functional -@pytest.mark.core -def test_trident_offline_initialize(vm): - """Basic trident offline initialize validation.""" - trident = TridentTool(vm) - host_status = trident.get() +# pass - # Load it as a yaml - host_status = yaml.load(host_status, Loader=HostStatusSafeLoader) - working_dir = "/tmp/datastore" +# @pytest.mark.functional +# @pytest.mark.core +# def test_trident_offline_initialize(vm): +# """Basic trident offline initialize validation.""" +# trident = TridentTool(vm) +# host_status = trident.get() - result = vm.execute("rm -rf " + working_dir) - assert_that(result.exit_code).is_equal_to(0) - vm.mkdir(working_dir) +# # Load it as a yaml +# host_status = yaml.load(host_status, Loader=HostStatusSafeLoader) - datastore_path = f"/var/lib/trident/offline/datastore.sqlite" - vm.execute(f"echo 'DatastorePath={datastore_path}' > /etc/trident/trident.conf") +# working_dir = "/tmp/datastore" - # Update the datastore location - host_status["spec"]["trident"] = {"datastorePath": datastore_path} +# result = vm.execute("rm -rf " + working_dir) +# assert_that(result.exit_code).is_equal_to(0) +# vm.mkdir(working_dir) - # Create mirror directory - if not os.path.exists(working_dir): - os.mkdir(working_dir) +# datastore_path = f"/var/lib/trident/offline/datastore.sqlite" +# vm.execute(f"echo 'DatastorePath={datastore_path}' > /etc/trident/trident.conf") - # Store it in a temporary file - host_status_path = f"{working_dir}/host-status.yaml" - with open(host_status_path, "w") as file: - yaml.dump(host_status, file) - vm.copy(host_status_path, host_status_path) +# # Update the datastore location +# host_status["spec"]["trident"] = {"datastorePath": datastore_path} - trident.offline_initialize(host_status_path) +# # Create mirror directory +# if not os.path.exists(working_dir): +# os.mkdir(working_dir) - vm.execute(f"sudo chown testuser {datastore_path}") +# # Store it in a temporary file +# host_status_path = f"{working_dir}/host-status.yaml" +# with open(host_status_path, "w") as file: +# yaml.dump(host_status, file) +# vm.copy(host_status_path, host_status_path) - # Use Trident get with the new config to load the status from the datastore - loaded_host_status = yaml.load(trident.get(), Loader=HostStatusSafeLoader) +# trident.offline_initialize(host_status_path) - host_status["spec"].pop("trident") - loaded_host_status["spec"].pop("trident") +# vm.execute(f"sudo chown testuser {datastore_path}") - # Check if the loaded status is the same as the original status - assert host_status == loaded_host_status +# # Use Trident get with the new config to load the status from the datastore +# loaded_host_status = yaml.load(trident.get(), Loader=HostStatusSafeLoader) - # Remove agent config so subsequent FTs use the real datastore - vm.execute(f"rm /etc/trident/trident.conf") +# host_status["spec"].pop("trident") +# loaded_host_status["spec"].pop("trident") - pass +# # Check if the loaded status is the same as the original status +# assert host_status == loaded_host_status +# # Remove agent config so subsequent FTs use the real datastore +# vm.execute(f"rm /etc/trident/trident.conf") -@pytest.mark.functional -@pytest.mark.core -def test_trident_start_network(vm): - """Basic trident start-network validation.""" +# pass - vm.mkdir("/etc/trident") - vm.execute("sudo chmod 777 /etc/trident") - vm.copy( - TRIDENT_REPO_DIR_PATH / "functional_tests/trident-setup.yaml", - "/etc/trident/config.yaml", - ) - trident = TridentTool(vm) - trident.start_network() +# @pytest.mark.functional +# @pytest.mark.core +# def test_trident_start_network(vm): +# """Basic trident start-network validation.""" - vm.execute("rm /etc/trident/config.yaml") +# vm.mkdir("/etc/trident") +# vm.execute("sudo chmod 777 /etc/trident") +# vm.copy( +# TRIDENT_REPO_DIR_PATH / "functional_tests/trident-setup.yaml", +# "/etc/trident/config.yaml", +# ) - pass +# trident = TridentTool(vm) +# trident.start_network() + +# vm.execute("rm /etc/trident/config.yaml") + +# pass diff --git a/functional_tests/test_setup.py b/functional_tests/test_setup.py index a52391cbf..948d1face 100644 --- a/functional_tests/test_setup.py +++ b/functional_tests/test_setup.py @@ -1,139 +1,125 @@ -import subprocess -import time -import pytest +import json import os -import tempfile import logging -import yaml +import subprocess +import tempfile +import time +from typing import Dict from pathlib import Path +from subprocess import CalledProcessError, TimeoutExpired from .conftest import ( - INSTALLER_ISO_PATH, - NETLAUNCH_SERVE_DIRECTORY, argus_runcmd, - trident_runcmd, ARGUS_REPO_DIR_PATH, - TRIDENT_REPO_DIR_PATH, - NETLAUNCH_BIN_PATH, VM_SSH_NODE_CACHE_KEY, + FT_BASE_IMAGE, + TEST_USER, ) from .ssh_node import SshNode +log = logging.getLogger(__name__) -def create_vm(create_params): +CLOUD_INIT_USER_TEMPLATE = """ +#cloud-config +users: + - name: {username} + ssh_authorized_keys: + - {ssh_pub_key} + sudo: ['ALL=(ALL) NOPASSWD:ALL'] +""" + + +def create_vm(create_params) -> Dict[str, str]: + log.info("Creating VM with parameters: %s", create_params) """Creates a VM with the given parameters, using virt-deploy.""" argus_runcmd([ARGUS_REPO_DIR_PATH / "virt-deploy", "create"] + create_params) - argus_runcmd([ARGUS_REPO_DIR_PATH / "virt-deploy", "run"]) - - -def disable_phonehome(ssh_node: SshNode): - """Disables phonehome in the VM to allow faster rerunning of Trident.""" - ssh_node.execute("sudo sed -i 's/^\s*phonehome: .*//' /etc/trident/config.yaml") - - -def prepare_hostconfig(test_dir_path: Path, ssh_pub_key: str): - """Sets up the host configuration file for the VM.""" - - # Add user's public key to trident-setup.yaml - with open( - TRIDENT_REPO_DIR_PATH / "functional_tests/trident-setup.yaml", "r" - ) as file: - trident_setup = yaml.safe_load(file) - trident_setup["os"]["users"][0]["sshPublicKeys"] = [ssh_pub_key] - - prepped_host_config_path = test_dir_path / "trident-setup.yaml" - with open(prepped_host_config_path, "w") as file: - yaml.dump(trident_setup, file) - - return prepped_host_config_path - - -def deploy_vm( - test_dir_path: Path, - ssh_pub_key: str, - known_hosts_path: Path, - remote_addr_path: Path, -) -> str: - """# Provision a VM with the given parameters, using virt-deploy to create the VM - and netlaunch to deploy the OS. Returns the ip address of the VM. - """ - - host_config_path = prepare_hostconfig(test_dir_path, ssh_pub_key) - - trident_runcmd( - [ - NETLAUNCH_BIN_PATH, - "-i", - INSTALLER_ISO_PATH, - "-c", - ARGUS_REPO_DIR_PATH / "vm-netlaunch.yaml", - "-t", - host_config_path, - "-l", - "-r", - remote_addr_path, - "-s", - NETLAUNCH_SERVE_DIRECTORY, - ] - ) - # Temporary solution to initialize the known_hosts file until we can inject - # a predictable key. - with open(remote_addr_path, "r") as file: - remote_addr = file.read().strip() - - for i in range(10): - try: - with open(known_hosts_path, "w") as file: - subprocess.run(["ssh-keyscan", remote_addr], stdout=file, check=True) - break - except: - time.sleep(1) + with open(ARGUS_REPO_DIR_PATH / "virt-deploy-metadata.json", "r") as file: + metadata = json.load(file) - return remote_addr + return metadata["virtualmachines"][0] -def test_create_vm(request): +def wait_online(ip: str, known_hosts_path: Path, timeout: int = 60) -> None: + """Waits for the VM to be online by checking SSH connectivity.""" + start_time = time.time() + while time.time() - start_time < timeout: + try: + with open(known_hosts_path, "w") as f: + subprocess.run( + [ + "ssh-keyscan", + ip, + ], + stdout=f, + check=True, + timeout=5, + ) + return + except (CalledProcessError, TimeoutExpired) as e: + time.sleep(5) + + raise TimeoutError(f"VM with IP {ip} did not come online within {timeout} seconds.") + + +def test_create_vm(request, known_hosts_path, ssh_key_public): """Test function to create a VM with virt-deploy""" + + if request.config.getoption("--reuse-environment"): + log.info("Skipping VM creation as --reuse-environment is set.") + return + request.config.cache.set(VM_SSH_NODE_CACHE_KEY, None) - if not request.config.getoption("--reuse-environment"): - # Create one VM with default flags, cpus, memory, but with two 16GiB disks. - create_vm([":::16,16"]) - - -@pytest.mark.depends("test_create_vm") -def test_deploy_vm( - request, - test_dir_path, - reuse_environment, - redeploy, - remote_addr_path, - known_hosts_path, - ssh_key_public, -): - if reuse_environment and not redeploy: - # Get the IP address from the remote_addr file of the existing VM. - with open(remote_addr_path, "r") as file: - address = file.read().strip() - else: - if ( - not ARGUS_REPO_DIR_PATH.is_dir() - or not (ARGUS_REPO_DIR_PATH / "virt-deploy").is_file() - ): - pytest.fail(f"{ARGUS_REPO_DIR_PATH} is not a argus-toolkit repo directory") - - # Deploy OS to VM. - address = deploy_vm( - test_dir_path, - ssh_key_public, - known_hosts_path, - remote_addr_path, + + with tempfile.TemporaryDirectory() as temp_dir: + work_dir = Path(temp_dir) + # Create a cloud-init metadata file for the VM. + cloud_init_meta = work_dir / "cloud-init-meta.yaml" + with open(cloud_init_meta, "w") as file: + file.write("#cloud-config\n") + + # Create a cloud-init user-data file for the VM. + cloud_init_user_data = work_dir / "cloud-init-user-data.yaml" + with open(cloud_init_user_data, "w") as file: + file.write( + CLOUD_INIT_USER_TEMPLATE.format( + username=TEST_USER, + ssh_pub_key=ssh_key_public, + ) + ) + + # Create one VM with default flags, cpus, memory, but with two 16GiB + # disks. Pass the base Image as the OS disk. And Pass cloud init-params + # to set up a user and ssh access. + vm_data = create_vm( + [ + ":::16,16", + "--os-disk", + FT_BASE_IMAGE, + "--ci-user", + cloud_init_user_data, + "--ci-meta", + cloud_init_meta, + ] ) - request.config.cache.set(VM_SSH_NODE_CACHE_KEY, address) + vm_name = vm_data["name"] + vm_ip = vm_data["ip"] + + subprocess.run( + ["virsh", "start", vm_name], + check=True, + ) + + wait_online(vm_ip, known_hosts_path, timeout=60) + + request.config.cache.set(VM_SSH_NODE_CACHE_KEY, vm_ip) + + +# def test_wait_online(known_hosts_path): +# wait_online("192.168.242.2", known_hosts_path, timeout=60) -@pytest.mark.depends("test_deploy_vm") -def test_deployment(vm): +def test_deployment(vm: SshNode): vm.execute("true") diff --git a/osutils/Cargo.toml b/osutils/Cargo.toml index 2e441878f..a90919947 100644 --- a/osutils/Cargo.toml +++ b/osutils/Cargo.toml @@ -12,6 +12,8 @@ configparser = { version = "3.1.0", features = ["indexmap"] } const_format = "0.2.33" duct = "0.13.7" enumflags2 = { version = "0.7", features = ["serde"] } +goblin = "0.9.3" +hex = "0.4.0" hostname = "0.4.0" indoc = "2.0.5" inventory = "0.3.15" diff --git a/osutils/src/dependencies.rs b/osutils/src/dependencies.rs index 055df95d4..8000247ce 100644 --- a/osutils/src/dependencies.rs +++ b/osutils/src/dependencies.rs @@ -94,6 +94,7 @@ pub enum Dependency { Df, Dracut, E2fsck, + Efivar, Efibootmgr, Findmnt, Iptables, @@ -119,6 +120,8 @@ pub enum Dependency { SystemdCryptenroll, #[strum(serialize = "systemd-firstboot")] SystemdFirstboot, + #[strum(serialize = "systemd-pcrlock")] + SystemdPcrlock, #[strum(serialize = "systemd-repart")] SystemdRepart, Touch, @@ -152,6 +155,7 @@ impl Dependency { fn path_override(&self) -> Option { Some(PathBuf::from(match self { Self::Netplan => "/usr/sbin/netplan", + Self::SystemdPcrlock => "/usr/lib/systemd/systemd-pcrlock", _ => return None, })) } diff --git a/osutils/src/efivar.rs b/osutils/src/efivar.rs new file mode 100644 index 000000000..d1b747d76 --- /dev/null +++ b/osutils/src/efivar.rs @@ -0,0 +1,185 @@ +use std::{fs, io::Write, path::Path}; + +use log::debug; +use tempfile::NamedTempFile; + +use trident_api::error::{ReportError, ServicingError, TridentError, TridentResultExt}; + +use crate::dependencies::{Dependency, DependencyResultExt}; + +const BOOTLOADER_INTERFACE_GUID: &str = "4a67b082-0a4c-41cf-b6c7-440b29bb8c4f"; + +const LOADER_ENTRY_ONESHOT: &str = "LoaderEntryOneShot"; +const LOADER_ENTRY_DEFAULT: &str = "LoaderEntryDefault"; +const LOADER_ENTRY_SELECTED: &str = "LoaderEntrySelected"; + +fn encode_utf16le(data: &str) -> Vec { + data.encode_utf16() + .flat_map(|u| u.to_le_bytes()) + .chain([0; 2]) + .collect() +} + +fn decode_utf16le(mut data: &[u8]) -> String { + if data.len() <= 2 { + return String::new(); + } + + // Remove null terminator + if data[data.len() - 2..] == [0, 0] { + data = &data[..data.len() - 2]; + } + + let utf16_data: Vec = data + .chunks(2) + .map(|chunk| u16::from_le_bytes([chunk[0], chunk[1]])) + .collect(); + String::from_utf16_lossy(&utf16_data) +} + +/// Set an EFI variable using the efivar command-line tool. +/// `name` should include the GUID, e.g. "BootNext-8be4df61-93ca-11d2-aa0d-00e098032b8c" +/// `data` should be a hex string, e.g. "0100" for BootNext=0001 (little-endian) +fn set_efi_variable(name: &str, data_utf16: &[u8]) -> Result<(), TridentError> { + debug!( + "Setting EFI variable '{name}' to '{}'", + decode_utf16le(data_utf16) + ); + + // Write the UTF-16LE data to a temporary file + let mut tmpfile = NamedTempFile::new().structured(ServicingError::SetEfiVariable { + name: name.to_string(), + })?; + tmpfile + .write_all(data_utf16) + .structured(ServicingError::SetEfiVariable { + name: name.to_string(), + })?; + + Dependency::Efivar + .cmd() + .arg("--verbose") + .arg("--name") + .arg(name) + .arg("--write") + .arg("--datafile") + .arg(tmpfile.path()) + .run_and_check() + .message(format!("efivar failed to set variable '{name}'")) +} + +/// Set the LoaderEntryOneShot EFI variable for systemd-boot oneshot boot. +pub fn set_oneshot(entry: &str) -> Result<(), TridentError> { + debug!("Setting oneshot boot entry to: '{entry}'"); + set_efi_variable( + &format!("{BOOTLOADER_INTERFACE_GUID}-{LOADER_ENTRY_ONESHOT}"), + &encode_utf16le(entry), + ) +} + +/// Set the LoaderEntryDefault EFI variable for systemd-boot default boot. +pub fn set_default(entry: &str) -> Result<(), TridentError> { + debug!("Setting default boot entry to: '{entry}'"); + set_efi_variable( + &format!("{BOOTLOADER_INTERFACE_GUID}-{LOADER_ENTRY_DEFAULT}"), + &encode_utf16le(entry), + ) +} + +fn read_efi_variable(guid: &str, variable: &str) -> Result, TridentError> { + let efi_var_path = Path::new("/sys/firmware/efi/efivars/").join(format!("{variable}-{guid}")); + + // Read the LoaderEntrySelected EFI variable from efivars + let data = fs::read(efi_var_path).structured(ServicingError::ReadEfiVariable { + name: variable.to_string(), + })?; + + // The first 4 bytes are attributes, skip them + if data.len() <= 4 { + return Err(TridentError::new(ServicingError::ReadEfiVariable { + name: variable.to_string(), + })) + .message("EFI variable file is too short"); + } + Ok(data[4..].to_vec()) +} + +/// Returns whether the LoaderEntrySelected EFI variable is set. +pub fn current_var_set() -> bool { + read_efi_variable(BOOTLOADER_INTERFACE_GUID, LOADER_ENTRY_SELECTED).is_ok() +} + +/// Set the LoaderEntryDefault EFI variable to the current boot entry +pub fn set_default_to_current() -> Result<(), TridentError> { + let current = read_efi_variable(BOOTLOADER_INTERFACE_GUID, LOADER_ENTRY_SELECTED)?; + debug!( + "Setting default boot entry to current: '{}'", + decode_utf16le(¤t) + ); + set_efi_variable( + &format!("{BOOTLOADER_INTERFACE_GUID}-{LOADER_ENTRY_DEFAULT}"), + ¤t, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_encode_utf16le() { + let input = "Test"; + let expected = vec![84, 0, 101, 0, 115, 0, 116, 0, 0, 0]; + assert_eq!(encode_utf16le(input), expected); + } + + #[test] + fn test_decode_utf16le() { + let input = vec![84, 0, 101, 0, 115, 0, 116, 0, 0, 0]; + assert_eq!(decode_utf16le(&input), "Test"); + } +} + +#[cfg(feature = "functional-test")] +#[cfg_attr(not(test), allow(unused_imports, dead_code))] +mod functional_test { + use pytest_gen::functional_test; + + use super::*; + + #[functional_test(feature = "helpers")] + fn test_set_oneshot() { + let entry = "TestEntry"; + set_oneshot(entry).unwrap(); + let data = read_efi_variable(BOOTLOADER_INTERFACE_GUID, LOADER_ENTRY_ONESHOT).unwrap(); + assert_eq!(decode_utf16le(&data), entry); + + set_oneshot("").unwrap(); + } + + #[functional_test(feature = "helpers")] + fn test_set_default() { + let entry = "TestDefaultEntry"; + set_default(entry).unwrap(); + let data = read_efi_variable(BOOTLOADER_INTERFACE_GUID, LOADER_ENTRY_DEFAULT).unwrap(); + assert_eq!(decode_utf16le(&data), entry); + + set_default("").unwrap(); + } + + #[functional_test(feature = "helpers")] + fn test_set_default_to_current() { + set_efi_variable( + &format!("{BOOTLOADER_INTERFACE_GUID}-{LOADER_ENTRY_SELECTED}"), + &encode_utf16le("CurrentEntry"), + ) + .unwrap(); + + // Now set the default to the current entry + set_default_to_current().unwrap(); + let data = read_efi_variable(BOOTLOADER_INTERFACE_GUID, LOADER_ENTRY_DEFAULT).unwrap(); + assert_eq!(decode_utf16le(&data), "CurrentEntry"); + + set_default("").unwrap(); + } +} diff --git a/osutils/src/encryption.rs b/osutils/src/encryption.rs index a296ace68..cf28cefd2 100644 --- a/osutils/src/encryption.rs +++ b/osutils/src/encryption.rs @@ -169,7 +169,7 @@ fn to_tpm2_pcrs_arg(pcrs: BitFlags) -> String { format!( "--tpm2-pcrs={}", pcrs.iter() - .map(|flag| flag.to_value().to_string()) + .map(|flag| flag.to_num().to_string()) .collect::>() .join("+") ) @@ -336,18 +336,18 @@ mod functional_test { // Create a temporary file to store the recovery key file let key_file_tmp = NamedTempFile::new().unwrap(); - let key_file_path = key_file_tmp.path().to_owned(); - fs::set_permissions(&key_file_path, Permissions::from_mode(0o600)).unwrap(); - generate_recovery_key_file(&key_file_path).unwrap(); + let key_file_path = key_file_tmp.path(); + fs::set_permissions(key_file_path, Permissions::from_mode(0o600)).unwrap(); + generate_recovery_key_file(key_file_path).unwrap(); // Run `cryptsetup-luksFormat` on the partition - cryptsetup_luksformat(&key_file_path, &partition1.node).unwrap(); + cryptsetup_luksformat(key_file_path, &partition1.node).unwrap(); // Run `systemd-cryptenroll` on the partition - systemd_cryptenroll(&key_file_path, &partition1.node, BitFlags::from(Pcr::Pcr7)).unwrap(); + systemd_cryptenroll(key_file_path, &partition1.node, BitFlags::from(Pcr::Pcr7)).unwrap(); // Open the encrypted volume, to make the block device available - cryptsetup_open(&key_file_path, &partition1.node, ENCRYPTED_VOLUME_NAME).unwrap(); + cryptsetup_open(key_file_path, &partition1.node, ENCRYPTED_VOLUME_NAME).unwrap(); // Format the unlocked volume with ext4 mkfs::run(Path::new(ENCRYPTED_VOLUME_PATH), MkfsFileSystemType::Ext4).unwrap(); @@ -396,7 +396,7 @@ mod functional_test { cryptsetup_close(ENCRYPTED_VOLUME_NAME).unwrap(); // Re-open the encrypted volume - cryptsetup_open(&key_file_path, &partition1.node, ENCRYPTED_VOLUME_NAME).unwrap(); + cryptsetup_open(key_file_path, &partition1.node, ENCRYPTED_VOLUME_NAME).unwrap(); // Re-mount the encrypted volume Dependency::Mount diff --git a/osutils/src/lib.rs b/osutils/src/lib.rs index c0b71bc40..c164c880d 100644 --- a/osutils/src/lib.rs +++ b/osutils/src/lib.rs @@ -7,6 +7,7 @@ pub mod dependencies; pub mod df; pub mod e2fsck; pub mod efibootmgr; +pub mod efivar; pub mod encryption; pub mod exe; pub mod files; @@ -30,6 +31,7 @@ pub mod osmodifier; pub mod osrelease; pub mod overlay; pub mod path; +pub mod pcrlock; pub mod repart; pub mod resize2fs; pub mod scripts; diff --git a/osutils/src/lsblk.rs b/osutils/src/lsblk.rs index fef90248b..012d780d0 100644 --- a/osutils/src/lsblk.rs +++ b/osutils/src/lsblk.rs @@ -140,6 +140,9 @@ pub enum PartitionTableType { /// Master Boot Record #[serde(rename = "mbr", alias = "dos")] Mbr, + + #[serde(other)] + Unknown, } /// Returns a list of all block devices on the system. @@ -1180,6 +1183,92 @@ mod tests { parse_lsblk_output(output).unwrap(); } + + #[test] + fn unknown_partition_table_type() { + let output = r#"{ +"blockdevices": [ + { + "alignment": 0, + "id-link": null, + "id": null, + "disc-aln": 0, + "dax": false, + "disc-gran": 4096, + "disk-seq": 4309, + "disc-max": 4294966784, + "disc-zero": false, + "fsavail": null, + "fsroots": [ + null + ], + "fssize": null, + "fstype": null, + "fsused": null, + "fsuse%": null, + "fsver": null, + "group": "daemon", + "hctl": null, + "hotplug": false, + "kname": "loop32", + "label": null, + "log-sec": 512, + "maj:min": "7:32", + "maj": "7", + "min": "32", + "min-io": 512, + "mode": "brw-rw----", + "model": null, + "mq": " 1", + "name": "loop32", + "opt-io": 0, + "owner": "root", + "partflags": null, + "partlabel": null, + "partn": null, + "parttype": null, + "parttypename": null, + "partuuid": null, + "path": "/dev/loop32", + "phy-sec": 512, + "pkname": null, + "pttype": "PMBR", + "ptuuid": null, + "ra": 128, + "rand": false, + "rev": null, + "rm": false, + "ro": false, + "rota": false, + "rq-size": 128, + "sched": "none", + "serial": null, + "size": 13893632000, + "start": null, + "state": null, + "subsystems": "block", + "mountpoint": null, + "mountpoints": [ + null + ], + "tran": null, + "type": "loop", + "uuid": null, + "vendor": null, + "wsame": 0, + "wwn": null, + "zoned": "none", + "zone-sz": 0, + "zone-wgran": 0, + "zone-app": 0, + "zone-nr": 0, + "zone-omax": 0, + "zone-amax": 0, + "children": [] + } +]}"#; + let _ = parse_lsblk_output(output).unwrap(); + } } #[cfg(feature = "functional-test")] diff --git a/osutils/src/mdadm.rs b/osutils/src/mdadm.rs index 235726729..17b6fe8a8 100644 --- a/osutils/src/mdadm.rs +++ b/osutils/src/mdadm.rs @@ -11,10 +11,30 @@ use crate::{dependencies::Dependency, lsblk}; pub const METADATA_VERSION: &str = "1.0"; +/// Creates a RAID array using `mdadm` with the specified level and devices. pub fn create( raid_path: &PathBuf, level: &RaidLevel, device_paths: Vec, +) -> Result<(), Error> { + create_inner(raid_path, level, device_paths, None) +} + +/// Creates a RAID array using `mdadm` with the specified level, devices, and homehost. +pub fn create_homehost( + raid_path: &PathBuf, + level: &RaidLevel, + device_paths: Vec, + homehost: &str, +) -> Result<(), Error> { + create_inner(raid_path, level, device_paths, Some(homehost)) +} + +fn create_inner( + raid_path: &PathBuf, + level: &RaidLevel, + device_paths: Vec, + homehost: Option<&str>, ) -> Result<(), Error> { trace!("Creating RAID array '{}'", &raid_path.display()); @@ -36,6 +56,10 @@ pub fn create( .args(&device_paths) .arg(format!("--metadata={METADATA_VERSION}")); + if let Some(homehost) = homehost { + mdadm_command.arg(format!("--homehost={}", homehost)); + } + mdadm_command .run_and_check() .context("Failed to run mdadm create") diff --git a/osutils/src/mount.rs b/osutils/src/mount.rs index 949f4ff7a..720090f89 100644 --- a/osutils/src/mount.rs +++ b/osutils/src/mount.rs @@ -100,15 +100,15 @@ mod functional_test { use pytest_gen::functional_test; use trident_api::constants::MOUNT_OPTION_READ_ONLY; - use crate::mountpoint; + use crate::{filesystems::MkfsFileSystemType, mkfs, mountpoint, testutils::repart}; #[functional_test(feature = "helpers")] fn test_mount_and_umount() { - // CDROM device to be mounted - let device = Path::new("/dev/sr0"); - // Mount point - let mount_point = Path::new("/mnt/cdrom"); + let loopback = repart::make_loopback_filesystem(MkfsFileSystemType::Vfat); + let device = loopback.path(); + // Mount point + let mount_point = Path::new("/mnt/tmpmount"); if mountpoint::check_is_mountpoint(mount_point).unwrap() { umount(mount_point, false).unwrap(); } @@ -117,7 +117,7 @@ mod functional_test { fs::create_dir_all(mount_point).unwrap(); // Test mount_file function - mount(device, mount_point, MountFileSystemType::Iso9660, &[]).unwrap(); + mount(device, mount_point, MountFileSystemType::Vfat, &[]).unwrap(); // If device is a file, fetch the name of loop device that was mounted at mount point; // otherwise, use the device path itself @@ -145,6 +145,10 @@ mod functional_test { #[functional_test(feature = "helpers")] fn test_recursive_unmount() { + let loopback = NamedTempFile::new().unwrap(); + loopback.as_file().set_len(1024 * 1024).unwrap(); + mkfs::run(loopback.path(), MkfsFileSystemType::Ext4).unwrap(); + let tmp_mount = Path::new("/mnt/tmpfs"); fs::create_dir_all(tmp_mount).unwrap(); mount( @@ -158,7 +162,7 @@ mod functional_test { let cdrom_mount = tmp_mount.join("cdrom"); fs::create_dir_all(&cdrom_mount).unwrap(); mount( - Path::new("/dev/sr0"), + loopback.path(), &cdrom_mount, MountFileSystemType::Auto, &[], diff --git a/osutils/src/osmodifier.rs b/osutils/src/osmodifier.rs index 42063359f..79f9fcdf4 100644 --- a/osutils/src/osmodifier.rs +++ b/osutils/src/osmodifier.rs @@ -26,6 +26,9 @@ pub struct OSModifierConfig { #[serde(skip_serializing_if = "Option::is_none")] pub kernel_command_line: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub selinux: Option, } impl OSModifierConfig { diff --git a/osutils/src/pcrlock.rs b/osutils/src/pcrlock.rs new file mode 100644 index 000000000..e795fa370 --- /dev/null +++ b/osutils/src/pcrlock.rs @@ -0,0 +1,747 @@ +use std::{ + fs, + path::{Path, PathBuf}, + process::Command, +}; + +use anyhow::{bail, Context, Error, Result}; +use enumflags2::{make_bitflags, BitFlags}; +use goblin::pe::PE; +use log::{debug, error, trace, warn}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256, Sha384, Sha512}; +use tempfile::NamedTempFile; + +use trident_api::{ + error::{ReportError, ServicingError, TridentError}, + primitives::hash::Sha256Hash, +}; + +use sysdefs::tpm2::Pcr; + +use crate::dependencies::{Dependency, DependencyResultExt}; + +use crate::exe::RunAndCheck; + +/// Path to the pcrlock directory where .pcrlock files are stored. +const PCRLOCK_DIR: &str = "/var/lib/pcrlock.d"; + +/// Path to the PCR policy JSON file. +const PCR_POLICY_PATH: &str = "/var/lib/systemd/pcrlock.json"; + +/// Dir-s for dynamically generated .pcrlock files that might contain 1+ .pcrlock files, for the +/// current and updated images: +/// 1. /var/lib/pcrlock.d/600-gpt.pcrlock.d, where `lock-gpt` measures the GPT partition table of +/// the booted medium, as recorded to PCR 5 by the firmware, +const GPT_PCRLOCK_DIR: &str = "600-gpt.pcrlock.d"; + +/// 2. /var/lib/pcrlock.d/610-boot-loader-code.pcrlock.d, where Trident measures the bootx64.efi +/// binary, as recorded into PCR 4 following Microsoft's Authenticode hash spec, +const BOOT_LOADER_CODE_PCRLOCK_DIR: &str = "610-boot-loader-code.pcrlock.d"; + +/// 3. /var/lib/pcrlock.d/630-boot-loader-conf.pcrlock.d, where `lock-raw` measures the boot loader +/// configuration file, as recorded into PCR 5, +const BOOT_LOADER_CONF_PCRLOCK_DIR: &str = "630-boot-loader-conf.pcrlock.d"; + +/// 4. /var/lib/pcrlock.d/650-uki.pcrlock.d, where `lock-uki` measures the UKI binary, as recorded +/// into PCR 4, +const UKI_PCRLOCK_DIR: &str = "650-uki.pcrlock.d"; + +/// 5. /var/lib/pcrlock.d/710-kernel-cmdline.pcrlock.d, where `lock-kernel-cmdline` measures the +/// kernel command line, as recorded into PCR 9, +const KERNEL_CMDLINE_PCRLOCK_DIR: &str = "710-kernel-cmdline.pcrlock.d"; + +/// 6. /var/lib/pcrlock.d/720-kernel-initrd.pcrlock.d, where Trident measures the initrd section of +/// the UKI binary, as recorded into PCR 9. +const KERNEL_INITRD_PCRLOCK_DIR: &str = "720-kernel-initrd.pcrlock.d"; + +/// Valid PCRs for TPM 2.0 policy generation, following the `systemd-pcrlock` spec. +/// +/// https://www.man7.org/linux/man-pages/man8/systemd-pcrlock.8.html. +const VALID_PCRLOCK_PCRS: BitFlags = make_bitflags!(Pcr::{Pcr0 | Pcr1 | Pcr2 | Pcr3 | Pcr4 | Pcr5 | Pcr7 | Pcr11 | Pcr12 | Pcr13 | Pcr14 | Pcr15}); + +#[derive(Debug, Deserialize)] +struct PcrValue { + pcr: Pcr, +} + +#[derive(Debug, Deserialize)] +struct PcrPolicy { + #[serde(rename = "pcrValues")] + pcr_values: Vec, +} + +/// Validates the PCR input and calls a helper function to generate the TPM 2.0 access policy. +/// Parses the output of the helper function to validate that the policy has been updated as +/// expected. +/// +/// If PCRs are not specified, the command defaults to PCRs 0-5, 7, 11-15. +pub fn generate_tpm2_access_policy(pcrs: BitFlags) -> Result<(), TridentError> { + debug!( + "Generating a new TPM 2.0 access policy for the following PCRs: {:?}", + pcrs.iter().map(|pcr| pcr.to_num()).collect::>() + ); + + // Validate that all requested PCRs are allowed by systemd-pcrlock + let filtered_pcrs = pcrs & VALID_PCRLOCK_PCRS; + + if pcrs != filtered_pcrs { + let ignored = pcrs & !filtered_pcrs; + warn!( + "Ignoring unsupported PCRs while generating a new TPM 2.0 access policy: {:?}", + ignored.iter().collect::>() + ); + } + + let output = make_policy(pcrs).structured(ServicingError::GenerateTpm2AccessPolicy)?; + + // Validate that TPM 2.0 access policy has been updated + if !output.contains("Calculated new PCR policy") || !output.contains("Updated NV index") { + warn!("TPM 2.0 access policy has not been updated:\n{}", output); + } + + // Log PCR policy JSON contents + let pcrlock_policy = + fs::read_to_string(PCR_POLICY_PATH).structured(ServicingError::GenerateTpm2AccessPolicy)?; + trace!( + "Contents of PCR policy JSON at '{PCR_POLICY_PATH}':\n{}", + pcrlock_policy + ); + + // Parse the policy JSON to validate that all requested PCRs are present + let policy: PcrPolicy = serde_json::from_str(&pcrlock_policy) + .structured(ServicingError::GenerateTpm2AccessPolicy)?; + // Extract PCRs from the policy, and filter for PCRs that were requested yet are missing + // from the policy + let policy_pcrs: Vec = policy.pcr_values.iter().map(|pv| pv.pcr).collect(); + let missing_pcrs: Vec = pcrs + .iter() + .filter(|pcr| !policy_pcrs.contains(pcr)) + .collect(); + + // If any requested PCRs are missing from the policy, return an error + if !missing_pcrs.is_empty() { + error!( + "Some requested PCRs are missing from the generated PCR policy: '{:?}'", + missing_pcrs + .iter() + .map(|pcr| pcr.to_num()) + .collect::>() + ); + return Err(TridentError::new(ServicingError::GenerateTpm2AccessPolicy)); + } + + Ok(()) +} + +#[derive(Debug, Deserialize)] +struct LogEntry { + pcr: Pcr, + pcrname: Option, + event: Option, + sha256: Option, + component: Option, + description: Option, +} + +#[derive(Debug, Deserialize)] +struct LogOutput { + log: Vec, +} + +/// Runs `systemd-pcrlock log` to get the combined TPM 2.0 event log, output as a "pretty" JSON. +/// Parses the output and validates that every log entry has been matched to a recognized boot +/// component. +/// +/// If a log entry has a null component, it means that there is no .pcrlock file that records that +/// specific measurement extended into the given PCR, for any boot process component. For that +/// reason, .pcrlock files are known as boot component definition files. If a log entry for a PCR +/// has its component missing, then the value of that PCR cannot be predicted and so the PCR cannot +/// be included in a pcrlock policy. Thus, this validation ensures that all .pcrlock files have +/// been added & generated, so that a valid TPM 2.0 access policy can be generated. +/// Please refer to `systemd-pcrlock` doc for additional info: +/// https://www.man7.org/linux/man-pages/man8/systemd-pcrlock.8.html. +fn validate_log() -> Result<(), Error> { + let output = Dependency::SystemdPcrlock + .cmd() + .arg("log") + .arg("--json=pretty") + .output_and_check() + .context("Failed to run systemd-pcrlock log")?; + + let parsed: LogOutput = + serde_json::from_str(&output).context("Failed to parse systemd-pcrlock log output")?; + + let unrecognized: Vec<_> = parsed + .log + .iter() + .filter(|entry| entry.component.is_none()) + .collect(); + + if unrecognized.is_empty() { + return Ok(()); + } + + let entries: Vec = unrecognized + .into_iter() + .map(|entry| { + format!( + "pcr='{}', pcrname='{}', event='{}', sha256='{}', description='{}'", + entry.pcr.to_num(), + entry.pcrname.as_deref().unwrap_or("null"), + entry.event.as_deref().unwrap_or("null"), + entry.sha256.as_ref().map(|h| h.as_str()).unwrap_or("null"), + entry.description.as_deref().unwrap_or("null"), + ) + }) + .collect(); + + bail!( + "Failed to validate systemd-pcrlock log output as some log entries cannot be matched \ + to recognized components:\n{}", + entries.join("\n") + ); +} + +/// Runs `systemd-pcrlock make-policy` command to predict the PCR state for future boots and then +/// generate a TPM 2.0 access policy, stored in a TPM 2.0 NV index. The prediction and info about +/// the used TPM 2.0 and its NV index are written to PCR_POLICY_PATH. +fn make_policy(pcrs: BitFlags) -> Result { + Dependency::SystemdPcrlock + .cmd() + .arg("make-policy") + .arg(to_pcr_arg(pcrs)) + .output_and_check() + .context("Failed to run systemd-pcrlock make-policy") +} + +/// Converts the provided PCR bitflags into the `--pcr=` argument for `systemd-pcrlock`. Returns a +/// string with the PCR indices separated by `,`. +fn to_pcr_arg(pcrs: BitFlags) -> String { + format!( + "--pcr={}", + pcrs.iter() + .map(|flag| flag.to_num().to_string()) + .collect::>() + .join(",") + ) +} + +/// Represents the `systemd-pcrlock lock-*` commands. Each command generates or removes specific +/// .pcrlock files based on the TPM 2.0 event log of the current/next boot covering all records for +/// a specific set of PCRs. +/// +/// For more info, see the official documentation for the `systemd-pcrlock` tool: +/// https://www.man7.org/linux/man-pages/man8/systemd-pcrlock.8.html. +enum LockCommand { + /// Generates .pcrlock files covering all records for PCRs 0 ("platform-code") and 2 + /// ("external-code"). Allows locking the boot process to the current version of the firmware + /// of the system and its extension cards. + FirmwareCode, + + /// Locks down the firmware configuration, i.e. PCRs 1 ("platform-config") and 3 + /// ("external-config"). + FirmwareConfig, + + /// Generates a .pcrlock file based on the SecureBoot policy currently enforced. Looks at + /// SecureBoot, PK, KEK, db, dbx, dbt, dbr EFI variables and predicts their measurements to PCR + /// 7 ("secure-boot-policy") on the next boot. + SecureBootPolicy, + + /// Generates a .pcrlock file based on the SecureBoot authorities used to validate the boot + /// path. Uses relevant measurements on PCR 7 ("secure-boot-policy"). + SecureBootAuthority, + + /// Generates a .pcrlock file based on the GPT partition table of the specified disk. If no + /// disk is specified automatically determines the block device backing the root file system. + /// Locks the state of the disk partitioning, which firmware measures to PCR 5 + /// ("boot-loader-config"). + Gpt { + path: Option, + pcrlock_file: PathBuf, + }, + + /// Generates a .pcrlock file based on the specified PE binary. Useful for predicting + /// measurements the firmware makes to PCR 4 ("boot-loader-code") if the specified + /// binary is part of the UEFI boot process. + /// + /// Used for non-UKI images only; UKI binaries must be locked with `lock-uki`. + #[allow(dead_code)] + Pe { + path: PathBuf, + pcrlock_file: PathBuf, + }, + + /// Generates a .pcrlock file based on the specified UKI PE binary. Useful for predicting + /// measurements the firmware makes to PCR 4 ("boot-loader-code"), and `systemd-stub` makes to + /// PCR 11 ("kernel-boot"). Used for UKI images only; non-UKI binaries must be locked with + /// `lock-pe`. + Uki { + path: PathBuf, + pcrlock_file: PathBuf, + }, + + /// Generates a .pcrlock file based on /etc/machine-id. Useful for predicting measurements + /// systemd-pcrmachine.service makes to PCR 15 ("system-identity"). + MachineId, + + /// Generates a .pcrlock file based on file system identity. Useful for predicting measurements + /// systemd-pcrfs@.service makes to PCR 15 ("system-identity") for the root and var + /// filesystems. + FileSystem, + + /// Generates a .pcrlock file based on /proc/cmdline (or the specified file if given). Useful + /// for predicting measurements the Linux kernel makes to PCR 9 ("kernel-initrd"). + KernelCmdline { + path: Option, + pcrlock_file: PathBuf, + }, + + /// Generates a .pcrlock file based on a kernel initrd cpio archive. Useful for predicting + /// measurements the Linux kernel makes to PCR 9 ("kernel-initrd"). Should not be used for + /// `systemd-stub` UKIs, as the initrd is combined dynamically from various sources and hence + /// does not take a single input, like this command. + #[allow(dead_code)] + KernelInitrd { + path: PathBuf, + pcrlock_file: PathBuf, + }, + + /// Generates/removes a .pcrlock file based on raw binary data. The data is either read from + /// the specified file or from STDIN. Requires that `--pcrs=` is specified. The generated + /// .pcrlock file is written to the file specified via `--pcrlock=. + Raw { + path: PathBuf, + pcrs: BitFlags, + pcrlock_file: PathBuf, + }, +} + +impl LockCommand { + /// Returns the name of the subcommand for the `systemd-pcrlock` tool. + fn subcmd_name(&self) -> &'static str { + match self { + Self::FirmwareCode => "lock-firmware-code", + Self::FirmwareConfig => "lock-firmware-config", + Self::SecureBootPolicy => "lock-secureboot-policy", + Self::SecureBootAuthority => "lock-secureboot-authority", + Self::MachineId => "lock-machine-id", + Self::FileSystem => "lock-file-system", + Self::Gpt { .. } => "lock-gpt", + Self::Pe { .. } => "lock-pe", + Self::Uki { .. } => "lock-uki", + Self::KernelCmdline { .. } => "lock-kernel-cmdline", + Self::KernelInitrd { .. } => "lock-kernel-initrd", + Self::Raw { .. } => "lock-raw", + } + } + + /// Runs a `systemd-pcrlock` command. + /// + /// Primarily designed for running the `lock-*` commands. + fn run(&self) -> Result<(), TridentError> { + let (path, pcrlock_file, pcrs) = { + let mut cmd_path: Option = None; + let mut cmd_pcrlock_file: Option = None; + let mut cmd_pcrs: Option> = None; + + match self { + Self::FirmwareCode + | Self::FirmwareConfig + | Self::SecureBootPolicy + | Self::SecureBootAuthority + | Self::MachineId + | Self::FileSystem => (), + + Self::Gpt { path, pcrlock_file } | Self::KernelCmdline { path, pcrlock_file } => { + cmd_path = path.clone(); + cmd_pcrlock_file = Some(pcrlock_file.clone()); + } + + Self::Pe { path, pcrlock_file } + | Self::Uki { path, pcrlock_file } + | Self::KernelInitrd { path, pcrlock_file } => { + cmd_path = Some(path.clone()); + cmd_pcrlock_file = Some(pcrlock_file.clone()); + } + + Self::Raw { + path, + pcrs: raw_pcrs, + pcrlock_file, + } => { + cmd_path = Some(path.clone()); + cmd_pcrlock_file = Some(pcrlock_file.clone()); + cmd_pcrs = Some(*raw_pcrs); + } + } + + (cmd_path, cmd_pcrlock_file, cmd_pcrs) + }; + + let mut cmd = Dependency::SystemdPcrlock.cmd(); + cmd.arg(self.subcmd_name()); + + if let Some(path) = path { + cmd.arg(path); + } + + if let Some(pcrs) = pcrs { + cmd.arg(to_pcr_arg(pcrs)); + } + + if let Some(pcrlock_file) = pcrlock_file { + cmd.arg(format!("--pcrlock={}", pcrlock_file.display())); + } + + cmd.run_and_check().message(format!( + "Failed to run systemd-pcrlock {}", + self.subcmd_name() + )) + } +} + +/// Generates dynamically defined .pcrlock files for either (1) the current boot only or (2) the +/// current and the next boots. Calls the `systemd-pcrlock lock-*` commands to generate the +/// .pcrlock files, as well as helpers to generate the remaining .pcrlock files. +pub fn generate_pcrlock_files( + // Vector containing paths of partitioned disks to measure via lock-gpt, + gpt_disks: Vec>, + // Vector containing paths of PE binaries to measure via lock-pe, + _pe_binaries: Vec, + // Vector containing paths of UKI binaries to measure via lock-uki, + uki_binaries: Vec, + // Vector containing paths of kernel cmdlines to measure via lock-kernel-cmdline, + kernel_cmdlines: Vec>, + // Vector containing paths of kernel initrds to measure via lock-kernel-initrd; not used for + // UKI images, + _kernel_initrds: Vec<(PathBuf, PathBuf)>, + // Vector containing paths of raw binaries and PCRs they're extended to, to measure via + // lock-raw, + raw_binaries: Vec<(PathBuf, BitFlags)>, + // Vector containing paths of systemd-boot binaries to be measured by Trident, + systemd_boot_binaries: Vec, +) -> Result<(), TridentError> { + let basic_cmds: Vec = vec![ + LockCommand::FirmwareCode, + LockCommand::FirmwareConfig, + LockCommand::SecureBootPolicy, + LockCommand::SecureBootAuthority, + LockCommand::MachineId, + LockCommand::FileSystem, + ]; + + for cmd in basic_cmds { + cmd.run()?; + } + + // lock-gpt + for (id, disk_path) in gpt_disks.into_iter().enumerate() { + let pcrlock_file = generate_pcrlock_output_path(GPT_PCRLOCK_DIR, id); + LockCommand::Gpt { + path: disk_path, + pcrlock_file: pcrlock_file.clone(), + } + .run()?; + } + + // lock-uki + for (id, uki_path) in uki_binaries.clone().into_iter().enumerate() { + let pcrlock_file = generate_pcrlock_output_path(UKI_PCRLOCK_DIR, id); + LockCommand::Uki { + path: uki_path, + pcrlock_file: pcrlock_file.clone(), + } + .run()?; + } + + // lock-kernel-cmdline + for (id, kernel_cmdline_path) in kernel_cmdlines.into_iter().enumerate() { + let pcrlock_file = generate_pcrlock_output_path(KERNEL_CMDLINE_PCRLOCK_DIR, id); + LockCommand::KernelCmdline { + path: kernel_cmdline_path, + pcrlock_file: pcrlock_file.clone(), + } + .run()?; + } + + // For now, needed to generate 630-boot-loader-conf.pcrlock.d, which measures the raw binary of + // /boot/efi/loader/loader.conf into PCR 5. + for (id, (raw_binary_path, pcrs)) in raw_binaries.into_iter().enumerate() { + let pcrlock_file = generate_pcrlock_output_path(BOOT_LOADER_CONF_PCRLOCK_DIR, id); + LockCommand::Raw { + path: raw_binary_path, + pcrs, + pcrlock_file: pcrlock_file.clone(), + } + .run()?; + } + + // Run helpers to generate two remaining .pcrlock files + for (id, systemd_boot_path) in systemd_boot_binaries.into_iter().enumerate() { + let pcrlock_file = generate_pcrlock_output_path(BOOT_LOADER_CODE_PCRLOCK_DIR, id); + generate_610_boot_loader_code_pcrlock(systemd_boot_path, pcrlock_file.clone()).structured( + ServicingError::GeneratePcrlockFile { + pcrlock_file: pcrlock_file.display().to_string(), + }, + )?; + } + + for (id, uki_path) in uki_binaries.into_iter().enumerate() { + let pcrlock_file = generate_pcrlock_output_path(KERNEL_INITRD_PCRLOCK_DIR, id); + generate_720_kernel_initrd_pcrlock(uki_path, pcrlock_file.clone()).structured( + ServicingError::GeneratePcrlockFile { + pcrlock_file: pcrlock_file.display().to_string(), + }, + )?; + } + + // Parse the systemd-pcrlock log output to validate that every log entry has been matched to a + // recognized boot component, and thus that all necessary .pcrlock files have been added or + // generated + validate_log().structured(ServicingError::ValidatePcrlockLog)?; + + Ok(()) +} + +/// Represents a single digest entry in a .pcrlock file. +#[derive(Serialize)] +struct DigestEntry<'a> { + hash_alg: &'a str, + digest: String, +} + +/// Represents a single record in a .pcrlock file. +#[derive(Serialize)] +struct Record<'a> { + pcr: u8, + digests: Vec>, +} + +/// Represents a .pcrlock file. +#[derive(Serialize)] +struct PcrLock<'a> { + records: Vec>, +} + +/// Generates a full .pcrlock file path under /var/lib/pcrlock.d, given the sub-dir, e.g. 600-gpt, +/// and the index of the .pcrlock file. This is needed so that each image, current and update, gets +/// its own .pcrlock file. +fn generate_pcrlock_output_path(pcrlock_subdir: &str, index: usize) -> PathBuf { + let base = Path::new(PCRLOCK_DIR).join(pcrlock_subdir); + base.join(format!("generated-{index}.pcrlock")) +} + +/// Generates .pcrlock files under /var/lib/pcrlock.d/610-boot-loader-code.pcrlock.d, where Trident +/// measures the bootloader PE binary, i.e., the `systemd-boot` binary likely under +/// /EFI/BOOT/bootx64.efi, as recorded into PCR 4 following Microsoft's Authenticode hash spec for +/// measuring Windows PE binaries: +/// https://reversea.me/index.php/authenticode-i-understanding-windows-authenticode/. +fn generate_610_boot_loader_code_pcrlock( + systemd_boot_path: PathBuf, + pcrlock_file: PathBuf, +) -> Result<()> { + // Read the entire file into memory + let buffer = fs::read(&systemd_boot_path).with_context(|| { + format!( + "Failed to read PE binary at {}", + systemd_boot_path.display() + ) + })?; + + // Parse PE + let pe = PE::parse(&buffer).with_context(|| { + format!( + "Failed to parse PE binary at {}", + systemd_boot_path.display() + ) + })?; + + // Initialize hashers + let mut sha256 = Sha256::new(); + let mut sha384 = Sha384::new(); + let mut sha512 = Sha512::new(); + + for slice in pe.authenticode_ranges() { + sha256.update(slice); + sha384.update(slice); + sha512.update(slice); + } + + let digests = vec![ + DigestEntry { + hash_alg: "sha256", + digest: format!("{:x}", sha256.finalize()), + }, + DigestEntry { + hash_alg: "sha384", + digest: format!("{:x}", sha384.finalize()), + }, + DigestEntry { + hash_alg: "sha512", + digest: format!("{:x}", sha512.finalize()), + }, + ]; + + // Build PcrLock structure with PCR 4 + let pcrlock = PcrLock { + records: vec![Record { pcr: 4, digests }], + }; + + if let Some(parent) = pcrlock_file.parent() { + fs::create_dir_all(parent).context(format!( + "Failed to create directory for .pcrlock file at {}", + pcrlock_file.display() + ))?; + } + + let json = serde_json::to_string(&pcrlock).context(format!( + "Failed to serialize .pcrlock file {} as JSON", + pcrlock_file.display() + ))?; + fs::write(&pcrlock_file, json).context(format!( + "Failed to write .pcrlock file at {}", + pcrlock_file.display() + ))?; + + Ok(()) +} + +/// Generates .pcrlock files under /var/lib/pcrlock.d/720-kernel-initrd.pcrlock.d, where Trident +/// measures the initrd section of the UKI binary, as recorded into PCR 9. +fn generate_720_kernel_initrd_pcrlock(uki_path: PathBuf, pcrlock_file: PathBuf) -> Result<()> { + // Copy UKI to a temp file + let uki_temp = NamedTempFile::new().context("Failed to create temporary UKI file")?; + fs::copy(&uki_path, uki_temp.path()) + .with_context(|| format!("Failed to copy UKI from {}", uki_path.display()))?; + + // Extract .initrd + let initrd_temp = NamedTempFile::new().context("Failed to create temporary initrd file")?; + let initrd_path = initrd_temp.path().to_path_buf(); + Command::new("objcopy") + .arg("--dump-section") + .arg(format!(".initrd={}", initrd_path.display())) + .arg(uki_temp.path()) + .run_and_check() + .context(format!( + "Failed to execute objcopy to extract initrd section from UKI at '{}'", + uki_temp.path().display() + ))?; + + // Read extracted initrd and compute hashes + let buffer = fs::read(&initrd_path).with_context(|| { + format!( + "Failed to read extracted initrd at {}", + initrd_path.display() + ) + })?; + + let digests = vec![ + DigestEntry { + hash_alg: "sha256", + digest: hex::encode(Sha256::digest(&buffer)), + }, + DigestEntry { + hash_alg: "sha384", + digest: hex::encode(Sha384::digest(&buffer)), + }, + DigestEntry { + hash_alg: "sha512", + digest: hex::encode(Sha512::digest(&buffer)), + }, + ]; + + // Write .pcrlock file + if let Some(parent) = pcrlock_file.parent() { + fs::create_dir_all(parent).context(format!( + "Failed to create directory for .pcrlock file at {}", + pcrlock_file.display() + ))?; + } + + let pcrlock = PcrLock { + records: vec![Record { pcr: 9, digests }], + }; + + let json = serde_json::to_string(&pcrlock).context(format!( + "Failed to serialize .pcrlock file {} as JSON", + pcrlock_file.display() + ))?; + fs::write(&pcrlock_file, json).context(format!( + "Failed to write .pcrlock file at {}", + pcrlock_file.display() + ))?; + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + use enumflags2::make_bitflags; + + #[test] + fn test_to_pcr_arg() { + let pcrs = make_bitflags!(Pcr::{Pcr1 | Pcr4}); + assert_eq!(to_pcr_arg(pcrs), "--pcr=1,4".to_string()); + + let single_pcr = make_bitflags!(Pcr::{Pcr7}); + assert_eq!(to_pcr_arg(single_pcr), "--pcr=7".to_string()); + + let all_pcrs = BitFlags::::all(); + assert_eq!( + to_pcr_arg(all_pcrs), + "--pcr=0,1,2,3,4,5,7,9,10,11,12,13,14,15,16,23".to_string() + ); + } + + #[test] + fn test_generate_pcrlock_output_path() { + let index = 0; + let expected_path = Path::new(PCRLOCK_DIR) + .join(GPT_PCRLOCK_DIR) + .join(format!("generated-{index}.pcrlock")); + assert_eq!( + generate_pcrlock_output_path(GPT_PCRLOCK_DIR, index), + expected_path + ); + } +} + +#[cfg(feature = "functional-test")] +#[cfg_attr(not(test), allow(unused_imports, dead_code))] +mod functional_test { + use super::*; + + use pytest_gen::functional_test; + + use trident_api::error::ErrorKind; + + #[functional_test(feature = "helpers")] + fn test_generate_tpm2_access_policy() { + // Test case #0. Since no pcrlock files have been generated yet, only 0-valued PCRs can be + // used to generate a TPM 2.0 access policy. + let zero_pcrs = make_bitflags!(Pcr::{Pcr11 | Pcr12 | Pcr13}); + generate_tpm2_access_policy(zero_pcrs).unwrap(); + + // Test case #1. Try to generate a TPM 2.0 access policy with all PCRs; should return an + // error since no pcrlock files have been generated yet. + let pcrs = BitFlags::::all(); + assert_eq!( + generate_tpm2_access_policy(pcrs).unwrap_err().kind(), + &ErrorKind::Servicing(ServicingError::GenerateTpm2AccessPolicy) + ); + + // TODO: Add other/more test cases once helpers are implemented and statically defined + // pcrlock files have been added. + } + + #[functional_test(feature = "helpers")] + fn test_validate_log() { + // TODO: This test will fail for now since .pcrlock files have not been generated/added + // yet. Once static .pcrlock files are added and dynamic files are generated, the test + // should pass. + validate_log().unwrap_err(); + } +} diff --git a/osutils/src/sfdisk.rs b/osutils/src/sfdisk.rs index ff041bbf4..caa9940c9 100644 --- a/osutils/src/sfdisk.rs +++ b/osutils/src/sfdisk.rs @@ -398,55 +398,48 @@ mod functional_test { /// /// ```json /// { - /// "partitiontable": { - /// "label": "gpt", - /// "id": "71F5C3EB-6D53-414B-9FF4-0953E6291577", - /// "device": "/dev/sda", - /// "unit": "sectors", - /// "firstlba": 2048, - /// "lastlba": 33554398, - /// "sectorsize": 512, - /// "partitions": [ - /// { - /// "node": "/dev/sda1", - /// "start": 2048, - /// "size": 102400, - /// "type": "C12A7328-F81F-11D2-BA4B-00A0C93EC93B", - /// "uuid": "8D738FD1-9B6F-4B6D-B174-021954453D68", - /// "name": "esp" - /// },{ - /// "node": "/dev/sda2", - /// "start": 104448, - /// "size": 8388608, - /// "type": "4F68BCE3-E8CD-4DB1-96E7-FBCAF984B709", - /// "uuid": "71982B79-7759-449F-8D68-ACA7625AC1E2", - /// "name": "root-a", - /// "attrs": "GUID:59" - /// },{ - /// "node": "/dev/sda3", - /// "start": 8493056, - /// "size": 8388608, - /// "type": "4F68BCE3-E8CD-4DB1-96E7-FBCAF984B709", - /// "uuid": "1864172F-3594-4F7A-B447-EBCA0B115DC6", - /// "name": "root-b", - /// "attrs": "GUID:59" - /// },{ - /// "node": "/dev/sda4", - /// "start": 16881664, - /// "size": 4194304, - /// "type": "0657FD6D-A4AB-43C4-84E5-0933C84B4F4F", - /// "uuid": "ED608DB8-58D6-484B-B309-B03CD3615037", - /// "name": "swap" - /// },{ - /// "node": "/dev/sda5", - /// "start": 21075968, - /// "size": 204800, - /// "type": "0FC63DAF-8483-4772-8E79-3D69D8477DE4", - /// "uuid": "7DE2DA6E-4512-4091-B0B7-EC432DA971AA", - /// "name": "trident" - /// } - /// ] - /// } + /// "partitiontable": { + /// "label": "gpt", + /// "id": "BC1DB325-B1C0-4D6F-B98F-F9F24AB1C8EF", + /// "device": "/dev/sda", + /// "unit": "sectors", + /// "firstlba": 2048, + /// "lastlba": 21282782, + /// "sectorsize": 512, + /// "partitions": [ + /// { + /// "node": "/dev/sda1", + /// "start": 2048, + /// "size": 102400, + /// "type": "C12A7328-F81F-11D2-BA4B-00A0C93EC93B", + /// "uuid": "" + /// },{ + /// "node": "/dev/sda2", + /// "start": 104448, + /// "size": 8388608, + /// "type": "4F68BCE3-E8CD-4DB1-96E7-FBCAF984B709", + /// "uuid": "" + /// },{ + /// "node": "/dev/sda3", + /// "start": 8493056, + /// "size": 8388608, + /// "type": "4F68BCE3-E8CD-4DB1-96E7-FBCAF984B709", + /// "uuid": "" + /// },{ + /// "node": "/dev/sda4", + /// "start": 16881664, + /// "size": 4194304, + /// "type": "0657FD6D-A4AB-43C4-84E5-0933C84B4F4F", + /// "uuid": "" + /// },{ + /// "node": "/dev/sda5", + /// "start": 21075968, + /// "size": 204800, + /// "type": "0FC63DAF-8483-4772-8E79-3D69D8477DE4", + /// "uuid": "" + /// } + /// ] + /// } /// } /// ``` /// @@ -458,9 +451,9 @@ mod functional_test { assert_eq!(disk.unit, SfDiskUnit::Sectors); print!("disk: {:#?}", disk); assert_eq!(disk.firstlba, 2048); - assert_eq!(disk.lastlba, 33554398); + assert_eq!(disk.lastlba, 31457246); assert_eq!(disk.sectorsize, 512); - assert_eq!(disk.capacity, 17_178_803_712); + assert_eq!(disk.capacity, 16_105_061_888); assert_eq!(disk.partitions.len(), 5); let expected_partitions = [ diff --git a/osutils/src/testutils/repart.rs b/osutils/src/testutils/repart.rs index 63bf852cc..9700b91af 100644 --- a/osutils/src/testutils/repart.rs +++ b/osutils/src/testutils/repart.rs @@ -3,8 +3,11 @@ use std::{ffi::OsString, path::Path}; use anyhow::Error; use sysdefs::partition_types::DiscoverablePartitionType; +use tempfile::NamedTempFile; -use crate::{dependencies::Dependency, repart::RepartPartitionEntry}; +use crate::{ + dependencies::Dependency, filesystems::MkfsFileSystemType, mkfs, repart::RepartPartitionEntry, +}; pub const DISK_SIZE: u64 = 16 * 1024 * 1024 * 1024; // 16 GiB pub const PART1_SIZE: u64 = 50 * 1024 * 1024; // 50 MiB @@ -15,8 +18,6 @@ pub const SIZE_100MIB: u64 = 100 * 1024 * 1024; pub const OS_DISK_DEVICE_PATH: &str = "/dev/sda"; pub const TEST_DISK_DEVICE_PATH: &str = "/dev/sdb"; -pub const CDROM_DEVICE_PATH: &str = "/dev/sr0"; -pub const CDROM_MOUNT_PATH: &str = "/mnt/cdrom"; pub fn generate_partition_definition_esp_generic() -> Vec { vec![ @@ -191,3 +192,11 @@ pub fn clear_disk(disk_path: impl AsRef) -> Result<(), Error> { .run_and_check()?; Ok(()) } + +/// Generate a temporary file with the given filesystem type on it. +pub fn make_loopback_filesystem(filesystem_type: MkfsFileSystemType) -> NamedTempFile { + let loopback = NamedTempFile::new().unwrap(); + loopback.as_file().set_len(5 * 1024 * 1024).unwrap(); + mkfs::run(loopback.path(), filesystem_type).unwrap(); + loopback +} diff --git a/packaging/static-pcrlock-files/350-action-efi-application.pcrlock b/packaging/static-pcrlock-files/350-action-efi-application.pcrlock new file mode 100644 index 000000000..2baaa9ceb --- /dev/null +++ b/packaging/static-pcrlock-files/350-action-efi-application.pcrlock @@ -0,0 +1 @@ +{"records":[{"pcr":4,"digests":[{"hashAlg":"sha1","digest":"cd0fdb4531a6ec41be2753ba042637d6e5f7f256"},{"hashAlg":"sha256","digest":"3d6772b4f84ed47595d72a2c4c5ffd15f5bb72c7507fe26f2aaee2c69d5633ba"},{"hashAlg":"sha384","digest":"77a0dab2312b4e1e57a84d865a21e5b2ee8d677a21012ada819d0a98988078d3d740f6346bfe0abaa938ca20439a8d71"},{"hashAlg":"sha512","digest":"03020279c5ea3676d6630c82a9931343225e8eab81529b65c786aeb6a445d3852a34dd193178f938b6b47345a72d4b647df309c971f7c02f0ede296a136a1086"}]}]} diff --git a/packaging/static-pcrlock-files/400-secureboot-separator.pcrlock.d/300-0x00000000.pcrlock b/packaging/static-pcrlock-files/400-secureboot-separator.pcrlock.d/300-0x00000000.pcrlock new file mode 100644 index 000000000..c577c9874 --- /dev/null +++ b/packaging/static-pcrlock-files/400-secureboot-separator.pcrlock.d/300-0x00000000.pcrlock @@ -0,0 +1 @@ +{"records":[{"pcr":7,"digests":[{"hashAlg":"sha1","digest":"9069ca78e7450a285173431b3e52c5c25299e473"},{"hashAlg":"sha256","digest":"df3f619804a92fdb4057192dc43dd748ea778adc52bc498ce80524c014b81119"},{"hashAlg":"sha384","digest":"394341b7182cd227c5c6b07ef8000cdfd86136c4292b8e576573ad7ed9ae41019f5818b4b971c9effc60e1ad9f1289f0"},{"hashAlg":"sha512","digest":"ec2d57691d9b2d40182ac565032054b7d784ba96b18bcb5be0bb4e70e3fb041eff582c8af66ee50256539f2181d7f9e53627c0189da7e75a4d5ef10ea93b20b3"}]}]} diff --git a/packaging/static-pcrlock-files/400-secureboot-separator.pcrlock.d/600-0xffffffff.pcrlock b/packaging/static-pcrlock-files/400-secureboot-separator.pcrlock.d/600-0xffffffff.pcrlock new file mode 100644 index 000000000..2e86898c9 --- /dev/null +++ b/packaging/static-pcrlock-files/400-secureboot-separator.pcrlock.d/600-0xffffffff.pcrlock @@ -0,0 +1 @@ +{"records":[{"pcr":7,"digests":[{"hashAlg":"sha1","digest":"d9be6524a5f5047db5866813acf3277892a7a30a"},{"hashAlg":"sha256","digest":"ad95131bc0b799c0b1af477fb14fcf26a6a9f76079e48bf090acb7e8367bfd0e"},{"hashAlg":"sha384","digest":"4a06b879c7eedbe01c945d46b5bd785b59203dce81ea6a1206c28091ca285365f760d9167778f0dc1763d4854aafd40a"},{"hashAlg":"sha512","digest":"ea71bb243b0b2db729b9eb88e3c55a3f490fbff23457825051224a1fe6e6d3f480590cfa3a4a6b12c622d6ac366feb03cd17004ed004cb3f0d52731626946679"}]}]} diff --git a/packaging/static-pcrlock-files/500-separator.pcrlock.d/300-0x00000000.pcrlock b/packaging/static-pcrlock-files/500-separator.pcrlock.d/300-0x00000000.pcrlock new file mode 100644 index 000000000..f1e473f1a --- /dev/null +++ b/packaging/static-pcrlock-files/500-separator.pcrlock.d/300-0x00000000.pcrlock @@ -0,0 +1 @@ +{"records":[{"pcr":0,"digests":[{"hashAlg":"sha1","digest":"9069ca78e7450a285173431b3e52c5c25299e473"},{"hashAlg":"sha256","digest":"df3f619804a92fdb4057192dc43dd748ea778adc52bc498ce80524c014b81119"},{"hashAlg":"sha384","digest":"394341b7182cd227c5c6b07ef8000cdfd86136c4292b8e576573ad7ed9ae41019f5818b4b971c9effc60e1ad9f1289f0"},{"hashAlg":"sha512","digest":"ec2d57691d9b2d40182ac565032054b7d784ba96b18bcb5be0bb4e70e3fb041eff582c8af66ee50256539f2181d7f9e53627c0189da7e75a4d5ef10ea93b20b3"}]},{"pcr":1,"digests":[{"hashAlg":"sha1","digest":"9069ca78e7450a285173431b3e52c5c25299e473"},{"hashAlg":"sha256","digest":"df3f619804a92fdb4057192dc43dd748ea778adc52bc498ce80524c014b81119"},{"hashAlg":"sha384","digest":"394341b7182cd227c5c6b07ef8000cdfd86136c4292b8e576573ad7ed9ae41019f5818b4b971c9effc60e1ad9f1289f0"},{"hashAlg":"sha512","digest":"ec2d57691d9b2d40182ac565032054b7d784ba96b18bcb5be0bb4e70e3fb041eff582c8af66ee50256539f2181d7f9e53627c0189da7e75a4d5ef10ea93b20b3"}]},{"pcr":2,"digests":[{"hashAlg":"sha1","digest":"9069ca78e7450a285173431b3e52c5c25299e473"},{"hashAlg":"sha256","digest":"df3f619804a92fdb4057192dc43dd748ea778adc52bc498ce80524c014b81119"},{"hashAlg":"sha384","digest":"394341b7182cd227c5c6b07ef8000cdfd86136c4292b8e576573ad7ed9ae41019f5818b4b971c9effc60e1ad9f1289f0"},{"hashAlg":"sha512","digest":"ec2d57691d9b2d40182ac565032054b7d784ba96b18bcb5be0bb4e70e3fb041eff582c8af66ee50256539f2181d7f9e53627c0189da7e75a4d5ef10ea93b20b3"}]},{"pcr":3,"digests":[{"hashAlg":"sha1","digest":"9069ca78e7450a285173431b3e52c5c25299e473"},{"hashAlg":"sha256","digest":"df3f619804a92fdb4057192dc43dd748ea778adc52bc498ce80524c014b81119"},{"hashAlg":"sha384","digest":"394341b7182cd227c5c6b07ef8000cdfd86136c4292b8e576573ad7ed9ae41019f5818b4b971c9effc60e1ad9f1289f0"},{"hashAlg":"sha512","digest":"ec2d57691d9b2d40182ac565032054b7d784ba96b18bcb5be0bb4e70e3fb041eff582c8af66ee50256539f2181d7f9e53627c0189da7e75a4d5ef10ea93b20b3"}]},{"pcr":4,"digests":[{"hashAlg":"sha1","digest":"9069ca78e7450a285173431b3e52c5c25299e473"},{"hashAlg":"sha256","digest":"df3f619804a92fdb4057192dc43dd748ea778adc52bc498ce80524c014b81119"},{"hashAlg":"sha384","digest":"394341b7182cd227c5c6b07ef8000cdfd86136c4292b8e576573ad7ed9ae41019f5818b4b971c9effc60e1ad9f1289f0"},{"hashAlg":"sha512","digest":"ec2d57691d9b2d40182ac565032054b7d784ba96b18bcb5be0bb4e70e3fb041eff582c8af66ee50256539f2181d7f9e53627c0189da7e75a4d5ef10ea93b20b3"}]},{"pcr":5,"digests":[{"hashAlg":"sha1","digest":"9069ca78e7450a285173431b3e52c5c25299e473"},{"hashAlg":"sha256","digest":"df3f619804a92fdb4057192dc43dd748ea778adc52bc498ce80524c014b81119"},{"hashAlg":"sha384","digest":"394341b7182cd227c5c6b07ef8000cdfd86136c4292b8e576573ad7ed9ae41019f5818b4b971c9effc60e1ad9f1289f0"},{"hashAlg":"sha512","digest":"ec2d57691d9b2d40182ac565032054b7d784ba96b18bcb5be0bb4e70e3fb041eff582c8af66ee50256539f2181d7f9e53627c0189da7e75a4d5ef10ea93b20b3"}]},{"pcr":6,"digests":[{"hashAlg":"sha1","digest":"9069ca78e7450a285173431b3e52c5c25299e473"},{"hashAlg":"sha256","digest":"df3f619804a92fdb4057192dc43dd748ea778adc52bc498ce80524c014b81119"},{"hashAlg":"sha384","digest":"394341b7182cd227c5c6b07ef8000cdfd86136c4292b8e576573ad7ed9ae41019f5818b4b971c9effc60e1ad9f1289f0"},{"hashAlg":"sha512","digest":"ec2d57691d9b2d40182ac565032054b7d784ba96b18bcb5be0bb4e70e3fb041eff582c8af66ee50256539f2181d7f9e53627c0189da7e75a4d5ef10ea93b20b3"}]}]} diff --git a/packaging/static-pcrlock-files/500-separator.pcrlock.d/600-0xffffffff.pcrlock b/packaging/static-pcrlock-files/500-separator.pcrlock.d/600-0xffffffff.pcrlock new file mode 100644 index 000000000..0b8d20b9c --- /dev/null +++ b/packaging/static-pcrlock-files/500-separator.pcrlock.d/600-0xffffffff.pcrlock @@ -0,0 +1 @@ +{"records":[{"pcr":0,"digests":[{"hashAlg":"sha1","digest":"d9be6524a5f5047db5866813acf3277892a7a30a"},{"hashAlg":"sha256","digest":"ad95131bc0b799c0b1af477fb14fcf26a6a9f76079e48bf090acb7e8367bfd0e"},{"hashAlg":"sha384","digest":"4a06b879c7eedbe01c945d46b5bd785b59203dce81ea6a1206c28091ca285365f760d9167778f0dc1763d4854aafd40a"},{"hashAlg":"sha512","digest":"ea71bb243b0b2db729b9eb88e3c55a3f490fbff23457825051224a1fe6e6d3f480590cfa3a4a6b12c622d6ac366feb03cd17004ed004cb3f0d52731626946679"}]},{"pcr":1,"digests":[{"hashAlg":"sha1","digest":"d9be6524a5f5047db5866813acf3277892a7a30a"},{"hashAlg":"sha256","digest":"ad95131bc0b799c0b1af477fb14fcf26a6a9f76079e48bf090acb7e8367bfd0e"},{"hashAlg":"sha384","digest":"4a06b879c7eedbe01c945d46b5bd785b59203dce81ea6a1206c28091ca285365f760d9167778f0dc1763d4854aafd40a"},{"hashAlg":"sha512","digest":"ea71bb243b0b2db729b9eb88e3c55a3f490fbff23457825051224a1fe6e6d3f480590cfa3a4a6b12c622d6ac366feb03cd17004ed004cb3f0d52731626946679"}]},{"pcr":2,"digests":[{"hashAlg":"sha1","digest":"d9be6524a5f5047db5866813acf3277892a7a30a"},{"hashAlg":"sha256","digest":"ad95131bc0b799c0b1af477fb14fcf26a6a9f76079e48bf090acb7e8367bfd0e"},{"hashAlg":"sha384","digest":"4a06b879c7eedbe01c945d46b5bd785b59203dce81ea6a1206c28091ca285365f760d9167778f0dc1763d4854aafd40a"},{"hashAlg":"sha512","digest":"ea71bb243b0b2db729b9eb88e3c55a3f490fbff23457825051224a1fe6e6d3f480590cfa3a4a6b12c622d6ac366feb03cd17004ed004cb3f0d52731626946679"}]},{"pcr":3,"digests":[{"hashAlg":"sha1","digest":"d9be6524a5f5047db5866813acf3277892a7a30a"},{"hashAlg":"sha256","digest":"ad95131bc0b799c0b1af477fb14fcf26a6a9f76079e48bf090acb7e8367bfd0e"},{"hashAlg":"sha384","digest":"4a06b879c7eedbe01c945d46b5bd785b59203dce81ea6a1206c28091ca285365f760d9167778f0dc1763d4854aafd40a"},{"hashAlg":"sha512","digest":"ea71bb243b0b2db729b9eb88e3c55a3f490fbff23457825051224a1fe6e6d3f480590cfa3a4a6b12c622d6ac366feb03cd17004ed004cb3f0d52731626946679"}]},{"pcr":4,"digests":[{"hashAlg":"sha1","digest":"d9be6524a5f5047db5866813acf3277892a7a30a"},{"hashAlg":"sha256","digest":"ad95131bc0b799c0b1af477fb14fcf26a6a9f76079e48bf090acb7e8367bfd0e"},{"hashAlg":"sha384","digest":"4a06b879c7eedbe01c945d46b5bd785b59203dce81ea6a1206c28091ca285365f760d9167778f0dc1763d4854aafd40a"},{"hashAlg":"sha512","digest":"ea71bb243b0b2db729b9eb88e3c55a3f490fbff23457825051224a1fe6e6d3f480590cfa3a4a6b12c622d6ac366feb03cd17004ed004cb3f0d52731626946679"}]},{"pcr":5,"digests":[{"hashAlg":"sha1","digest":"d9be6524a5f5047db5866813acf3277892a7a30a"},{"hashAlg":"sha256","digest":"ad95131bc0b799c0b1af477fb14fcf26a6a9f76079e48bf090acb7e8367bfd0e"},{"hashAlg":"sha384","digest":"4a06b879c7eedbe01c945d46b5bd785b59203dce81ea6a1206c28091ca285365f760d9167778f0dc1763d4854aafd40a"},{"hashAlg":"sha512","digest":"ea71bb243b0b2db729b9eb88e3c55a3f490fbff23457825051224a1fe6e6d3f480590cfa3a4a6b12c622d6ac366feb03cd17004ed004cb3f0d52731626946679"}]},{"pcr":6,"digests":[{"hashAlg":"sha1","digest":"d9be6524a5f5047db5866813acf3277892a7a30a"},{"hashAlg":"sha256","digest":"ad95131bc0b799c0b1af477fb14fcf26a6a9f76079e48bf090acb7e8367bfd0e"},{"hashAlg":"sha384","digest":"4a06b879c7eedbe01c945d46b5bd785b59203dce81ea6a1206c28091ca285365f760d9167778f0dc1763d4854aafd40a"},{"hashAlg":"sha512","digest":"ea71bb243b0b2db729b9eb88e3c55a3f490fbff23457825051224a1fe6e6d3f480590cfa3a4a6b12c622d6ac366feb03cd17004ed004cb3f0d52731626946679"}]}]} diff --git a/packaging/static-pcrlock-files/700-action-efi-exit-boot-services.pcrlock.d/300-present.pcrlock b/packaging/static-pcrlock-files/700-action-efi-exit-boot-services.pcrlock.d/300-present.pcrlock new file mode 100644 index 000000000..d7012df2e --- /dev/null +++ b/packaging/static-pcrlock-files/700-action-efi-exit-boot-services.pcrlock.d/300-present.pcrlock @@ -0,0 +1 @@ +{"records":[{"pcr":5,"digests":[{"hashAlg":"sha1","digest":"443a6b7b82b7af564f2e393cd9d5a388b7fa4a98"},{"hashAlg":"sha256","digest":"d8043d6b7b85ad358eb3b6ae6a873ab7ef23a26352c5dc4faa5aeedacf5eb41b"},{"hashAlg":"sha384","digest":"214b0bef1379756011344877743fdc2a5382bac6e70362d624ccf3f654407c1b4badf7d8f9295dd3dabdef65b27677e0"},{"hashAlg":"sha512","digest":"0fed3a4c9552021436534d27f3adb481e22b50b29e4b37a63f518540a651a174f149b69f500b0bdb2cb3bf4e0e21e0781451090af33e88f6bee4cbebd15c1668"}]},{"pcr":5,"digests":[{"hashAlg":"sha1","digest":"475545ddc978d7bfd036facc7e2e987f48189f0d"},{"hashAlg":"sha256","digest":"b54f7542cbd872a81a9d9dea839b2b8d747c7ebd5ea6615c40f42f44a6dbeba0"},{"hashAlg":"sha384","digest":"0a2e01c85deae718a530ad8c6d20a84009babe6c8989269e950d8cf440c6e997695e64d455c4174a652cd080f6230b74"},{"hashAlg":"sha512","digest":"1bb30cdbd6da78fe2a8a161ef51176e22d64dce305b40b47243673af64a2b16fca6182116433e3891be94773f6d7d411275721d5bf7d40ea51a274d5c891637c"}]}]} diff --git a/packaging/static-pcrlock-files/700-action-efi-exit-boot-services.pcrlock.d/600-absent.pcrlock b/packaging/static-pcrlock-files/700-action-efi-exit-boot-services.pcrlock.d/600-absent.pcrlock new file mode 100644 index 000000000..a16142b6e --- /dev/null +++ b/packaging/static-pcrlock-files/700-action-efi-exit-boot-services.pcrlock.d/600-absent.pcrlock @@ -0,0 +1 @@ +{"records":[]} diff --git a/packaging/static-pcrlock-files/750-enter-initrd.pcrlock b/packaging/static-pcrlock-files/750-enter-initrd.pcrlock new file mode 100644 index 000000000..a2332dc32 --- /dev/null +++ b/packaging/static-pcrlock-files/750-enter-initrd.pcrlock @@ -0,0 +1 @@ +{"records":[{"pcr":11,"digests":[{"hashAlg":"sha1","digest":"b1b01d5f73f321eb70e76f8a0e241ac0a3fa4a6e"},{"hashAlg":"sha256","digest":"51e6b92f405d1f98d96e3de343d61d420ad6923b25de21d766f9298192f14fed"},{"hashAlg":"sha384","digest":"687eef3a3a8c716439b5ed583657e8668401630c321f2f35d19b953ddf20b68a96474d0c2e5f0e1757bfa5ba70b9fc32"},{"hashAlg":"sha512","digest":"ab0ddfdabe43f1d06b3e58fbe17439a0f7f552e9e228d85665d485ececf7e733bae4cd7e0a17e5456e2ee7e412f5a0f37de05a782cce781e173ee26958de7f30"}]}]} diff --git a/packaging/static-pcrlock-files/800-leave-initrd.pcrlock b/packaging/static-pcrlock-files/800-leave-initrd.pcrlock new file mode 100644 index 000000000..bd8f436a7 --- /dev/null +++ b/packaging/static-pcrlock-files/800-leave-initrd.pcrlock @@ -0,0 +1 @@ +{"records":[{"pcr":11,"digests":[{"hashAlg":"sha1","digest":"865e1ff2cc5b8db815313b23fe3d8b561212f5d1"},{"hashAlg":"sha256","digest":"3be261aff7db92bf507eae947f4003ffa2bcad0bffe3524601d62d0bc8be7135"},{"hashAlg":"sha384","digest":"9c0743b7a2e1ee06c70b7137b763cd2205c26ced274149959b05bd5a51bfa96b4fedaa4f87398b5c88986d1ff0879910"},{"hashAlg":"sha512","digest":"01b8ca86b9f8fac967f383380aff7cdffd2ef0c496574517c25398f7c74aa611821dd469ba021b2aa9b9a7232865708ca45c79368f2e7fffda3dd6b308264008"}]}]} diff --git a/packaging/static-pcrlock-files/850-sysinit.pcrlock b/packaging/static-pcrlock-files/850-sysinit.pcrlock new file mode 100644 index 000000000..3bae4452f --- /dev/null +++ b/packaging/static-pcrlock-files/850-sysinit.pcrlock @@ -0,0 +1 @@ +{"records":[{"pcr":11,"digests":[{"hashAlg":"sha1","digest":"aeabcf402223916e804cce79778a55d5a9276983"},{"hashAlg":"sha256","digest":"730bb5a583ba880c277e656d2dc8aba1a314a11b14d25b05153d2bab82567a48"},{"hashAlg":"sha384","digest":"955cc8939f81d862b3119aabe612fd36bf91668bb62397f5e4126085d79ba6d7cbfa4e3a2345747f0b476ce4b1cbc2c9"},{"hashAlg":"sha512","digest":"a9eb62cdd1cd8292b6325a8ee3770d6f1b613426a749e17ffba8f90bdd6c41806468fb79d01276de7cc791877dfebae165d4ed07585154acf96652c6db92acc1"}]}]} diff --git a/packaging/static-pcrlock-files/900-ready.pcrlock b/packaging/static-pcrlock-files/900-ready.pcrlock new file mode 100644 index 000000000..9a0e82f6e --- /dev/null +++ b/packaging/static-pcrlock-files/900-ready.pcrlock @@ -0,0 +1 @@ +{"records":[{"pcr":11,"digests":[{"hashAlg":"sha1","digest":"75c0533730caf1f78561c0883fb87bc8d98ef04b"},{"hashAlg":"sha256","digest":"b24d6d33736ecd5604a4b17bc9c6481039fac362bb7df044ef1c10a2bfd21db6"},{"hashAlg":"sha384","digest":"23ed5781da39fe6dc17f79478aeeb9eb2bca1d776061da188e10f9c85f7933fb39cfdba50f39af8aed24e5b45b80d006"},{"hashAlg":"sha512","digest":"ca6616f94a209e53f6fdc526b473172eb4b2157cf4809c31e36ad52db614ed352e68407be53c238ba17a561c4fde43f4a859aa8711f9781a0c934296d4d7571b"}]}]} diff --git a/packaging/static-pcrlock-files/950-shutdown.pcrlock b/packaging/static-pcrlock-files/950-shutdown.pcrlock new file mode 100644 index 000000000..1bc3f76e0 --- /dev/null +++ b/packaging/static-pcrlock-files/950-shutdown.pcrlock @@ -0,0 +1 @@ +{"records":[{"pcr":11,"digests":[{"hashAlg":"sha1","digest":"53669f193b2174641c72654b5c3e5b67950334ae"},{"hashAlg":"sha256","digest":"08434ba9cdf55a02284e2913400586cd289878e0f055f7bb0b07ce392caeb989"},{"hashAlg":"sha384","digest":"186e2d6603b9755221b7ef894dd52b1154b48ef4786aec06ab6f7709e639715e89bd59fa80736bb45f0ca88583c212c1"},{"hashAlg":"sha512","digest":"9e5549deb36fc48768cb80e03bc91c36cf549ff5921e05bab5b68faefda7fac8c8a0755db783cbf1c1b98c80dc22ef06ff3f4a0a16704749f5cd4acf40e42a94"}]}]} diff --git a/packaging/static-pcrlock-files/990-final.pcrlock b/packaging/static-pcrlock-files/990-final.pcrlock new file mode 100644 index 000000000..77081ae75 --- /dev/null +++ b/packaging/static-pcrlock-files/990-final.pcrlock @@ -0,0 +1 @@ +{"records":[{"pcr":11,"digests":[{"hashAlg":"sha1","digest":"d594c2cc0a53025004791399d80e20852af4c988"},{"hashAlg":"sha256","digest":"2443630b4620165c8b173e7265e17526fe2787ae594364dd6d839ad58f2fc007"},{"hashAlg":"sha384","digest":"90697eec39ed47f2b7ed278aa6fe6a1c073fcc7f3af54299fb95ac8a18c771acbac71e25b5a5639554943bfdfab76737"},{"hashAlg":"sha512","digest":"b3d9598ca0aa5da28be1c97a45d53cc5c72a80e61c439c8bf3e89c5c0661f49df8fa34019a21cd5e31261ae3a3a87ef4592d8010aad6a5ecdc9dbaae38cd1470"}]}]} diff --git a/scripts/compare-cosi.py b/scripts/compare-cosi.py new file mode 100755 index 000000000..b836523ac --- /dev/null +++ b/scripts/compare-cosi.py @@ -0,0 +1,475 @@ +#!/usr/bin/env python3 + +""" +A script to thoroughly compare two COSI files. +It extracts the contents of the COSI files, compares the files and directories, +and generates a report of the differences. + +If it finds a UKI file in both with different content, it will extract the initrd +and compare the contents of the initrd files. + +It optionally outputs the diff trees to a specified directory for further analysis. + +Requires: +- Python 3.12 (I think) or higher +- pefile + +Usage: + python3 compare-cosi.py [-o ] +""" + +import argparse +from contextlib import contextmanager +from dataclasses import dataclass +import gzip +import hashlib +import logging +import json +import shutil +from pathlib import Path +import tarfile +import tempfile +from typing import Dict, Generator, List, Optional, Tuple +import subprocess +from io import StringIO + +# Set up logging +logging.basicConfig(level=logging.DEBUG) +log = logging.getLogger("compare-cosi") + +try: + import pefile +except ImportError: + log.critical( + "pefile is not installed. Please install it using 'pip install pefile'." + ) + exit(1) + + +def parse_args(): + parser = argparse.ArgumentParser( + description="Compare two COSI files and optionally write results to a file." + ) + parser.add_argument("cosi_file_a", type=Path, help="Path to the first COSI file.") + parser.add_argument("cosi_file_b", type=Path, help="Path to the second COSI file.") + parser.add_argument( + "-o", + "--output", + type=Path, + default=None, + help="Optional path to write the comparison results.", + ) + return parser.parse_args() + + +@contextmanager +def cosi_extractor(cosi_file_path: Path) -> "Generator[Path, None, None]": + """ + Extracts the given COSI file (assumed to be a zip archive) to the specified mount path. + If the mount path does not exist, it will be created. + """ + + with tempfile.TemporaryDirectory(prefix=f"{cosi_file_path.name}-") as work_dir: + work_path = Path(work_dir) + log.debug(f"Temporary work directory for '{cosi_file_path}': '{work_path}'") + + mounted = [] + + try: + yield extract_and_mount_cosi(mounted, cosi_file_path, work_path) + finally: + for mount_point in reversed(mounted): + log.debug(f"Unmounting {mount_point}") + # Unmount the image + unmount_command = ["umount", str(mount_point)] + subprocess.run(unmount_command, check=True) + + +def extract_and_mount_cosi( + mounted: List[Path], + cosi_file_path: Path, + work_path: Path, +) -> Path: + if not work_path.exists(): + raise FileNotFoundError(f"Work path does not exist: {work_path}") + + extract_path = work_path / "extracted" + decompression_path = work_path / "decompressed" + + with open(cosi_file_path, "rb") as cosi_file: + with tarfile.open(fileobj=cosi_file, mode="r:*") as tar: + tar.extractall(path=extract_path, filter="data") + + metadata_path = extract_path / "metadata.json" + if not metadata_path.exists(): + raise FileNotFoundError(f"Metadata file does not exist: {metadata_path}") + log.info(f"Extracted COSI file to: {extract_path}") + + with open(metadata_path, "r") as metadata_file: + metadata = json.load(metadata_file) + + root_path = work_path / "root" + root_path.mkdir(parents=True, exist_ok=True) + + # Mount all images + image_mount: List[Tuple[Path, Path]] = [ + (Path(image["mountPoint"]), Path(image["image"]["path"])) + for image in metadata["images"] + ] + + image_mount.sort(key=lambda x: len(x[0].parts)) + + for mount_point, image_path in image_mount: + effective_mount_point = root_path / mount_point.relative_to("/") + + # VERY IMPORTANT SAFETY CHECKS TO AVOID WEIRD ISSUES! + assert effective_mount_point.is_absolute() + # Python 3.8 compatibility: check if effective_mount_point is under root_path + try: + effective_mount_point.relative_to(root_path) + except ValueError: + raise AssertionError("Mount point must be inside root_path") + + mount_point.mkdir(parents=True, exist_ok=True) + image_path = extract_path / image_path + if not image_path.exists(): + raise FileNotFoundError(f"Image path does not exist: {image_path}") + + # Decompress the image file using zstd + decompressed_image_path = decompression_path / image_path.relative_to( + extract_path + ).with_suffix(".raw") + + decompressed_image_path.parent.mkdir(parents=True, exist_ok=True) + subprocess.run( + ["zstd", "-d", "-f", str(image_path), "-o", str(decompressed_image_path)], + check=True, + ) + + log.debug(f"Mounting {decompressed_image_path} to {effective_mount_point}") + + if not effective_mount_point.exists(): + effective_mount_point.mkdir(parents=True, exist_ok=True) + + # Mount the decompressed image + mount_command = [ + "mount", + "-o", + "loop", + str(decompressed_image_path), + str(effective_mount_point), + ] + log.debug(f"Mounting {decompressed_image_path} to {effective_mount_point}") + subprocess.run(mount_command, check=True) + mounted.append(effective_mount_point) + + return root_path + + +def hash_file(path: Path) -> str: + """Return SHA256 hash of file contents.""" + h = hashlib.sha256() + with open(path, "rb") as f: + for chunk in iter(lambda: f.read(8192), b""): + h.update(chunk) + return h.hexdigest() + + +class TreeCompare: + def __init__(self, tree_a: Path, tree_b: Path): + self.tree_a = tree_a + self.tree_b = tree_b + self.same_files: List[Path] = [] + self.only_in_tree_a: List[Path] = [] + self.only_in_tree_b: List[Path] = [] + self.diff_files: List[Path] = [] + + for root, _, files in tree_a.walk(): + rel_root = Path(root).relative_to(tree_a) + for file in files: + rel_path = rel_root / file + path_a = tree_a / rel_path + path_b = tree_b / rel_path + + if path_b.exists(follow_symlinks=False): + if path_b.is_file(): + if hash_file(path_a) == hash_file(path_b): + self.same_files.append(rel_path) + else: + self.diff_files.append(rel_path) + elif path_a.is_file(): + self.only_in_tree_a.append(rel_path) + else: + # This is a directory that only exists in tree A. + # We don't have to do anything as we only care about files. + pass + + for root, _, files in tree_b.walk(): + rel_root = Path(root).relative_to(tree_b) + for file in files: + rel_path = rel_root / file + path_a = tree_a / rel_path + if not path_a.exists(follow_symlinks=False): + self.only_in_tree_b.append(rel_path) + + def diftree_a(self) -> List[Path]: + return [file for file in self.diff_files + self.only_in_tree_a] + + def diftree_b(self) -> List[Path]: + return [file for file in self.diff_files + self.only_in_tree_b] + + def __copy_diftree(self, base: Path, files: List[Path], dest: Path): + for file in files: + source = base / file + dest_file = dest / file + dest_file.parent.mkdir(parents=True, exist_ok=True) + shutil.copy(source, dest_file, follow_symlinks=False) + + def copy_diftree_a(self, dest: Path): + """Copy files only in tree A and files with different content to the destination.""" + log.info(f"Copying diftree A to '{dest}' from '{self.tree_a}'") + self.__copy_diftree(self.tree_a, self.diftree_a(), dest) + + def copy_diftree_b(self, dest: Path): + """Copy files only in tree B and files with different content to the destination.""" + log.info(f"Copying diftree B to '{dest}' from '{self.tree_b}'") + self.__copy_diftree(self.tree_b, self.diftree_b(), dest) + + def copy_diftrees(self, a_dest: Path, b_dest: Path): + """Copy files only in tree A and B and files with different content to the respective destinations.""" + a_dest.mkdir(parents=True, exist_ok=True) + b_dest.mkdir(parents=True, exist_ok=True) + + self.copy_diftree_a(a_dest) + self.copy_diftree_b(b_dest) + log.info(f"Copied differing files to:\n - A: {a_dest}/\n - B: {b_dest}/") + + def report(self, a_name: str = None, b_name: str = None) -> str: + """Generate a report of the differences.""" + if a_name is None: + a_name = str(self.tree_a) + if b_name is None: + b_name = str(self.tree_b) + + report = StringIO() + report.write(f"Files only in '{a_name}':\n") + for file in self.only_in_tree_a: + report.write(f" {file}\n") + report.write(f"Files only in '{b_name}':\n") + for file in self.only_in_tree_b: + report.write(f" {file}\n") + report.write(f"Files with different content:\n") + for file in self.diff_files: + report.write(f" {file}\n") + return report.getvalue() + + +@dataclass +class UkiData: + path: Path + sections: List[str] + initrd: Path + + +@contextmanager +def uki_extractor(uki_path: Path) -> Generator[UkiData, None, None]: + with tempfile.TemporaryDirectory() as work_dir: + work_path = Path(work_dir) + yield extract_uki(uki_path, work_path) + # Cleanup is handled by the context manager + + +def extract_uki_section( + sections: Dict[str, pefile.SectionStructure], section_name: str, target: Path +): + """ + Extract a specific section from the PE file. + """ + if section_name not in sections: + log.error(f"Section {section_name} not found in PE file.") + raise ValueError(f"Section {section_name} not found in PE file.") + + section = sections[section_name] + log.info(f"Extracting section '{section_name}' to '{target}'") + with open(target, "wb") as f: + f.write(section.get_data()) + return target + + +def detect_compression(path: Path) -> str: + with open(path, "rb") as f: + magic = f.read(4) + if magic.startswith(b"\x1f\x8b"): + return "gzip" + elif magic == b"\x28\xb5\x2f\xfd": + return "zstd" + else: + return "unknown" + + +def extract_initrd(path: Path, target: Path): + """ + Extract the initrd file from the given path. + """ + compression = detect_compression(path) + log.info(f"Detected compression: {compression}") + + target.mkdir(parents=True, exist_ok=True) + + with tempfile.TemporaryDirectory() as work_dir: + work_path = Path(work_dir) + staging_file = work_path / "staging_file" + if compression == "gzip": + with gzip.open(path, "rb") as gzf: + with open(staging_file, "wb") as out_f: + shutil.copyfileobj(gzf, out_f) + elif compression == "zstd": + subprocess.run( + ["zstd", "-d", str(path), "-o", str(staging_file)], check=True + ) + else: + raise ValueError(f"Unsupported compression type: {compression}") + + # Extract the cpio archive from staging_file into target + subprocess.run( + ["cpio", "-idmv"], + cwd=target, + input=staging_file.read_bytes(), + check=True, + capture_output=True, + ) + + +def extract_uki(uki_path: Path, workdir: Path) -> UkiData: + log.info(f"Extracting UKI file: {uki_path}") + with open(uki_path, "rb") as f: + data = f.read() + pe = pefile.PE(data=data) + + available_sections = {} + section: pefile.SectionStructure + for section in pe.sections: + name: str = section.Name.decode("utf-8").strip("\x00") + available_sections[name] = section + log.debug(f"Sections in UKI file: {','.join(available_sections.keys())}") + + # Extract all sections + initrd_img = workdir / "initrd.img" + extract_uki_section(available_sections, ".initrd", initrd_img) + initrd_extracted = workdir / "initrd" + extract_initrd(initrd_img, workdir / "initrd") + + pe.close() + return UkiData( + path=uki_path, + sections=list(available_sections.keys()), + initrd=initrd_extracted, + ) + + +def compare_ukis( + *, + uki_path: Path, + cosi_a_path: Path, + cosi_a_root: Path, + cosi_b_path: Path, + cosi_b_root: Path, + output: Optional[Path] = None, +): + """ + Compare the UKI files in the two COSI files. + This is a placeholder function and should be implemented based on your requirements. + """ + with uki_extractor(cosi_a_root / uki_path) as uki_a, uki_extractor( + cosi_b_root / uki_path + ) as uki_b: + # Compare the two UKI files + log.info(f"Comparing UKI files: {uki_a.path} and {uki_b.path}") + # Implement your comparison logic here + initrd_compare = TreeCompare(uki_a.initrd, uki_b.initrd) + + report = initrd_compare.report(a_name=cosi_a_path.name, b_name=cosi_b_path.name) + print(report) + + if output: + initrd_compare.copy_diftrees( + output / f"{cosi_a_path.name}-initrd", + output / f"{cosi_b_path.name}-initrd", + ) + + with open(output / "initrd-report.txt", "w") as report_file: + report_file.write(report) + + +def main(): + args = parse_args() + cosi_file_a: Path = args.cosi_file_a + cosi_file_b: Path = args.cosi_file_b + output: Optional[Path] = args.output + + if not cosi_file_a.exists(): + log.error(f"COSI file 1 does not exist: {cosi_file_a}") + exit(1) + + if not cosi_file_b.exists(): + log.error(f"COSI file 2 does not exist: {cosi_file_b}") + exit(1) + + if output: + if output.exists(): + shutil.rmtree(output) + output.mkdir(parents=True, exist_ok=True) + log.info(f"Results will be written to: {output}") + else: + log.info("No output file specified.") + + log.info(f"COSI file 1: {cosi_file_a}") + log.info(f"COSI file 2: {cosi_file_b}") + + with tempfile.TemporaryDirectory() as work_dir: + log.info(f"Temporary work directory: {work_dir}") + work_dir = Path(work_dir) + cosi_a_workdir = work_dir / "cosi_a" + cosi_b_workdir = work_dir / "cosi_b" + + cosi_a_workdir.mkdir(parents=True, exist_ok=True) + cosi_b_workdir.mkdir(parents=True, exist_ok=True) + + with cosi_extractor(cosi_file_a) as cosi_a_root, cosi_extractor( + cosi_file_b + ) as cosi_b_root: + # Compare the two COSI files + log.info(f"Comparing {cosi_file_a} and {cosi_file_b}") + log.debug(f"COSI A root: {cosi_a_root}") + log.debug(f"COSI B root: {cosi_b_root}") + + # Compare the two directories + compare = TreeCompare(cosi_a_root, cosi_b_root) + + report = compare.report() + print(report) + + if output: + with open(output / "report.txt", "w") as report_file: + report_file.write(report) + + compare.copy_diftrees( + output / cosi_file_a.name, + output / cosi_file_b.name, + ) + + for file in compare.diff_files: + if file.suffix == ".efi": + log.info(f"UKI file found: {file}") + compare_ukis( + uki_path=file, + cosi_a_path=cosi_file_a, + cosi_a_root=cosi_a_root, + cosi_b_path=cosi_file_b, + cosi_b_root=cosi_b_root, + output=args.output, + ) + + +if __name__ == "__main__": + main() diff --git a/scripts/lab-netlaunch b/scripts/lab-netlaunch index 727eab23a..fafc05986 100755 --- a/scripts/lab-netlaunch +++ b/scripts/lab-netlaunch @@ -21,6 +21,10 @@ # This is necessary because the SSHD is configured with `GatewayPorts no`, which means that the # forwarded port is only accessible from the jumpbox itself. Miniproxy is used to forward the # connection to a port that is accessible from the outside. +# +# +# More info about lab access: +# https://dev.azure.com/msazuredev/AzureForOperatorsIndustry/_wiki/wikis/AzureForOperatorsIndustry.wiki/23997/AEP-Labs-on-prem-access def delete_me_to_run(): @@ -36,13 +40,18 @@ def delete_me_to_run(): # Prevent accidental execution of this script. delete_me_to_run() -import subprocess +import inspect import json -import time -from typing import List +import logging import psutil -from pathlib import Path +import signal +import subprocess +import time +import threading + from contextlib import contextmanager +from pathlib import Path +from typing import List # This is an arbitrary port that we will use for netlaunch to listen on. LISTEN_HTTP_PORT_LOCAL = 32365 @@ -68,36 +77,63 @@ BMC_SHH_PORT_LOCAL = 32556 # These are the details of the jumpbox that we will use to establish the SSH tunnel. JUMPBOX_SUBSCRIPTION = "f18e9c89-62a2-4c08-95cf-12d482634db4" JUMPBOX_RESOURCE_GROUP = "b37-westus3-labs-rg" -JUMPBOX_NAME = "b37-westus3-dev-azl3-arc-vm" -JUMPBOX_IP = "10.249.132.133" +JUMPBOX_NAME = "b37-westus3-aep-azl3-vm" +JUMPBOX_IP = "10.249.132.138" BAREMETAL_MACHINE_IP = "10.8.4.50/23" BAREMETAL_MACHINE_GATEWAY = "10.8.4.1" BAREMETAL_MACHINE_DEFAULT_INTERFACE = "eth0" -def recursive_kill(proc: int): +logging.basicConfig(level=logging.INFO) +log = logging.getLogger("lab-netlaunch") + + +def recursive_kill(proc: int, signal_type: signal.Signals = signal.SIGINT): """Given a process ID, kill the process and all its children.""" psutil_proc = psutil.Process(proc) - print("") - print(f"Terminating process {psutil_proc.name()}[{proc}]") + log.info( + f"Sending signal {signal_type.name} to process {psutil_proc.name()}[{proc}]" + ) for child in psutil_proc.children(recursive=True): - print(f"Terminating child process {child.name()}[{child.pid}]") - child.kill() - psutil_proc.kill() + log.info( + f"Sending signal {signal_type.name} to child process {child.name()}[{child.pid}]" + ) + child.send_signal(signal_type) + psutil_proc.send_signal(signal_type) @contextmanager -def background_process(cmd: List[str]): +def background_process(cmd: List[str], **kwargs): """Run a command in the background and yield the process object. On exit, kill the process and all its children and wait for it to finish.""" - print(f"Running command: {cmd[0]}") - proc = subprocess.Popen(cmd) - print(f"Started process {cmd[0]}[{proc.pid}]") + log.info(f"Running command: {cmd[0]}") + proc = subprocess.Popen(cmd, text=True, **kwargs) + log.info(f"Started process {cmd[0]}[{proc.pid}]") try: yield proc finally: + if proc.poll() is not None: + log.info( + f"Process {cmd[0]}[{proc.pid}] already exited with code {proc.returncode}." + ) + return + + log.info(f"Killing process '{cmd[0]}' and all its children...") recursive_kill(proc.pid) + log.info(f"Waiting for process {cmd[0]}[{proc.pid}] to finish...") + start_time = time.time() + while proc.poll() is None and time.time() - start_time < 10: + time.sleep(0.2) + if proc.poll() is None: + log.warning( + f"Process {cmd[0]}[{proc.pid}] did not exit after SIGINT, sending SIGKILL..." + ) + recursive_kill(proc.pid, signal.SIGKILL) + else: + log.info( + f"Process {cmd[0]}[{proc.pid}] exited graciously with code {proc.returncode}" + ) proc.wait() @@ -137,93 +173,188 @@ def get_secret_value(keyvault: str, secret: str) -> str: return json.loads(res.stdout)["value"] -bmc_username = get_secret_value("kvAfoStaging", "j25-bmc-username") -bmc_password = get_secret_value("kvAfoStaging", "j25-bmc-password") - -netlaunch_config_file = Path("./input/baremetal-netlaunch.yaml") - -contents = f"""# This file is generated by {__file__} -netlaunch: - bmc: - # Connect to localhost because we will establish an SSH tunnel to the BMC. - ip: "localhost" - # Connect to the local port that we will forward to the BMC's Redfish service. - port: {BMC_REDFISH_PORT_LOCAL} - username: "{bmc_username}" - password: "{bmc_password}" - # Set up the serial-over-SSH connection to the BMC. - serialOverSsh: - # Connect to forwarded port. - sshPort: {BMC_SHH_PORT_LOCAL} - comPort: {BMC_SERIAL_PORT} - output: "baremetal-serial.log" - # Instruct netlaunch to tell Trident and the BMC to connect to this address. (The jumpbox) - announceIp: "{JUMPBOX_IP}" - announcePort: {LISTEN_HTTP_PORT_REMOTE} - -# Set up the initial network on the baremetal machine. -iso: - preTridentScript: | - ip addr add {BAREMETAL_MACHINE_IP} dev {BAREMETAL_MACHINE_DEFAULT_INTERFACE} - ip route add default via {BAREMETAL_MACHINE_GATEWAY} dev {BAREMETAL_MACHINE_DEFAULT_INTERFACE} - while true; do - curl -s -o /dev/null "http://{JUMPBOX_IP}:{LISTEN_HTTP_PORT_REMOTE}" && break - sleep 1 - done -""" - -netlaunch_config_file.write_text(contents) -print(f"Generated {netlaunch_config_file}:\n{contents}") - -print("Copying miniproxy to the jumpbox...") -with open("bin/miniproxy", "rb") as miniproxy: - copy_cmd = get_ssh_command_base() - # Write the miniproxy binary to the jumpbox via stdin and make it - # executable. - copy_cmd.extend( - [ - "cat - > ~/miniproxy && chmod +x ~/miniproxy", - ] - ) - # Feed in the miniproxy binary to the stdin of the SSH command. - subprocess.run( - copy_cmd, - stdin=miniproxy, - check=True, - stdout=subprocess.DEVNULL, - ) +def generate_netlaunch_config() -> Path: + bmc_username = get_secret_value("kvAfoStaging", "j25-bmc-username") + bmc_password = get_secret_value("kvAfoStaging", "j25-bmc-password") + + netlaunch_config_file = Path("./input/baremetal-netlaunch.yaml") + + contents = f""" + # This file is generated by {__file__} + netlaunch: + bmc: + # Connect to localhost because we will establish an SSH tunnel to the BMC. + ip: "localhost" + # Connect to the local port that we will forward to the BMC's Redfish service. + port: {BMC_REDFISH_PORT_LOCAL} + username: "{bmc_username}" + password: "{bmc_password}" + # Set up the serial-over-SSH connection to the BMC. + serialOverSsh: + # Connect to forwarded port. + sshPort: {BMC_SHH_PORT_LOCAL} + comPort: {BMC_SERIAL_PORT} + output: "baremetal-serial.log" + # Instruct netlaunch to tell Trident and the BMC to connect to this address. (The jumpbox) + announceIp: "{JUMPBOX_IP}" + announcePort: {LISTEN_HTTP_PORT_REMOTE} + + # Set up the initial network on the baremetal machine. + iso: + preTridentScript: | + ip addr add {BAREMETAL_MACHINE_IP} dev {BAREMETAL_MACHINE_DEFAULT_INTERFACE} + ip route add default via {BAREMETAL_MACHINE_GATEWAY} dev {BAREMETAL_MACHINE_DEFAULT_INTERFACE} + while true; do + curl -s -o /dev/null "http://{JUMPBOX_IP}:{LISTEN_HTTP_PORT_REMOTE}" && break + sleep 1 + done + """ + contents = inspect.cleandoc(contents) + log.info(f"Generated {netlaunch_config_file}:\n{contents}") + netlaunch_config_file.write_text(contents) + return netlaunch_config_file + + +def copy_miniproxy_to_jumpbox(): + log.info("Copying miniproxy to the jumpbox...") + with open("bin/miniproxy", "rb") as miniproxy: + copy_cmd = get_ssh_command_base() + # Write the miniproxy binary to the jumpbox via stdin and make it + # executable. + copy_cmd.extend( + [ + "killall miniproxy && cat - > ~/miniproxy && chmod +x ~/miniproxy", + ] + ) + # Feed in the miniproxy binary to the stdin of the SSH command. + subprocess.run( + copy_cmd, + stdin=miniproxy, + check=True, + text=True, + ) + + +def make_tunnel_command() -> List[str]: + tunnel_command = get_ssh_command_base() + forwarding_params = [ + "-R", + f"{LISTEN_HTTP_PORT_LOCAL}:localhost:{LISTEN_HTTP_PORT_LOCAL}", + "-L", + f"{BMC_REDFISH_PORT_LOCAL}:{BMC_IP}:{BMC_REDFISH_PORT}", + "-L", + f"{BMC_SHH_PORT_LOCAL}:{BMC_IP}:{BMC_SSH_PORT}", + f"./miniproxy -l {LISTEN_HTTP_PORT_REMOTE} -f {LISTEN_HTTP_PORT_LOCAL}", + ] + + log.info(f"SSH Tunnel settings: {' '.join(forwarding_params)}") + + tunnel_command.extend(forwarding_params) + return tunnel_command + + +def wait_for_ssh_tunnel(tunnel: subprocess.Popen, timeout: float = 15) -> None: + """Wait for the SSH tunnel to be established.""" + log.info("Waiting for SSH tunnel to be established...") + start_time = time.time() + while tunnel.poll() is None: + if time.time() - start_time > timeout: + log.error("SSH tunnel did not establish within the timeout period.") + raise TimeoutError( + "SSH tunnel did not establish within the timeout period." + ) + + line = tunnel.stderr.readline() + if not line: + continue + + if "Failed to listen" in line: + log.error(f"SSH tunnel failed to establish: {line.strip()}") + raise RuntimeError(f"SSH tunnel failed to establish: {line.strip()}") + + if "Listening..." in line: + log.info(f"SSH tunnel established: {line.strip()}") + return + + +sigint_received = False + + +def handle_sigint(signum, frame): + global sigint_received + sigint_received = True + raise KeyboardInterrupt("Received SIGINT, shutting down gracefully...") + + +signal.signal(signal.SIGINT, handle_sigint) + + +class OutputReader: + def __init__(self, proc: subprocess.Popen, name: str = "OutputReader"): + self.log = logging.getLogger(name) + self.proc = proc + self.stderr_thread = threading.Thread(target=self._read_error, daemon=True) + self.stdout_thread = threading.Thread(target=self._read_output, daemon=True) + self.stderr_thread.start() + self.stdout_thread.start() + + def _read_output(self): + try: + while self.proc.poll() is None: + line = self.proc.stdout.readline() + if not line or sigint_received: + break + self.log.info(line.strip()) + except Exception as e: + log.error(f"Error reading output: {e}") + + def _read_error(self): + try: + while self.proc.poll() is None: + line = self.proc.stderr.readline() + if not line or sigint_received: + break + self.log.info(line.strip()) + except Exception as e: + log.error(f"Error reading error output: {e}") + + +def deploy(netlaunch_config_file: Path): + log.info("Establishing SSH tunnel to BMC...") + tunnel_command = make_tunnel_command() + + with background_process( + tunnel_command, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) as ssh_tunnel: + wait_for_ssh_tunnel(ssh_tunnel) + log.info("SSH tunnel established successfully.") + reader = OutputReader(ssh_tunnel, "SSH-TUNNEL") + + # Start netlaunch + subprocess.run( + [ + "make", + "run-netlaunch", + f"NETLAUNCH_CONFIG={netlaunch_config_file}", + f"NETLAUNCH_PORT={LISTEN_HTTP_PORT_LOCAL}", + ], + check=True, + ) + + while True: + time.sleep(60) + + +def main(): + try: + nl_cfg = generate_netlaunch_config() + copy_miniproxy_to_jumpbox() + deploy(nl_cfg) + except KeyboardInterrupt: + log.warning("Received KeyboardInterrupt, shutting down...") -print("Establishing SSH tunnel to BMC...") -tunnel_command = get_ssh_command_base() -forwarding_params = [ - "-R", - f"{LISTEN_HTTP_PORT_LOCAL}:localhost:{LISTEN_HTTP_PORT_LOCAL}", - "-L", - f"{BMC_REDFISH_PORT_LOCAL}:{BMC_IP}:{BMC_REDFISH_PORT}", - "-L", - f"{BMC_SHH_PORT_LOCAL}:{BMC_IP}:{BMC_SSH_PORT}", - f"./miniproxy -s {LISTEN_HTTP_PORT_REMOTE} -d {LISTEN_HTTP_PORT_LOCAL}", -] - -print(f"SSH Tunnel settings: {' '.join(forwarding_params)}") - -tunnel_command.extend(forwarding_params) - -with background_process(tunnel_command) as ssh_tunnel: - # Wait for the tunnel to be established. We *should* do this by reading the - # output of the SSH command instead, but that's a bit more complicated. - time.sleep(5) - - # Start netlaunch - subprocess.run( - [ - "make", - "run-netlaunch", - f"NETLAUNCH_CONFIG={netlaunch_config_file}", - f"NETLAUNCH_PORT={LISTEN_HTTP_PORT_LOCAL}", - ], - check=True, - ) - while True: - time.sleep(60) +if __name__ == "__main__": + main() diff --git a/scripts/loop-update/README.md b/scripts/loop-update/README.md index 9a189557d..ef41e4ae8 100644 --- a/scripts/loop-update/README.md +++ b/scripts/loop-update/README.md @@ -15,27 +15,25 @@ the scaling pipeline. They can be also used locally. servicing and two sets of update images. The produced images are moved to `artifacts`. Generally needs to be only rerun when you want to refresh the images to be used. - -- For Azure VMs, you will need to publish the image once, before it could be - used to create VMs. To publish the image, call `publish-sig-image.sh`. - -- `deploy-vm.sh`: Creates a VM instance with the base image and starts the VM. - It ensures the VM gets to the login prompt. - -- `check-deployment.sh`: Fetches the Host Status of the freshly deployed VM to - ensure it is in an expected state. You need to deploy the VM first using the - script above. - -- `loop-update.sh`: Loops through the update images and applies them to the VM. - It ensures the VM gets to the login prompt after each update and confirms the - Host Status is as expected. This script will power off and restart the VM - every 10 runs. By default, it will execute 20 loops, and you can change this - by setting `RETRY_COUNT` environment variable. - -- `cleanup-vm.sh`: Deletes the VM instance. The deploy scripts will delete - automatically before creating the new VMs. The other scripts use a presence of - a QEMU VM to decide whether to target Azure or QEMU, so if you are switching - between the two, you may need to delete the VM using this script first. - -- `common.sh`: Not used directly. Contains common functions used by the other - scripts. + +- The servicing tests are backed by a storm scenario (../tools/storm/servicing). + +- To run the scenario, you can use the `servicing-tests.sh` script or by invoking + the storm binary directly. + +- The servicing tests are composed of several storm test cases: + +1. For Azure VMs, `publish-sig-image` is the first testcase and it will + configure an appropriate qcow2 image as needed for an Azure VM and + upload it. +2. For all VMs, `deploy-vm` is the next phase and will create a VM on the + selected platform. +3. For all VMs, `check-deployment` will verify that the VM has been started + and that it booted from the expected volume.` +4. For all VMs, `update-loop` will update the VM the specified number of + times, applying the update images and checking that the VM is in the + expected state. +5. For all VMs, `rollback` will validate that rollback and update works. +6. For all VMs, `collect-logs` will collect logs from the VM. +7. For all VMs, `cleanup-vm` will delete the VM. + diff --git a/scripts/loop-update/check-deployment.sh b/scripts/loop-update/check-deployment.sh index 6f959fd5c..5e4a42b87 100755 --- a/scripts/loop-update/check-deployment.sh +++ b/scripts/loop-update/check-deployment.sh @@ -3,18 +3,35 @@ set -euxo pipefail . $(dirname $0)/common.sh -VM_IP=`getIp` - -# Help diagnose https://dev.azure.com/mariner-org/ECF/_workitems/edit/11273 and -# fail explicitly if multiple IPs are found -if [ "$TEST_PLATFORM" == "qemu" ]; then - if [ `echo $VM_IP | wc -w` -gt 1 ]; then - echo "Multiple IPs found:" - echo $VM_IP - sudo virsh domifaddr $VM_NAME - adoError "Multiple IPs found" - exit 1 - fi +SUDO="sudo" +if [ "$TEST_PLATFORM" == "azure" ]; then + SUDO="" fi -checkActiveVolume "volume-a" 0 \ No newline at end of file +$SUDO ./bin/storm-trident helper servicing-tests \ + --output-path $OUTPUT \ + --artifacts-dir $ARTIFACTS \ + --retry-count $RETRY_COUNT \ + --expected-volume volume-b \ + --storage-account-resource-group $RESOURCE_GROUP \ + --ssh-private-key-path ~/.ssh/id_rsa \ + --user $SSU_USER \ + --platform $TEST_PLATFORM \ + --name $VM_NAME \ + --serial-log $VM_SERIAL_LOG \ + --who-am-i $ALIAS \ + --subscription $SUBSCRIPTION \ + --image-definition $IMAGE_DEFINITION \ + --region $PUBLISH_LOCATION \ + --gallery-resource-group $GALLERY_RESOURCE_GROUP \ + --storage-account $STORAGE_ACCOUNT \ + --gallery-name $GALLERY_NAME \ + --offer $OFFER \ + --test-resource-group $TEST_RESOURCE_GROUP \ + --size $TEST_VM_SIZE \ + --ssh-public-key-path $SSH_PUBLIC_KEY_PATH \ + --user $SSH_USER \ + --update-port-a $UPDATE_PORT_A \ + --update-port-b $UPDATE_PORT_B \ + --expected-volume volume-a \ + --test-case-to-run check-deployment diff --git a/scripts/loop-update/common.sh b/scripts/loop-update/common.sh deleted file mode 100755 index d95899671..000000000 --- a/scripts/loop-update/common.sh +++ /dev/null @@ -1,315 +0,0 @@ -#!/bin/bash -set -euxo pipefail - -ARTIFACTS=${ARTIFACTS:-artifacts} -VM_NAME=${VM_NAME:-trident-vm-verity-test} -VM_SERIAL_LOG=${VM_SERIAL_LOG:-/tmp/$VM_NAME.log} -VERBOSE=${VERBOSE:-False} -OUTPUT=${OUTPUT:-} - -ALIAS=${ALIAS:-`whoami`} - -SUBSCRIPTION=${SUBSCRIPTION:-b8a0db63-c5fa-4198-8e2a-f9d6ff52465e} # CoreOS_AzureLinux_BMP_dev -IMAGE_DEFINITION=${IMAGE_DEFINITION:-trident-vm-verity-azure-testimage} -RESOURCE_GROUP=${RESOURCE_GROUP:-azlinux_bmp_dev} -PUBLISH_LOCATION=${PUBLISH_LOCATION:-eastus2} -GALLERY_RESOURCE_GROUP=${GALLERY_RESOURCE_GROUP:-$ALIAS-trident-rg} -STORAGE_ACCOUNT=${STORAGE_ACCOUNT:-azlinuxbmpdev} -GALLERY_NAME=${GALLERY_NAME:-${ALIAS}_trident_gallery} -PUBLISHER=${PUBLISHER:-$ALIAS} -OFFER=${OFFER:-trident-vm-verity-azure-offer} -export AZCOPY_AUTO_LOGIN_TYPE=${AZCOPY_AUTO_LOGIN_TYPE:-AZCLI} -TEST_RESOURCE_GROUP=${TEST_RESOURCE_GROUP:-$GALLERY_RESOURCE_GROUP-test} -TEST_VM_SIZE=${TEST_VM_SIZE:-Standard_D2ds_v5} -SSH_PUBLIC_KEY_PATH=${SSH_PUBLIC_KEY_PATH:-~/.ssh/id_rsa.pub} - -# Third parent of this script -TRIDENT_SOURCE_DIRECTORY=$(dirname $(dirname $(dirname $(realpath $0)))) -SSH_USER=testuser - -UPDATE_PORT_A=8000 -UPDATE_PORT_B=8001 - -function getIp() { - if [ "$TEST_PLATFORM" == "qemu" ]; then - while [ `sudo virsh domifaddr $VM_NAME | grep -c "ipv4"` -eq 0 ]; do sleep 1; done - sudo virsh domifaddr $VM_NAME | grep ipv4 | awk '{print $4}' | cut -d'/' -f1 - elif [ "$TEST_PLATFORM" == "azure" ]; then - IP_TYPE=publicIps - if [ ! -z "${BUILD_BUILDNUMBER:-}" ]; then - IP_TYPE=privateIps - fi - az vm show -d -g $TEST_RESOURCE_GROUP -n $VM_NAME --query $IP_TYPE -o tsv - fi -} - -function sshCommand() { - local COMMAND=$1 - - # BatchMode - running from a script, disable any interactive prompts - # ConnectTimeout - how long to wait for the connection to be established - # ServerAliveCountMax - how many keepalive packets can be missed before the connection is closed - # ServerAliveInterval - how often to send keepalive packets - # StrictHostKeyChecking - disable host key checking; TODO: remove this and - # use the known_hosts file instead - # UserKnownHostsFile - disable known hosts file to simplify local runs - ssh \ - -o BatchMode=yes \ - -o ConnectTimeout=10 \ - -o ServerAliveCountMax=3 \ - -o ServerAliveInterval=5 \ - -o StrictHostKeyChecking=no \ - -o UserKnownHostsFile=/dev/null \ - -i ../test-images/build/id_rsa \ - $SSH_USER@$VM_IP \ - "$COMMAND" -} - -function sshProxyPort() { - local PORT=$1 - - ssh \ - -R $PORT:localhost:$PORT -N \ - -o BatchMode=yes \ - -o ConnectTimeout=10 \ - -o ServerAliveCountMax=3 \ - -o ServerAliveInterval=5 \ - -o StrictHostKeyChecking=no \ - -o UserKnownHostsFile=/dev/null \ - -i ../test-images/build/id_rsa \ - $SSH_USER@$VM_IP & -} - -function adoError() { - local MESSAGE=$1 - - set +x - echo "##vso[task.logissue type=error]$MESSAGE" - set -x -} - -function adoWarning() { - local MESSAGE=$1 - - set +x - echo "##vso[task.logissue type=warning]$MESSAGE" - set -x -} - -function checkActiveVolume() { - local VOLUME=$1 - local ITERATION=$2 - - bin/storm-trident helper check-ssh ~/.ssh/id_rsa $VM_IP $SSH_USER none --check-active-volume $VOLUME -} - -function validateRollback() { - HOST_STATUS=`sshCommand "set -o pipefail; sudo systemd-run --pipe --property=After=trident.service trident get"` - # Validate that lastError.category is set to "servicing" - CATEGORY=$(echo "$HOST_STATUS" | yq eval '.lastError.category' -) - if [ "$CATEGORY" != "servicing" ]; then - sshCommand "sudo trident get" - echo "Category of last error is not 'servicing', but '$CATEGORY'" - adoError "Category of last error is not 'servicing', but '$CATEGORY'" - exit 1 - fi - - # Validate that lastError.error contains the expected content - ERROR=$(echo "$HOST_STATUS" | yq eval '.lastError.error' -) - if [[ "$ERROR" != *"!ab-update-reboot-check"* ]]; then - echo "Type of last error is not '!ab-update-reboot-check', but '$ERROR'" - adoError "Type of last error is not '!ab-update-reboot-check', but '$ERROR'" - exit 1 - fi - - # Validate that lastError.message matches the expected format - MESSAGE=$(echo "$HOST_STATUS" | yq eval '.lastError.message' -) - if ! echo "$MESSAGE" | grep -Eq '^A/B update failed as host booted from .+ instead of the expected device .+$'; then - echo "Message of last error does not match the expected format: '$MESSAGE'" - adoError "Message of last error does not match the expected format: '$MESSAGE'" - exit 1 - fi - - echo "Rollback validation succeeded" -} - -function truncateLog() { - if sudo virsh dominfo $VM_NAME > /dev/null; then - sudo truncate -s 0 "$VM_SERIAL_LOG" - fi -} - -function analyzeSerialLog() { - local LOG=$1 - - LAST_LINE=$(tail -n 1 $LOG) - if [[ $LAST_LINE == *"tpm tpm0: Operation Timed out"* ]]; then - echo "Error found in serial log: tpm tpm0: Operation Timed out" - adoError "tpm tpm0: Operation Timed out" - else - echo "No error found in serial log" - echo "Last line of serial log: $LAST_LINE" - adoError "Last line of serial log: $LAST_LINE" - fi -} - -function waitForLogin() { - set +e - local ITERATION=$1 - - LOGGING="" - if [ $VERBOSE == True ]; then - echo "VM serial log:" - LOGGING="-v" - fi - - # Keeping errors masked, as we want to handle the failure explicitly - sudo $TRIDENT_SOURCE_DIRECTORY/e2e_tests/helpers/wait_for_login.py \ - -d "$VM_SERIAL_LOG" \ - -o ./serial.log \ - -t 120 \ - $LOGGING - - WAIT_FOR_LOGIN_EXITCODE=$? - - if [ "$OUTPUT" != "" ]; then - mkdir -p $OUTPUT - PAD_ITERATION=$(printf "%03d" $ITERATION) - OUTPUT_FILENAME=$PAD_ITERATION-serial.log - if [ "${ROLLBACK:-}" == "true" ]; then - OUTPUT_FILENAME=$PAD_ITERATION-rollback-serial.log - fi - sudo cp ./serial.log $OUTPUT/$OUTPUT_FILENAME - fi - - if [ $WAIT_FOR_LOGIN_EXITCODE -ne 0 ]; then - echo "Failed to reach login prompt for the VM" - adoError "Failed to reach login prompt for the VM for iteration $ITERATION" - - analyzeSerialLog ./serial.log - - if [ "$TEST_PLATFORM" == "qemu" ]; then - virsh dominfo $VM_NAME - fi - - df -h - exit $WAIT_FOR_LOGIN_EXITCODE - fi - set -e -} - -function getLatestVersion() { - local G_RG_NAME=$1 - local G_NAME=$2 - local I_NAME=$3 - - # TODO improve the sorting - az sig image-version list -g $G_RG_NAME -r $G_NAME -i $I_NAME --query '[].name' -o tsv | sort -t "." -k1,1n -k2,2n -k3,3n | tail -1 -} - -function getImageVersion() { - local OP=${1:-} - if [ -z "${BUILD_BUILDNUMBER:-}" ]; then - image_version=$(getLatestVersion $GALLERY_RESOURCE_GROUP $GALLERY_NAME $IMAGE_DEFINITION) - if [ -z $image_version ]; then - image_version=0.0.1 - else - if [ "$OP" == "increment" ]; then - # Increment the semver version - image_version=$(echo $image_version | awk -F. '{print $1"."$2"."$3+1}') - fi - fi - else - image_version="0.0.$BUILD_BUILDID" - fi - - echo $image_version -} - -function resizeImage() { - local IMAGE_PATH=$1 - # VHD images on Azure must have a virtual size aligned to 1MB. https://learn.microsoft.com/en-us/azure/virtual-machines/linux/create-upload-generic#resize-vhds - raw_file="resize.raw" - sudo qemu-img convert -f vpc -O raw $IMAGE_PATH $raw_file - MB=$((1024*1024)) - size=$(qemu-img info -f raw --output json "$raw_file" | \ - gawk 'match($0, /"virtual-size": ([0-9]+),/, val) {print val[1]}') - - rounded_size=$(((($size+$MB-1)/$MB)*$MB)) - - echo "Rounded Size = $rounded_size" - - sudo qemu-img resize $raw_file $rounded_size - - sudo qemu-img convert -f raw -o subformat=fixed,force_size -O vpc $raw_file $IMAGE_PATH -} - -function killUpdateServer() { - local UPDATE_PORT=$1 - - set +e - KILL_PID=$(lsof -ti tcp:${UPDATE_PORT}) - PROCESS_FOUND=$? - set -e - if [ $PROCESS_FOUND -eq 0 ]; then - echo "Process already running on the Trident update server port: '${UPDATE_PORT}'. Killing process '$KILL_PID'." - kill -9 $KILL_PID > /dev/null 2>&1 || true - fi -} - -function logAssignedRoles() { - # Log the managed identity roles on the subscription - # 1cd7f210-4327-4ef9-b33f-f64d342cc431 is trident-servicing-test managed - # identity, assigned to the pool - set +e - echo "Assigned roles: `az role assignment list --assignee 1cd7f210-4327-4ef9-b33f-f64d342cc431 --scope "/subscriptions/$SUBSCRIPTION"`" 1>&2 - set -e -} - -function azCommand() { - local COMMAND="$@" - - set +e - # Capture the output, so we only print it once - OUTPUT=`az $COMMAND` - RESULT=$? - logAssignedRoles - set -e - if [ $RESULT -ne 0 ]; then - # Only for pipelines - if [ ! -z "${BUILD_BUILDNUMBER:-}" ]; then - adoWarning "Managed identity does not have access to the subscription (command '$COMMAND' exit code is $RESULT), retrying..." - az login --identity > /dev/null - fi - az $COMMAND - else - echo -n $OUTPUT - fi -} - -function ensureAzureAccess() { - local RESOURCE_GROUP=$1 - - SUCCESS=0 - for i in {1..10}; do - set +e - EXISTS=`az group exists -n "$RESOURCE_GROUP"` - RESULT=$? - set -e - logAssignedRoles - # If the check did not fail and actually returned a value, we should - # have access - if [ $RESULT -eq 0 ] && [ ! -z "$EXISTS" ]; then - SUCCESS=1 - break - fi - echo "Managed identity does not have access to the subscription, retrying..." - sleep 5 - az login --identity - done - if [ $SUCCESS -eq 0 ]; then - echo "Managed identity does not have access to the subscription" - adoError "Managed identity does not have access to the subscription" - exit 1 - fi -} \ No newline at end of file diff --git a/scripts/loop-update/deploy-vm.sh b/scripts/loop-update/deploy-vm.sh deleted file mode 100755 index 9f9004e5c..000000000 --- a/scripts/loop-update/deploy-vm.sh +++ /dev/null @@ -1,99 +0,0 @@ -#!/bin/bash -set -euo pipefail - -. $(dirname "$0")/common.sh - -if [ "$TEST_PLATFORM" == "qemu" ]; then - sudo virsh list --all - sudo virsh destroy "$VM_NAME" || true - sudo virsh undefine "$VM_NAME" --nvram || true - - IMAGE_FILES="$(find $ARTIFACTS -type f -name 'trident-vm-*-testimage.qcow2')" - IMAGE_FILES_COUNT=$(echo "$IMAGE_FILES" | wc -l) - - if [ $IMAGE_FILES_COUNT -lt 1 ]; then - echo "Image file not found!" - exit 1 - elif [ $IMAGE_FILES_COUNT -gt 1 ]; then - echo "Multiple image files found:" - echo $IMAGE_FILES - exit 1 - else - echo "Image file found: $IMAGE_FILES" - fi - - IMAGE_FILE=$(echo $IMAGE_FILES | head -1) - - BOOT_IMAGE="$ARTIFACTS/booted.qcow2" - cp "$IMAGE_FILE" "$BOOT_IMAGE" - - BOOT_CONFIG="--machine q35 --boot uefi,loader_secure=yes" - if [ "${SECURE_BOOT:-}" == "False" ]; then - BOOT_CONFIG="--boot uefi,loader_secure=no" - fi - - sudo virt-install \ - --name "$VM_NAME" \ - --memory 2048 \ - --vcpus 2 \ - --os-variant generic \ - --import \ - --disk "$BOOT_IMAGE,bus=sata" \ - --network default \ - $BOOT_CONFIG \ - --noautoconsole \ - --serial "file,path=$VM_SERIAL_LOG" - - until [ -f "$VM_SERIAL_LOG" ] - do - sleep 0.1 - done - - waitForLogin 0 -elif [ "$TEST_PLATFORM" == "azure" ]; then - az account set -s "$SUBSCRIPTION" - - # Ensure access when running in Azure DevOps, since this has not been - # working reliably in the past - if [ ! -z "${BUILD_BUILDNUMBER:-}" ]; then - ensureAzureAccess "$TEST_RESOURCE_GROUP" - fi - if [ "`azCommand group exists -n "$TEST_RESOURCE_GROUP"`" == "true" ]; then - azCommand group delete -n "$TEST_RESOURCE_GROUP" -y - fi - - azCommand group create -n "$TEST_RESOURCE_GROUP" -l "$PUBLISH_LOCATION" --tags creationTime=$(date +%s) - - if [ -n "${VALIDATION_SUBNET_ID:-}" ]; then - SUBNET_ARG="--subnet $VALIDATION_SUBNET_ID" - fi - - VERSION=`getImageVersion` - azCommand vm create \ - --resource-group "$TEST_RESOURCE_GROUP" \ - --name "$VM_NAME" \ - --size "$TEST_VM_SIZE" \ - --os-disk-size-gb 60 \ - --admin-username "$SSH_USER" \ - --ssh-key-values "$SSH_PUBLIC_KEY_PATH" \ - --image "/subscriptions/$SUBSCRIPTION/resourceGroups/$GALLERY_RESOURCE_GROUP/providers/Microsoft.Compute/galleries/$GALLERY_NAME/images/$IMAGE_DEFINITION/versions/$VERSION" \ - --location "$PUBLISH_LOCATION" \ - --security-type TrustedLaunch \ - --enable-secure-boot true \ - --enable-vtpm true \ - --no-wait \ - $SUBNET_ARG - - # Attempt to enable the boot diagnostics early on - while ! azCommand vm boot-diagnostics enable --name "$VM_NAME" -g "$TEST_RESOURCE_GROUP"; do - sleep 1 - done - - # Wait for the boot diagnostics to be available - while azCommand vm boot-diagnostics get-boot-log --name "$VM_NAME" --resource-group "$TEST_RESOURCE_GROUP" | grep "BlobNotFound"; do - sleep 5 - done - - # Use az cli to confirm the VM deployment status is successful - while [ "`azCommand vm show -d -g "$TEST_RESOURCE_GROUP" -n "$VM_NAME" --query provisioningState -o tsv`" != "Succeeded" ]; do sleep 1; done -fi diff --git a/scripts/loop-update/fetch-logs.sh b/scripts/loop-update/fetch-logs.sh deleted file mode 100755 index 0a249a834..000000000 --- a/scripts/loop-update/fetch-logs.sh +++ /dev/null @@ -1,73 +0,0 @@ -#!/bin/bash -set -euo pipefail - -. $(dirname $0)/common.sh -OUTPUT_DIR="$1" - -downloadJournalLog() { - local DEST=$1 - - local JOURNAL_LOG=/tmp/journal.log - - # Blocking error causing abort, so we can do other cleanup tasks - set +e - sshCommand "sudo journalctl --no-pager > $JOURNAL_LOG && sudo chmod 644 $JOURNAL_LOG" - if [ $? -eq 0 ]; then - scpDownloadFile $JOURNAL_LOG $DEST - else - echo "Failed to download journal log" - fi - set -e -} - -scpDownloadFile() { - local SRC=$1 - local DEST=$2 - - scp -i ../test-images/build/id_rsa -r -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null $SSH_USER@$VM_IP:$SRC $DEST -} - -downloadCrashdumps() { - local DEST="$1" - - local CRASHDUMP_DIR=/var/crash - - if sshCommand "ls $CRASHDUMP_DIR/*"; then - echo "Crash files found on host" - adoError "Crash files found on host" - sshCommand "sudo mv $CRASHDUMP_DIR/* /tmp/crash && sudo chmod -R 644 /tmp/crash && sudo chmod +x /tmp/crash" - scpDownloadFile "/tmp/crash/*" "$DEST/" - tail -n 50 "$DEST/vmcore-dmesg.txt" - else - echo "No crash files found on host" - fi -} - -downloadAzureSerialLog() { - local DEST="$1" - - # Output of az vm boot-diagnostics get-boot-log is not very readable, so - # clean it up a bit: - # - convert \r\n to newlines - # - remove unicode characters - # - remove lines with only quotes - # - remove lines with only dashes - # - remove empty lines - az vm boot-diagnostics get-boot-log --name "$VM_NAME" --resource-group "$TEST_RESOURCE_GROUP" > "$DEST.raw" - cat "$DEST.raw" | sed -r 's/\\r\\n/\n/g;s/\\u[a-z0-9]{4}//g;/^"$/d;/^-+$/d;/^$/d' > "$DEST" -} - -if [ "$TEST_PLATFORM" == "azure" ]; then - downloadAzureSerialLog $OUTPUT_DIR/serial.log - if [ $VERBOSE == True ]; then - cat $OUTPUT_DIR/serial.log - else - echo "Serial log saved to $OUTPUT_DIR/serial.log" - fi - analyzeSerialLog $OUTPUT_DIR/serial.log -fi - -VM_IP=`getIp` - -downloadJournalLog $OUTPUT_DIR/journal.log -downloadCrashdumps $OUTPUT_DIR/ diff --git a/scripts/loop-update/loop-update.sh b/scripts/loop-update/loop-update.sh deleted file mode 100755 index ff18c6bfb..000000000 --- a/scripts/loop-update/loop-update.sh +++ /dev/null @@ -1,226 +0,0 @@ -#!/bin/bash -set -euo pipefail - -. $(dirname $0)/common.sh - -if [ "$OUTPUT" != "" ]; then - mkdir -p $OUTPUT -fi - -# When ROLLBACK is set to true, the script will trigger a rollback scenario -# during the first update iteration. The rollback scenario will ensure that post -# rebooting into the updated OS, the OS will reboot again, thus letting the -# firmware boot back into the original OS, as the update was never completed -# successfully, since Trident did not run post reboot to certify the update. -ROLLBACK=${ROLLBACK:-false} - -killUpdateServer $UPDATE_PORT_A -killUpdateServer $UPDATE_PORT_B - -ls -l $ARTIFACTS/update-a -ls -l $ARTIFACTS/update-b - -COSI_FILES="$(find $ARTIFACTS/update-a -type f -name '*.cosi')" -COSI_FILES_COUNT=$(echo "$COSI_FILES" | wc -l) - -if [ $COSI_FILES_COUNT -lt 1 ]; then - echo "COSI file not found!" - exit 1 -elif [ $COSI_FILES_COUNT -gt 1 ]; then - echo "Multiple COSI files found:" - echo $COSI_FILES - exit 1 -else - echo "COSI file found: $COSI_FILES" -fi - -COSI_FILE=$(echo $COSI_FILES | head -1) -COSI_FILE=$(basename $COSI_FILE) - -if ! [ -f bin/netlisten ]; then - echo "netlisten not found!" - exit 1 -fi - -bin/netlisten -p $UPDATE_PORT_A -s $ARTIFACTS/update-a --force-color --full-logstream logstream-full-update-a.log & -bin/netlisten -p $UPDATE_PORT_B -s $ARTIFACTS/update-b --force-color --full-logstream logstream-full-update-a.log & - -EXPECTED_VOLUME=${EXPECTED_VOLUME:-volume-b} -UPDATE_CONFIG=/var/lib/trident/update-config.yaml -# When triggering A/B update to B, we want to use images in update-config.yaml; to A, we want to -# use update-config2.yaml. However, if this is the rollback scenario, EXPECTED_VOLUME is current -# volume, so the value of UPDATE_CONFIG needs to be flipped. -if [ "$EXPECTED_VOLUME" == "volume-a" ] && [ "$ROLLBACK" == "false" ]; then - UPDATE_CONFIG=/var/lib/trident/update-config2.yaml -elif [ "$EXPECTED_VOLUME" == "volume-b" ] && [ "$ROLLBACK" == "true" ]; then - UPDATE_CONFIG=/var/lib/trident/update-config2.yaml -fi - -RETRY_COUNT=${RETRY_COUNT:-20} - -VM_IP=`getIp` - -# Update the update-config.yaml file with the COSI file and host address address -# of the http server -sshCommand "sudo sed -i 's!verity.cosi!files/$COSI_FILE!' /var/lib/trident/update-config.yaml" -sshCommand "sudo sed -i 's/192.168.122.1/localhost/' /var/lib/trident/update-config.yaml" - -sshCommand "sudo cp /var/lib/trident/update-config.yaml /var/lib/trident/update-config2.yaml && sudo sed -i 's/8000/8001/' /var/lib/trident/update-config2.yaml" - -for i in $(seq 1 $RETRY_COUNT); do - - if [ "$TEST_PLATFORM" == "qemu" ]; then - # For every 10th update, reboot the VM to ensure that we can handle reboots - if [ $((i % 10)) -eq 0 ]; then - echo "" - echo "***************************" - echo "** Rebooting VM **" - echo "***************************" - echo "" - - truncateLog - sudo virsh shutdown $VM_NAME - until [ `sudo virsh list | grep -c $VM_NAME` -eq 0 ]; do sleep 1; done - sudo virsh start $VM_NAME - waitForLogin $i - fi - fi - - echo "" - echo "***************************" - echo "** Starting update $i **" - echo "***************************" - echo "" - - truncateLog - LOGGING="-v WARN" - if [ $VERBOSE == True ]; then - LOGGING="-v DEBUG" - fi - - sshProxyPort $UPDATE_PORT_A - sshProxyPort $UPDATE_PORT_B - - if sshCommand "ls /var/crash/*"; then - echo "Crash files found on host" - adoError "Crash files found on host during iteration $i" - exit 1 - fi - - # If this is a rollback scenario, inject the script to trigger rollback into UPDATE_CONFIG - if [ "$ROLLBACK" == "true" ] && [ $i -eq 1 ]; then - TRIGGER_ROLLBACK_SCRIPT=.pipelines/templates/stages/testing_common/scripts/trigger-rollback.sh - SCRIPT_HOST_COPY=/var/lib/trident/trigger-rollback.sh - sshCommand "sudo tee $SCRIPT_HOST_COPY > /dev/null" < $TRIGGER_ROLLBACK_SCRIPT - sshCommand "sudo chmod +x $SCRIPT_HOST_COPY" - - # The VM host does not have yq installed, so create a local copy of the update config - # and inject the trigger-rollback script into it - COPY_CONFIG="./config.yaml" - sshCommand "sudo cat $UPDATE_CONFIG" > $COPY_CONFIG - yq eval ".scripts.postProvision += [{ - \"name\": \"mount-var\", - \"runOn\": [\"ab-update\"], - \"content\": \"mkdir -p \$TARGET_ROOT/tmp/var && mount --bind /var \$TARGET_ROOT/tmp/var\" - }]" -i $COPY_CONFIG - yq eval " - .scripts.postConfigure += [{ - \"name\": \"trigger-rollback\", - \"runOn\": [\"ab-update\"], - \"path\": \"$SCRIPT_HOST_COPY\" - }] - " -i $COPY_CONFIG - - # Set writableEtcOverlayHooks flag under internalParams to true, so that the script - # can create a new systemd service - yq eval ".internalParams.writableEtcOverlayHooks = true" -i $COPY_CONFIG - sshCommand "sudo tee $UPDATE_CONFIG > /dev/null" < $COPY_CONFIG - - # Print out the contents of the update config to validate that the script was injected - echo "Updated Host Configuration:" - sshCommand "sudo cat $UPDATE_CONFIG" - fi - - sshCommand "sudo cat $UPDATE_CONFIG" - - # Masking errors as we want to report the specific failure if it happens - set +e - - sshCommand "sudo trident update $LOGGING $UPDATE_CONFIG --allowed-operations stage" - STAGE_RESULT=$? - - if [ "$OUTPUT" != "" ]; then - PAD_ITERATION=$(printf "%03d" $i) - scp -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null $SSH_USER@$VM_IP:/var/log/trident-full.log $OUTPUT/$PAD_ITERATION-staged-trident-full.log - fi - - if [ $STAGE_RESULT -ne 0 ]; then - echo "Failed to stage update" - adoError "Failed to stage update for iteration $i" - exit 1 - fi - - set -e - - # Masking errors as the VM will be rebooting - set +e - - sshCommand "sudo trident update $LOGGING $UPDATE_CONFIG --allowed-operations finalize" - - LOGGING="" - if [ $VERBOSE == True ]; then - echo "VM serial log:" - LOGGING="-v" - fi - - if [ "$TEST_PLATFORM" == "qemu" ]; then - waitForLogin $i - elif [ "$TEST_PLATFORM" == "azure" ]; then - sleep 15 - SUCCESS=false - for j in $(seq 1 10); do - if sshCommand hostname; then - SUCCESS=true - break - fi - sleep 5 - done - if [ "$SUCCESS" == false ]; then - echo "VM did not come back up after update" - adoError "VM did not come back up after update for iteration $i" - exit 1 - fi - fi - set -e - - # Check that Trident updated correctly - NEW_IP=`getIp` - if [ "$NEW_IP" != "$VM_IP" ]; then - echo "VM IP changed from $VM_IP to $NEW_IP" - exit 1 - fi - checkActiveVolume $EXPECTED_VOLUME $i - - # If this is a rollback scenario and we're on 1st iteration, validate that firmware - # performed a rollback and that Trident detected it successfully - if [ "$ROLLBACK" == "true" ] && [ $i -eq 1 ]; then - validateRollback - fi - - if [ $VERBOSE == True ]; then - sshCommand "sudo trident get" - fi - - if [ "$EXPECTED_VOLUME" == "volume-a" ]; then - EXPECTED_VOLUME="volume-b" - # If this is a rollback scenario and we're on 1st iteration, do not update the config path - if [ "$ROLLBACK" != "true" ] || [ $i -ne 1 ]; then - UPDATE_CONFIG="/var/lib/trident/update-config.yaml" - fi - else - EXPECTED_VOLUME="volume-a" - if [ "$ROLLBACK" != "true" ] || [ $i -ne 1 ]; then - UPDATE_CONFIG="/var/lib/trident/update-config2.yaml" - fi - fi -done \ No newline at end of file diff --git a/scripts/loop-update/publish-sig-image-prepare.sh b/scripts/loop-update/publish-sig-image-prepare.sh deleted file mode 100755 index 9fd5d50f4..000000000 --- a/scripts/loop-update/publish-sig-image-prepare.sh +++ /dev/null @@ -1,87 +0,0 @@ -#!/bin/bash - -set -euxo pipefail - -SCRIPTS_DIR="$( dirname "$0" )" -. "$SCRIPTS_DIR/common.sh" - -az account set --subscription "$SUBSCRIPTION" - -if [ -z "${STORAGE_CONTAINER_NAME:-}" ]; then - echo "STORAGE_CONTAINER_NAME is not set. Exiting..." - exit 1 -fi - -if [ -z "${IMAGE_DEFINITION:-}" ]; then - echo "IMAGE_DEFINITION is not set. Exiting..." - exit 1 -fi -# Ensure access when running in Azure DevOps, since this has not been -# working reliably in the past -if [ ! -z "${BUILD_BUILDNUMBER:-}" ]; then - ensureAzureAccess "$RESOURCE_GROUP" -fi - -if [ "`azCommand group exists -n "$RESOURCE_GROUP"`" == "false" ]; then - azCommand group create -n "$RESOURCE_GROUP" -l "$PUBLISH_LOCATION" -fi -if [ "`azCommand group exists -n "$GALLERY_RESOURCE_GROUP"`" == "false" ]; then - azCommand group create -n "$GALLERY_RESOURCE_GROUP" -l "$PUBLISH_LOCATION" -fi - -# Ensure STORAGE_ACCOUNT exists and the managed identity has access -STORAGE_ACCOUNT_RESOURCE_ID="/subscriptions/$SUBSCRIPTION/resourceGroups/$RESOURCE_GROUP/providers/Microsoft.Storage/storageAccounts/$STORAGE_ACCOUNT" -if ! azCommand storage account show --ids "$STORAGE_ACCOUNT_RESOURCE_ID"; then - echo "Could not find storage account '$STORAGE_ACCOUNT' in the expected location. Creating the storage account." - - if [ "`azCommand storage account check-name --name "$STORAGE_ACCOUNT" --query nameAvailable`" == "false" ]; then - echo "Storage account name $STORAGE_ACCOUNT is not available" - exit 1 - fi - azCommand storage account create -g "$RESOURCE_GROUP" -n "$STORAGE_ACCOUNT" -l "$PUBLISH_LOCATION" --allow-shared-key-access false -fi - -# Ensure "build_target" storage container exists -CONTAINER_EXISTS="$(azCommand storage container exists --account-name "$STORAGE_ACCOUNT" --name "$STORAGE_CONTAINER_NAME" --auth-mode login | jq .exists)" -if [[ "$CONTAINER_EXISTS" != "true" ]]; then - echo "Could not find container '$STORAGE_CONTAINER_NAME'. Creating container '$STORAGE_CONTAINER_NAME' in storage account '$STORAGE_ACCOUNT'..." - azCommand storage container create --account-name "$STORAGE_ACCOUNT" --name "$STORAGE_CONTAINER_NAME" --auth-mode login -fi - -# Ensure STEAMBOAT_GALLERY_NAME exists -if ! azCommand sig show -r "$GALLERY_NAME" -g "$GALLERY_RESOURCE_GROUP"; then - echo "Could not find image gallery '$GALLERY_NAME' in resource group '$GALLERY_RESOURCE_GROUP'. Creating the gallery." - azCommand sig create -g "$GALLERY_RESOURCE_GROUP" -r "$GALLERY_NAME" -l "$PUBLISH_LOCATION" -fi - -# Ensure the "build_target" image-definition exists -# Note: We publish only the VHD from the secure-prod the SIG -IMAGE_DEFINITION_EXISTS="$(azCommand sig image-definition list -r "$GALLERY_NAME" -g "$GALLERY_RESOURCE_GROUP" | grep "name" | grep -c "$IMAGE_DEFINITION" || :;)" # the "|| :;" prevents grep from halting the script when it finds no matches and exits with exit code 1 -if [[ "$IMAGE_DEFINITION_EXISTS" -eq 0 ]]; then - echo "Could not find image-definition '$IMAGE_DEFINITION'. Creating definition '$IMAGE_DEFINITION' in gallery '$GALLERY_NAME'..." - azCommand sig image-definition create -i "$IMAGE_DEFINITION" --publisher "$PUBLISHER" --offer "$OFFER" --sku "$IMAGE_DEFINITION" -r "$GALLERY_NAME" -g "$GALLERY_RESOURCE_GROUP" --os-type Linux -fi - -if ! which azcopy; then - # Install az-copy dependency - PIPELINE_AGENT_OS="$(cat "/etc/os-release" | grep "^ID=" | cut -d = -f 2)" - PIPELINE_AGENT_OS_VERSION="$(cat "/etc/os-release" | grep "^VERSION_ID=" | cut -d = -f 2 | tr -d '"')" - AZCOPY_DOWNLOAD_URL="https://packages.microsoft.com/config/$PIPELINE_AGENT_OS/$PIPELINE_AGENT_OS_VERSION/packages-microsoft-prod.deb" - curl -sSL -O "$AZCOPY_DOWNLOAD_URL" - CURL_STATUS=$? - if [ $CURL_STATUS -ne 0 ]; then - echo "Failed to download the debian package repo while attempting to install azcopy. The URL '$AZCOPY_DOWNLOAD_URL' returned the curl exit status: $CURL_STATUS" - echo "Suggestion: Are you using a new, non-ubuntu, pipeline agent? If yes, add azcopy installation logic for the new build agent." - exit 1 - fi - sudo dpkg -i packages-microsoft-prod.deb - rm packages-microsoft-prod.deb - sudo apt-get update -y - sudo apt-get install azcopy -y - azcopy --version - AZCOPY_STATUS=$? - if [ $AZCOPY_STATUS -ne 0 ]; then - echo "Failed to install azcopy." - exit 1 - fi -fi diff --git a/scripts/loop-update/publish-sig-image.sh b/scripts/loop-update/publish-sig-image.sh deleted file mode 100755 index e6105827b..000000000 --- a/scripts/loop-update/publish-sig-image.sh +++ /dev/null @@ -1,50 +0,0 @@ -#!/bin/bash - -set -euxo pipefail - -SCRIPTS_DIR="$( dirname "$0" )" -. "$SCRIPTS_DIR/common.sh" - -az account set --subscription "$SUBSCRIPTION" - -CURRENT_DATE="$(date +'%y%m%d')" -CURRENT_TIME="$(date +'%H%M%S')" - -STORAGE_ACCOUNT_URL="https://$STORAGE_ACCOUNT.blob.core.windows.net" -STORAGE_ACCOUNT_RESOURCE_ID="/subscriptions/$SUBSCRIPTION/resourceGroups/$RESOURCE_GROUP/providers/Microsoft.Storage/storageAccounts/$STORAGE_ACCOUNT" - -export STORAGE_CONTAINER_NAME="${STORAGE_CONTAINER_NAME:-$ALIAS-test}" -"$SCRIPTS_DIR/publish-sig-image-prepare.sh" -export IMAGE_PATH="${IMAGE_PATH:-$ARTIFACTS/trident-vm-verity-azure-testimage.vhd}" - -IMAGE_VERSION="`getImageVersion increment`" -echo using image version $IMAGE_VERSION - -if azCommand sig image-version show \ - --resource-group "$GALLERY_RESOURCE_GROUP" \ - --gallery-name "$GALLERY_NAME" \ - --gallery-image-definition "$IMAGE_DEFINITION" \ - --gallery-image-version "$IMAGE_VERSION"; then - echo "Image version $IMAGE_VERSION already exists. Exiting..." - exit 0 -fi - -STORAGE_BLOB_NAME="${CURRENT_DATE##+(0)}.${CURRENT_TIME##+(0)}-$IMAGE_VERSION.vhd" -STORAGE_BLOB_ENDPOINT="$STORAGE_ACCOUNT_URL/$STORAGE_CONTAINER_NAME/$STORAGE_BLOB_NAME" - -# Get the path to the VHD file -resizeImage "$IMAGE_PATH" - -# Upload the image artifact to Steamboat Storage Account -azcopy copy "$IMAGE_PATH" "$STORAGE_BLOB_ENDPOINT" - -# Create Image Version from storage account blob -azCommand sig image-version create \ - --resource-group "$GALLERY_RESOURCE_GROUP" \ - --gallery-name "$GALLERY_NAME" \ - --gallery-image-definition "$IMAGE_DEFINITION" \ - --gallery-image-version "$IMAGE_VERSION" \ - --target-regions "$PUBLISH_LOCATION" \ - --location "$PUBLISH_LOCATION" \ - --os-vhd-storage-account "$STORAGE_ACCOUNT_RESOURCE_ID" \ - --os-vhd-uri "$STORAGE_BLOB_ENDPOINT" diff --git a/scripts/loop-update/servicing-tests.sh b/scripts/loop-update/servicing-tests.sh new file mode 100755 index 000000000..357645c8e --- /dev/null +++ b/scripts/loop-update/servicing-tests.sh @@ -0,0 +1,73 @@ +#!/bin/bash +set -euxo pipefail + +ARTIFACTS=${ARTIFACTS:-artifacts} +VM_NAME=${VM_NAME:-trident-vm-verity-test} +TEST_PLATFORM=${TEST_PLATFORM:-qemu} +VM_SERIAL_LOG=${VM_SERIAL_LOG:-/tmp/$VM_NAME.log} +VERBOSE=${VERBOSE:-False} +WATCH=${WATCH:-False} +OUTPUT=${OUTPUT:-} + +ALIAS=${ALIAS:-`whoami`} + +SUBSCRIPTION=${SUBSCRIPTION:-b8a0db63-c5fa-4198-8e2a-f9d6ff52465e} # CoreOS_AzureLinux_BMP_dev +IMAGE_DEFINITION=${IMAGE_DEFINITION:-trident-vm-grub-verity-azure-testimage} +RESOURCE_GROUP=${RESOURCE_GROUP:-azlinux_bmp_dev} +PUBLISH_LOCATION=${PUBLISH_LOCATION:-eastus2} +GALLERY_RESOURCE_GROUP=${GALLERY_RESOURCE_GROUP:-$ALIAS-trident-rg} +STORAGE_ACCOUNT=${STORAGE_ACCOUNT:-azlinuxbmpdev} +GALLERY_NAME=${GALLERY_NAME:-${ALIAS}_trident_gallery} +PUBLISHER=${PUBLISHER:-$ALIAS} +OFFER=${OFFER:-trident-vm-grub-verity-azure-offer} +export AZCOPY_AUTO_LOGIN_TYPE=${AZCOPY_AUTO_LOGIN_TYPE:-AZCLI} +TEST_RESOURCE_GROUP=${TEST_RESOURCE_GROUP:-$GALLERY_RESOURCE_GROUP-test} +TEST_VM_SIZE=${TEST_VM_SIZE:-Standard_D2ds_v5} +SSH_PRIVATE_KEY_PATH=${SSH_PRIVATE_KEY_PATH:-~/.ssh/id_rsa} +SSH_PUBLIC_KEY_PATH=${SSH_PUBLIC_KEY_PATH:-$SSH_PRIVATE_KEY_PATH.pub} +RETRY_COUNT=${RETRY_COUNT:-20} + +SSH_USER=testuser +UPDATE_PORT_A=8000 +UPDATE_PORT_B=8001 + + +SUDO="sudo" +if [ "$TEST_PLATFORM" == "azure" ]; then + az login --identity + SUDO="" +fi + +FLAGS="" +if [ "$VERBOSE" == "true" ]; then + FLAGS="$FLAGS --verbose" +fi +if [ "$WATCH" == "true" ]; then + FLAGS="$FLAGS -w" +fi + +$SUDO ./bin/storm-trident run servicing $FLAGS \ + --artifacts-dir $ARTIFACTS \ + --output-path "$OUTPUT" \ + --storage-account-resource-group $RESOURCE_GROUP \ + --name $VM_NAME \ + --serial-log $VM_SERIAL_LOG \ + --platform $TEST_PLATFORM \ + --retry-count $RETRY_COUNT \ + --rollback-retry-count $RETRY_COUNT \ + --who-am-i $ALIAS \ + --subscription $SUBSCRIPTION \ + --image-definition $IMAGE_DEFINITION \ + --region $PUBLISH_LOCATION \ + --gallery-resource-group $GALLERY_RESOURCE_GROUP \ + --storage-account $STORAGE_ACCOUNT \ + --gallery-name $GALLERY_NAME \ + --offer $OFFER \ + --test-resource-group $TEST_RESOURCE_GROUP \ + --size $TEST_VM_SIZE \ + --ssh-private-key-path $SSH_PRIVATE_KEY_PATH \ + --ssh-public-key-path $SSH_PUBLIC_KEY_PATH \ + --user $SSH_USER \ + --update-port-a $UPDATE_PORT_A \ + --update-port-b $UPDATE_PORT_B \ + --force-cleanup diff --git a/selinux-policy-trident/trident.fc b/selinux-policy-trident/trident.fc new file mode 100644 index 000000000..1fe2cea99 --- /dev/null +++ b/selinux-policy-trident/trident.fc @@ -0,0 +1,3 @@ +/usr/bin/trident -- gen_context(system_u:object_r:trident_exec_t,s0) + +/var/lib/trident(/.*)? gen_context(system_u:object_r:trident_var_lib_t,s0) \ No newline at end of file diff --git a/selinux-policy-trident/trident.if b/selinux-policy-trident/trident.if new file mode 100644 index 000000000..0d5a5cf70 --- /dev/null +++ b/selinux-policy-trident/trident.if @@ -0,0 +1,163 @@ + +## policy for trident + +######################################## +## +## Execute trident_exec_t in the trident domain. +## +## +## +## Domain allowed to transition. +## +## +# +interface(`trident_domtrans',` + gen_require(` + type trident_t, trident_exec_t; + ') + + corecmd_search_bin($1) + domtrans_pattern($1, trident_exec_t, trident_t) +') + +###################################### +## +## Execute trident in the caller domain. +## +## +## +## Domain allowed access. +## +## +# +interface(`trident_exec',` + gen_require(` + type trident_exec_t; + ') + + corecmd_search_bin($1) + can_exec($1, trident_exec_t) +') + +######################################## +## +## Search trident lib directories. +## +## +## +## Domain allowed access. +## +## +# +interface(`trident_search_lib',` + gen_require(` + type trident_var_lib_t; + ') + + allow $1 trident_var_lib_t:dir search_dir_perms; + files_search_var_lib($1) +') + +######################################## +## +## Read trident lib files. +## +## +## +## Domain allowed access. +## +## +# +interface(`trident_read_lib_files',` + gen_require(` + type trident_var_lib_t; + ') + + files_search_var_lib($1) + read_files_pattern($1, trident_var_lib_t, trident_var_lib_t) +') + +######################################## +## +## Manage trident lib files. +## +## +## +## Domain allowed access. +## +## +# +interface(`trident_manage_lib_files',` + gen_require(` + type trident_var_lib_t; + ') + + files_search_var_lib($1) + manage_files_pattern($1, trident_var_lib_t, trident_var_lib_t) +') + +######################################## +## +## Manage trident lib directories. +## +## +## +## Domain allowed access. +## +## +# +interface(`trident_manage_lib_dirs',` + gen_require(` + type trident_var_lib_t; + ') + + files_search_var_lib($1) + manage_dirs_pattern($1, trident_var_lib_t, trident_var_lib_t) +') + + +######################################## +## +## All of the rules required to administrate +## an trident environment +## +## +## +## Domain allowed access. +## +## +## +## +## Role allowed access. +## +## +## +# +interface(`trident_admin',` + gen_require(` + type trident_t; + type trident_var_lib_t; + ') + + allow $1 trident_t:process { signal_perms }; + ps_process_pattern($1, trident_t) + + optional_policy(` + tunable_policy(`allow_ptrace',` + allow $1 trident_t:process ptrace; + ') + ') + + optional_policy(` + tunable_policy(`deny_ptrace',` + allow $1 trident_t:process ptrace; + ') + ') + + files_search_var_lib($1) + admin_pattern($1, trident_var_lib_t) + optional_policy(` + systemd_passwd_agent_exec($1) + systemd_read_fifo_file_passwd_run($1) + ') +') diff --git a/selinux-policy-trident/trident.te b/selinux-policy-trident/trident.te new file mode 100644 index 000000000..4df4d2777 --- /dev/null +++ b/selinux-policy-trident/trident.te @@ -0,0 +1,923 @@ +policy_module(trident, 1.0.0) + +######################################## +# +# Declarations +# + +type trident_t; +type trident_exec_t; + +# Creates a domain for long running process (daemon), which is started by an init script. Domain is trident_t and entry_point is tridnet_exec_t. +init_daemon_domain(trident_t, trident_exec_t) + +# Create type to label files and directories in /var/lib that are specific to Trident, in particular the Trident datastore. +type trident_var_lib_t; +files_type(trident_var_lib_t) + +#################### +# +# Trident policy +# +require { + type admin_passwd_exec_t; + type anacron_exec_t; + type audisp_remote_exec_t; + type audit_spool_t; + type auditctl_exec_t; + type auditd_exec_t; + type auditd_unit_t; + type boot_t; + type bpf_t; + type bootloader_t; + type cgroup_t; + type chfn_exec_t; + type chkpwd_exec_t; + type chronyc_exec_t; + type chronyd_unit_t; + type chronyd_var_lib_t; + type chronyd_var_log_t; + type cloud_init_exec_t; + type cloud_init_state_t; + type cloud_init_t; + type container_unit_t; + type container_var_lib_t; + type crack_db_t; + type crack_exec_t; + type cron_spool_t; + type crond_unit_t; + type dbusd_unit_t; + type debugfs_t; + type default_t; + type device_t; + type devpts_t; + type dhcpc_exec_t; + type dhcpc_state_t; + type dmesg_exec_t; + type dosfs_t; + type efivarfs_t; + type etc_runtime_t; + type fs_t; + type fsadm_exec_t; + type fsadm_t; + type fuse_device_t; + type fusefs_t; + type getty_exec_t; + type gpg_agent_exec_t; + type gpg_pinentry_exec_t; + type gpg_secret_t; + type groupadd_exec_t; + type home_root_t; + type init_t; + type init_exec_t; + type init_runtime_t; + type init_var_lib_t; + type initctl_t; + type iptables_unit_t; + type kernel_t; + type kmod_exec_t; + type krb5kdc_exec_t; + type ld_so_t; + type ldconfig_cache_t; + type lib_t; + type load_policy_t; + type locale_t; + type locate_exec_t; + type logrotate_unit_t; + type logrotate_var_lib_t; + type lost_found_t; + type lvm_metadata_t; + type lvm_t; + type lvm_unit_t; + type mail_spool_t; + type memory_pressure_t; + type mnt_t; + type modules_conf_t; + type modules_dep_t; + type modules_object_t; + type mount_t; + type mount_exec_t; + type mptctl_device_t; + type net_conf_t; + type ntpd_exec_t; + type ntpd_unit_t; + type oddjob_mkhomedir_exec_t; + type power_unit_t; + type proc_t; + type proc_kcore_t; + type proc_mdstat_t; + type proc_net_t; + type root_t; + type rpm_t; + type rpm_script_t; + type rpm_unit_t; + type security_t; + type setfiles_t; + type semanage_t; + type semanage_exec_t; + type shadow_lock_t; + type shadow_t; + type shell_exec_t; + type ssh_agent_exec_t; + type ssh_exec_t; + type ssh_home_t; + type ssh_port_t; + type sshd_t; + type sshd_keygen_unit_t; + type sshd_unit_t; + type sudo_exec_t; + type sulogin_exec_t; + type sysctl_fs_t; + type sysctl_kernel_t; + type sysctl_vm_overcommit_t; + type sysctl_vm_t; + type sysfs_t; + type syslog_conf_t; + type syslogd_exec_t; + type syslogd_runtime_t; + type syslogd_unit_t; + type systemd_analyze_exec_t; + type systemd_backlight_exec_t; + type systemd_backlight_unit_t; + type systemd_binfmt_exec_t; + type systemd_binfmt_unit_t; + type systemd_cgroups_exec_t; + type systemd_cgtop_exec_t; + type systemd_coredump_exec_t; + type systemd_coredump_var_lib_t; + type systemd_factory_conf_t; + type systemd_generator_t; + type systemd_generator_exec_t; + type systemd_homed_exec_t; + type systemd_homework_exec_t; + type systemd_hostnamed_exec_t; + type systemd_hw_exec_t; + type systemd_hwdb_t; + type systemd_journalctl_exec_t; + type systemd_locale_exec_t; + type systemd_logind_exec_t; + type systemd_machine_id_setup_exec_t; + type systemd_modules_load_exec_t; + type systemd_networkd_t; + type systemd_networkd_unit_t; + type systemd_networkd_exec_t; + type systemd_notify_exec_t; + type systemd_passwd_agent_exec_t; + type systemd_pcrphase_t; + type systemd_pcrphase_exec_t; + type systemd_pstore_exec_t; + type systemd_resolved_exec_t; + type systemd_rfkill_exec_t; + type systemd_rfkill_unit_t; + type systemd_sessions_exec_t; + type systemd_socket_proxyd_exec_t; + type systemd_stdio_bridge_exec_t; + type systemd_sysctl_exec_t; + type systemd_sysusers_exec_t; + type systemd_tmpfiles_exec_t; + type systemd_unit_t; + type systemd_update_done_exec_t; + type systemd_user_manager_unit_t; + type systemd_user_runtime_dir_exec_t; + type systemd_userdbd_exec_t; + type systemd_userdbd_unit_t; + type tmp_t; + type tmpfs_t; + type trident_t; + type udev_exec_t; + type udev_t; + type udevadm_t; + type unlabeled_t; + type unreserved_port_t; + type updpwd_exec_t; + type useradd_exec_t; + type user_tmp_t; + type user_devpts_t; + type usr_t; + type uuidd_exec_t; + type var_run_t; + + # Define object classes that SELinux can protect + class dir { add_name create getattr open read remove_name search write }; + class file { create getattr ioctl lock open read rename setattr relabelto unlink write map execute execute_no_trans }; + class chr_file getattr; + class filesystem getattr; + class lnk_file read; + class netlink_route_socket { bind create getattr getopt nlmsg_read read setopt write }; + class process { getsched noatsecure rlimitinh siginh }; + class capability sys_admin; + class security read_policy; + class service start; + class tcp_socket { connect create getattr getopt name_connect read setopt shutdown write }; + class udp_socket { create ioctl }; + + attribute can_change_object_identity; + + role unconfined_r; + role system_r; +} + +# Allow Trident to change (relabel) the security context of a file or directory +typeattribute trident_t can_change_object_identity; + +# Defines transition from trident_t to fsadm_t domain when Trident executes fsadm tool (i.e. mkfs) +type_transition trident_t fsadm_exec_t:process fsadm_t; + +# Defines transition from ci_unconfined_t to trident_t when Steamboat executes Trident, as well as +# specific permissions necessary for Steamboat testing +optional_policy(` + require { + type ci_unconfined_t; + role ci_unconfined_r; + } + type_transition ci_unconfined_t trident_exec_t:process trident_t; + allow ci_unconfined_t trident_exec_t:file { getattr open read execute }; + allow trident_t trident_exec_t:file entrypoint; + role ci_unconfined_r types trident_t; + + allow trident_t ci_unconfined_t:fd use; +') + +# Allow transition between unconfined_t and trident_t domains; necessary for an interactive run +optional_policy(` + unconfined_run_to(trident_t, trident_exec_t) +') + +#============= trident_t ============== +# Gives trident_t the following elevated privileges: +# dac_override and dac_read_search - allow access files and directories without necessary DAC permissions +# sys_ptrace - allow trident_t to trace or debug other processes +# sys_rawio - allow trident_t to perform I/O operations directly on hardware devices +allow trident_t self:capability { dac_override dac_read_search sys_ptrace sys_rawio }; + +allow trident_t self:alg_socket { accept bind create read write }; +allow trident_t self:capability { audit_write chown mknod net_admin sys_chroot sys_resource sys_admin fowner fsetid sys_boot ipc_lock sys_nice linux_immutable sys_module setgid setuid }; +allow trident_t self:fifo_file manage_fifo_file_perms; +allow trident_t self:netlink_audit_socket { create nlmsg_relay read write }; +allow trident_t self:netlink_kobject_uevent_socket { bind create getattr getopt read setopt }; +allow trident_t self:netlink_route_socket { bind create getattr nlmsg_read read write }; +allow trident_t self:process { getsched setsched getcap setpgid signull getattr signal }; +allow trident_t self:tcp_socket { connect create getattr getopt read setopt shutdown write }; +allow trident_t self:unix_dgram_socket { connect create write }; +allow trident_t self:udp_socket { connect create getattr }; +allow trident_t self:key { search write }; +allow trident_t self:sem { associate create destroy read unix_read unix_write write }; + +# Ensure any new files, directories, or symbolic links created by trident_t are automatically labeled with type trident_var_lib_t +files_var_lib_filetrans(trident_t, trident_var_lib_t, { dir file lnk_file }) + +# Allow trident_t domain to interact with files and directories labeled as trident_var_lib_t +# Necessary so Trident can interact with the datastore at /var/lib/trident +allow trident_t trident_var_lib_t:dir { getattr search read write add_name create remove_name open mounton relabelto }; +allow trident_t trident_var_lib_t:file { getattr setattr create open read write unlink lock }; + +# Allow Trident to relabel its executable +allow trident_t trident_exec_t:file relabelto; + +allow trident_t audit_spool_t:dir { getattr open read relabelto }; +allow trident_t auditctl_exec_t:file getattr; +allow trident_t auditd_exec_t:file getattr; +allow trident_t auditd_log_t:dir relabelto; +allow trident_t auditd_unit_t:file getattr; +allow trident_t admin_passwd_exec_t:file getattr; +allow trident_t anacron_exec_t:file getattr; +allow trident_t audisp_remote_exec_t:file getattr; +allow trident_t boot_t:dir { mounton create relabelto }; +allow trident_t boot_t:file relabelto; +allow trident_t bpf_t:dir search; +allow trident_t cgroup_t:filesystem getattr; +allow trident_t chfn_exec_t:file getattr; +allow trident_t chkpwd_exec_t:file getattr; +allow trident_t chronyc_exec_t:file getattr; +allow trident_t chronyd_unit_t:file getattr; +allow trident_t chronyd_var_lib_t:dir { getattr open read relabelto }; +allow trident_t chronyd_var_log_t:dir { getattr open read relabelto }; +allow trident_t cloud_init_exec_t:file getattr; +allow trident_t cloud_init_state_t:dir { list_dir_perms relabelto }; +allow trident_t cloud_init_state_t:lnk_file read_lnk_file_perms; +allow trident_t cloud_init_state_t:file getattr; +allow trident_t container_unit_t:file getattr; +allow trident_t container_var_lib_t:file getattr; +allow trident_t crack_db_t:dir { getattr open search read }; +allow trident_t crack_db_t:file getattr; +allow trident_t crack_db_t:lnk_file getattr; +allow trident_t crack_exec_t:file getattr; +allow trident_t cron_spool_t:dir { read relabelto }; +allow trident_t crond_unit_t:file { getattr read open ioctl }; +allow trident_t dbusd_unit_t:file getattr; +allow trident_t debugfs_t:filesystem getattr; +allow trident_t debugfs_t:dir search; +allow trident_t default_t:dir { getattr open read relabelto search }; +allow trident_t default_t:file relabelto; +allow trident_t device_t:filesystem { getattr mount unmount }; +allow trident_t devpts_t:chr_file { read write ioctl getattr }; +allow trident_t devlog_t:sock_file { getattr write }; +allow trident_t dhcpc_exec_t:file getattr; +allow trident_t dhcpc_state_t:dir { getattr open read relabelto }; +allow trident_t dmesg_exec_t:file { execute getattr map open read relabelto setattr unlink write }; +allow trident_t dosfs_t:filesystem { getattr mount unmount }; +allow trident_t efivarfs_t:filesystem getattr; +allow trident_t efivarfs_t:dir search; +allow trident_t etc_runtime_t:file { getattr open read relabelto relabelfrom setattr unlink }; +allow trident_t etc_t:file { create execute execute_no_trans link relabelfrom relabelto rename setattr unlink write append }; +allow trident_t etc_t:dir { mounton relabelfrom }; +allow trident_t faillog_t:file relabelto; +allow trident_t fs_t:filesystem { mount unmount }; +allow trident_t fsadm_t:process { siginh rlimitinh noatsecure transition }; +allow trident_t fsadm_t:fd use; +allow trident_t fsadm_t:fifo_file { read write }; +allow trident_t fsadm_exec_t:file { getattr open read execute execute_no_trans relabelto setattr unlink write }; +allow trident_t fuse_device_t:chr_file { read write open }; +allow trident_t fusefs_t:dir getattr; +allow trident_t fusefs_t:filesystem { mount unmount getattr }; +allow trident_t getty_exec_t:file getattr; +allow trident_t gpg_pinentry_exec_t:file getattr; +allow trident_t gpg_secret_t:file getattr; +allow trident_t groupadd_exec_t:file getattr; +allow trident_t home_root_t:dir { mounton read relabelto add_name create relabelfrom setattr write }; +allow trident_t home_root_t:file { create getattr ioctl open relabelfrom setattr write }; +allow trident_t init_exec_t:file { execute getattr map open read relabelto setattr unlink write }; +allow trident_t init_t:system { reboot start status }; +allow trident_t init_t:key search; +allow trident_t init_t:unix_stream_socket connectto; +allow trident_t initctl_t:fifo_file getattr; +allow trident_t init_runtime_t:dir { add_name write }; +allow trident_t init_runtime_t:file { create getattr open write }; +allow trident_t init_var_lib_t:dir { getattr open read search relabelto }; +allow trident_t iptables_unit_t:file getattr; +allow trident_t kernel_t:process setsched; +allow trident_t kernel_t:system { module_request ipc_info }; +allow trident_t kernel_t:unix_dgram_socket sendto; +allow trident_t kmod_exec_t:file { execute execute_no_trans getattr map open read relabelto setattr unlink write }; +allow trident_t krb5kdc_exec_t:file getattr; +allow trident_t lastlog_t:file relabelto; +allow trident_t ld_so_t:file { execute_no_trans relabelto setattr unlink write }; +allow trident_t ldconfig_cache_t:dir { getattr open read search relabelto }; +allow trident_t ldconfig_cache_t:file { getattr relabelto }; +allow trident_t lib_t:file { create relabelto rename setattr unlink write }; +allow trident_t locale_t:dir { add_name relabelto remove_name rmdir setattr write }; +allow trident_t locale_t:file { link relabelto rename setattr unlink write }; +allow trident_t locate_exec_t:file getattr; +allow trident_t logrotate_unit_t:file getattr; +allow trident_t logrotate_var_lib_t:dir { getattr open read relabelto }; +allow trident_t lost_found_t:dir { getattr open read relabelto }; +allow trident_t lvm_metadata_t:dir { getattr open read }; +allow trident_t lvm_unit_t:file getattr; +allow trident_t mail_spool_t:dir { list_dir_perms relabelto }; +allow trident_t memory_pressure_t:file { read open getattr setattr }; +allow trident_t mnt_t:dir { add_name create getattr mounton open read search write }; +allow trident_t modules_conf_t:file { create relabelto setattr unlink write }; +allow trident_t modules_conf_t:dir { write add_name }; +allow trident_t modules_dep_t:file { getattr ioctl map open read relabelto setattr unlink }; +allow trident_t modules_object_t:file { getattr open read relabelto setattr unlink }; +allow trident_t mount_exec_t:file { execute execute_no_trans getattr map open read relabelto setattr unlink write }; +allow trident_t mptctl_device_t:chr_file getattr; +allow trident_t net_conf_t:file { relabelto setattr unlink }; +allow trident_t ntpd_exec_t:file { execute getattr }; +allow trident_t ntpd_unit_t:file getattr; +allow trident_t oddjob_mkhomedir_exec_t:file getattr; +allow trident_t power_unit_t:file { getattr open read relabelto setattr unlink }; +allow trident_t proc_t:dir read; +allow trident_t proc_t:file { getattr open read ioctl }; +allow trident_t proc_t:filesystem { getattr mount unmount }; +allow trident_t proc_kcore_t:file getattr; +allow trident_t proc_mdstat_t:file { getattr open read }; +allow trident_t proc_net_t:file { open read }; +allow trident_t root_t:dir { add_name create mounton write }; +allow trident_t root_t:file { create getattr map open read relabelfrom write }; +allow trident_t rpm_unit_t:file getattr; +allow trident_t rpm_var_cache_t:dir relabelto; +allow trident_t rpm_var_cache_t:file relabelto; +allow trident_t rpm_var_lib_t:dir relabelto; +allow trident_t rpm_var_lib_t:file relabelto; +allow trident_t security_t:file { map write }; +allow trident_t security_t:filesystem getattr; +allow trident_t selinux_config_t:dir relabelfrom; +allow trident_t selinux_config_t:file relabelfrom; +allow trident_t semanage_exec_t:file { execute execute_no_trans entrypoint getattr ioctl open read map }; +allow trident_t semanage_read_lock_t:file relabelto; +allow trident_t semanage_store_t:dir relabelto; +allow trident_t semanage_store_t:file relabelto; +allow trident_t semanage_trans_lock_t:file relabelto; +allow trident_t setfiles_exec_t:file entrypoint; +allow trident_t shadow_t:file { getattr open read relabelto setattr link unlink write append relabelfrom }; +allow trident_t shadow_lock_t:file { create getattr lock open read link unlink write setattr relabelfrom }; +allow trident_t shell_exec_t:file { execute execute_no_trans getattr map open read relabelto setattr unlink write }; +allow trident_t ssh_agent_exec_t:file getattr; +allow trident_t ssh_exec_t:file { execute getattr }; +allow trident_t ssh_home_t:dir { setattr relabelto }; +allow trident_t ssh_home_t:file relabelto; +allow trident_t ssh_port_t:tcp_socket name_connect; +allow trident_t sshd_t:fd use; +allow trident_t sshd_t:fifo_file { read write getattr ioctl }; # Allow Trident to read/write stdin/stdout/stderr +allow trident_t sshd_keygen_unit_t:file getattr; +allow trident_t sshd_unit_t:file getattr; +allow trident_t sulogin_exec_t:file { execute getattr map open read relabelto setattr unlink write }; +allow trident_t sysctl_fs_t:dir search; +allow trident_t sysctl_fs_t:file { getattr ioctl open read }; +allow trident_t sysctl_kernel_t:dir search; +allow trident_t sysctl_kernel_t:file { getattr ioctl open read }; +allow trident_t sysctl_vm_overcommit_t:file { open read }; +allow trident_t sysctl_vm_t:dir search; +allow trident_t sysfs_t:filesystem { mount unmount }; +allow trident_t syslog_conf_t:file { getattr open read relabelto setattr unlink }; +allow trident_t syslogd_exec_t:file { execute getattr map open read relabelto setattr unlink write }; +allow trident_t syslogd_runtime_t:dir search; +allow trident_t syslogd_unit_t:file { getattr open read relabelto setattr unlink }; +allow trident_t system_cron_spool_t:dir relabelto; +allow trident_t system_dbusd_var_lib_t:dir relabelto; +allow trident_t system_dbusd_var_lib_t:lnk_file relabelto; +allow trident_t system_map_t:file relabelto; +allow trident_t systemd_analyze_exec_t:file getattr; +allow trident_t systemd_backlight_exec_t:file getattr; +allow trident_t systemd_backlight_unit_t:file getattr; +allow trident_t systemd_binfmt_exec_t:file getattr; +allow trident_t systemd_binfmt_unit_t:file getattr; +allow trident_t systemd_cgroups_exec_t:file { execute getattr map open read relabelto setattr unlink write }; +allow trident_t systemd_cgtop_exec_t:file getattr; +allow trident_t systemd_coredump_exec_t:file { execute getattr map open read relabelto setattr unlink write }; +allow trident_t systemd_coredump_var_lib_t:dir { getattr open read relabelto }; +allow trident_t systemd_factory_conf_t:dir { getattr open read search }; +allow trident_t systemd_factory_conf_t:file getattr; +allow trident_t systemd_generator_exec_t:file { execute getattr map open read relabelto setattr unlink write }; +allow trident_t systemd_homed_exec_t:file getattr; +allow trident_t systemd_homework_exec_t:file getattr; +allow trident_t systemd_hostnamed_exec_t:file { execute getattr }; +allow trident_t systemd_hw_exec_t:file getattr; +allow trident_t systemd_hwdb_t:file { getattr open read relabelto setattr unlink }; +allow trident_t systemd_journal_t:file { relabelfrom_file_perms map open read relabelto }; +allow trident_t systemd_journal_t:dir relabelto; +allow trident_t systemd_journalctl_exec_t:file { execute getattr map open read relabelto setattr unlink write }; +allow trident_t systemd_locale_exec_t:file getattr; +allow trident_t systemd_logind_exec_t:file getattr; +allow trident_t systemd_machine_id_setup_exec_t:file getattr; +allow trident_t systemd_modules_load_exec_t:file { execute getattr map open read relabelto setattr unlink write }; +allow trident_t systemd_networkd_unit_t:service { start status }; +allow trident_t systemd_networkd_exec_t:file { execute getattr }; +allow trident_t systemd_notify_exec_t:file getattr; +allow trident_t systemd_passwd_agent_exec_t:file { execute getattr map open read relabelto setattr unlink write }; +allow trident_t systemd_pcrphase_exec_t:file { execute getattr }; +allow trident_t pstore_t:dir search; +allow trident_t systemd_pstore_exec_t:file { execute getattr }; +allow trident_t systemd_resolved_exec_t:file { execute getattr }; +allow trident_t systemd_rfkill_exec_t:file getattr; +allow trident_t systemd_rfkill_unit_t:file getattr; +allow trident_t systemd_sessions_exec_t:file getattr; +allow trident_t systemd_socket_proxyd_exec_t:file getattr; +allow trident_t systemd_stdio_bridge_exec_t:file getattr; +allow trident_t systemd_sysctl_exec_t:file { execute getattr map open read relabelto setattr unlink write }; +allow trident_t systemd_sysusers_exec_t:file { execute getattr map open read relabelto setattr unlink write }; +allow trident_t systemd_tmpfiles_exec_t:file { execute getattr map open read relabelto setattr unlink write }; +allow trident_t systemd_unit_t:dir { read add_name create write }; +allow trident_t systemd_unit_t:file { getattr ioctl link open read relabelto rename setattr unlink write create }; +allow trident_t systemd_unit_t:lnk_file { getattr read create }; +allow trident_t systemd_unit_t:service { start status }; +allow trident_t systemd_update_done_exec_t:file getattr; +allow trident_t systemd_user_manager_unit_t:file getattr; +allow trident_t systemd_user_runtime_dir_exec_t:file getattr; +allow trident_t systemd_userdbd_exec_t:file getattr; +allow trident_t systemd_userdbd_unit_t:file getattr; +allow trident_t tmp_t:chr_file { create getattr unlink }; +allow trident_t tmp_t:dir { add_name create getattr mounton open read relabelfrom remove_name rmdir search setattr write relabelto }; +allow trident_t tmp_t:file { append create getattr ioctl open read relabelfrom rename setattr unlink write map execute link }; +allow trident_t tmp_t:lnk_file { create getattr read rename unlink }; +allow trident_t tmpfs_t:file { append create execute getattr ioctl mounton open read relabelto rename setattr unlink write map link }; +allow trident_t tmpfs_t:filesystem { getattr mount unmount }; +allow trident_t udev_exec_t:file { execute execute_no_trans getattr map open read relabelto setattr unlink write }; +allow trident_t udev_runtime_t:dir { read watch }; +allow trident_t unlabeled_t:chr_file { create getattr link rename unlink }; +allow trident_t unlabeled_t:dir { create add_name getattr setattr open read remove_name search write mounton relabelfrom ioctl rename reparent rmdir }; +allow trident_t unlabeled_t:file { create getattr lock open read setattr unlink write ioctl relabelfrom append execute execute_no_trans link map relabelto rename }; +allow trident_t unlabeled_t:lnk_file { create getattr read relabelfrom rename unlink }; +allow trident_t unreserved_port_t:tcp_socket name_connect; +allow trident_t updpwd_exec_t:file getattr; +allow trident_t useradd_exec_t:file { execute execute_no_trans getattr map open read }; +allow trident_t user_tmp_t:file { getattr open read }; +allow trident_t user_devpts_t:chr_file { ioctl read write }; +allow trident_t usr_t:dir { add_name create read relabelto remove_name rmdir setattr write relabelto mounton }; +allow trident_t usr_t:file { create execute execute_no_trans getattr ioctl link open read relabelto rename setattr unlink write }; +allow trident_t uuidd_exec_t:file getattr; +allow trident_t uuidd_var_lib_t:dir relabelto; +allow trident_t var_t:dir { mounton relabelto }; +allow trident_t var_lib_t:dir relabelto; +allow trident_t var_lib_t:file relabelto; +allow trident_t var_lock_t:lnk_file relabelto; +allow trident_t var_log_t:dir relabelto; +allow trident_t var_log_t:lnk_file relabelto; +allow trident_t var_run_t:dir { add_name create remove_name write }; +allow trident_t var_run_t:file { create getattr lock open read unlink write }; +allow trident_t var_run_t:lnk_file relabelto; +allow trident_t var_spool_t:dir relabelto; +allow trident_t wtmp_t:file relabelto; + +# Policies below must be optional for Steamboat +optional_policy(` + require { + type trident_t; + type bluetooth_unit_t; + } + allow trident_t bluetooth_unit_t:file getattr; +') +optional_policy(` + require { + type trident_t; + type colord_var_lib_t; + } + allow trident_t colord_var_lib_t:dir { getattr open read relabelto }; +') +optional_policy(` + require { + type trident_t; + type dhcpd_unit_t; + } + allow trident_t dhcpd_unit_t:file getattr; +') +optional_policy(` + require { + type trident_t; + type loadkeys_exec_t; + } + allow trident_t loadkeys_exec_t:file { execute getattr map open read relabelto setattr unlink write }; +') +optional_policy(` + require { + type trident_t; + type mdadm_exec_t; + type mdadm_unit_t; + type mdadm_runtime_t; + } + allow trident_t mdadm_exec_t:file { open read getattr map relabelto setattr unlink write execute execute_no_trans }; + allow trident_t mdadm_unit_t:file { getattr open read relabelto setattr unlink }; + allow trident_t mdadm_runtime_t:dir { add_name remove_name search write }; + raid_manage_mdadm_runtime_files(trident_t) +') + +#============= interfaces ============== +########################################### +# Authentication and User Management +########################################### +auth_create_faillog_files(trident_t) +auth_exec_pam(trident_t) +auth_login_entry_type(trident_t) +auth_read_lastlog(trident_t) +auth_read_login_records(trident_t) +domain_entry_file(trident_t, gpg_agent_exec_t) +gpg_entry_type(trident_t) +gpg_list_user_secrets(trident_t) +su_exec(trident_t) +usermanage_check_exec_passwd(trident_t) +userdom_list_user_home_dirs(trident_t) +userdom_read_user_home_content_files(trident_t) +userdom_search_user_runtime_root(trident_t) +userdom_relabel_generic_user_home_files(trident_t) +userdom_relabelto_user_home_dirs(trident_t) + +########################################### +# System Services and Daemons +########################################### +bootloader_exec(trident_t) +chronyd_exec(trident_t) +chronyd_read_config(trident_t) +chronyd_read_key_files(trident_t) +clock_exec(trident_t) +create_files_pattern(trident_t, cgroup_t, cgroup_t) +cron_exec(trident_t) +cron_exec_crontab(trident_t) +cron_read_system_spool(trident_t) +dbus_exec(trident_t) +dbus_manage_lib_files(trident_t) +dbus_read_config(trident_t) +dbus_read_lib_files(trident_t) +dbus_list_system_bus_runtime(trident_t) +dbus_system_bus_client(trident_t) +domain_read_all_domains_state(trident_t) +hostname_exec(trident_t) +init_domtrans(trident_t) +init_rw_stream_sockets(trident_t) +logrotate_exec(trident_t) +ps_process_pattern(trident_t, cloud_init_t) # equivalent to cloud_init_read_state(trident_t) +ssh_domtrans(trident_t) +ssh_domtrans_keygen(trident_t) +ssh_manage_home_files(trident_t) +systemd_list_journal_dirs(trident_t) +systemd_read_networkd_units(trident_t) +systemd_read_user_runtime_units_files(trident_t) +systemd_dbus_chat_logind(trident_t) +systemd_read_user_unit_files(trident_t) + +########################################### +# File System Operations +########################################### +files_create_boot_dirs(trident_t) +files_list_kernel_modules(trident_t) +files_list_spool(trident_t) +files_list_var(trident_t) +files_manage_boot_files(trident_t) +files_manage_etc_dirs(trident_t) +files_manage_etc_symlinks(trident_t) +files_mounton_runtime_dirs(trident_t) +files_read_etc_files(trident_t) +files_read_default_symlinks(trident_t) +files_read_kernel_symbol_table(trident_t) +files_read_usr_src_files(trident_t) +files_read_usr_symlinks(trident_t) +files_read_var_lib_files(trident_t) +files_search_etc(trident_t) +files_search_kernel_modules(trident_t) +files_search_locks(trident_t) +files_search_spool(trident_t) +files_search_var_lib(trident_t) +files_rw_etc_runtime_files(trident_t) +fstools_domtrans(trident_t) +fstools_relabelto_entry_files(trident_t) +fs_getattr_hugetlbfs(trident_t) +fs_getattr_iso9660_files(trident_t) +fs_getattr_iso9660_fs(trident_t) +fs_getattr_pstorefs(trident_t) +fs_getattr_tracefs(trident_t) +fs_getattr_xattr_fs(trident_t) +fs_list_efivars(trident_t) +fs_list_hugetlbfs(trident_t) +fs_manage_dos_dirs(trident_t) +fs_manage_dos_files(trident_t) +fs_manage_efivarfs_files(trident_t) +fs_manage_tmpfs_dirs(trident_t) +fs_manage_tmpfs_symlinks(trident_t) +fs_mounton_tmpfs(trident_t) +fs_read_iso9660_files(trident_t) +fs_watch_memory_pressure(trident_t) +mount_list_runtime(trident_t) + +########################################### +# Network Management +########################################### +iptables_exec(trident_t) +iptables_read_config(trident_t) +iptables_status(trident_t) +sysnet_exec_ifconfig(trident_t) +sysnet_read_config(trident_t) +sysnet_read_dhcp_config(trident_t) +sysnet_relabel_config(trident_t) +sysnet_write_config(trident_t) + +########################################### +# Storage Management +########################################### +dev_rw_loop_control(trident_t) +dev_rw_lvm_control(trident_t) +lvm_exec(trident_t) +lvm_read_config(trident_t) +manage_files_pattern(trident_t, mount_runtime_t, mount_runtime_t) +storage_getattr_fuse_dev(trident_t) +storage_getattr_scsi_generic_dev(trident_t) +storage_raw_read_removable_device(trident_t) +storage_raw_read_fixed_disk(trident_t) +storage_raw_write_fixed_disk(trident_t) + +########################################### +# SELinux Management +########################################### +corecmd_relabel_bin_files(trident_t) +files_relabel_etc_files(trident_t) +files_relabel_kernel_modules(trident_t) +files_relabelto_etc_runtime_files(trident_t) +files_relabelto_usr_files(trident_t) +libs_relabel_ld_so(trident_t) +libs_relabelto_lib_files(trident_t) +miscfiles_relabel_localization(trident_t) +relabel_files_pattern(trident_t, udev_rules_t, udev_rules_t) +selinux_load_policy(trident_t) +seutil_exec_checkpolicy(trident_t) +seutil_exec_loadpolicy(trident_t) +seutil_exec_setfiles(trident_t) +seutil_get_semanage_read_lock(trident_t) +seutil_get_semanage_trans_lock(trident_t) +seutil_manage_bin_policy(trident_t) +seutil_manage_config(trident_t) +seutil_manage_config_dirs(trident_t) +seutil_manage_file_contexts(trident_t) +seutil_manage_module_store(trident_t) +seutil_read_default_contexts(trident_t) +seutil_read_bin_policy(trident_t) +udev_relabel_rules_files(trident_t) + +########################################### +# Package Management +########################################### +rpm_delete_db(trident_t) +rpm_exec(trident_t) +rpm_read_cache(trident_t) +rpm_read_db(trident_t) + +########################################### +# Device Management +########################################### +corenet_getattr_ppp_dev(trident_t) +corenet_read_tun_tap_dev(trident_t) +dev_getattr_acpi_bios_dev(trident_t) +dev_getattr_autofs_dev(trident_t) +dev_getattr_framebuffer_dev(trident_t) +dev_getattr_generic_usb_dev(trident_t) +dev_getattr_mouse_dev(trident_t) +dev_getattr_pmqos_dev(trident_t) +dev_getattr_sysfs(trident_t) +dev_getattr_xserver_misc_dev(trident_t) +dev_list_sysfs(trident_t) +dev_manage_generic_blk_files(trident_t) +dev_manage_generic_dirs(trident_t) +dev_mounton(trident_t) +dev_mounton_sysfs_dirs(trident_t) +dev_read_input_dev(trident_t) +dev_read_kmsg(trident_t) +dev_read_rand(trident_t) +dev_read_raw_memory(trident_t) +dev_read_realtime_clock(trident_t) +dev_read_sysfs(trident_t) +dev_read_watchdog(trident_t) +dev_read_urand(trident_t) +dev_relabelfrom_generic_chr_files(trident_t) +dev_relabelfrom_vfio_dev(trident_t) +dev_rw_nvram(trident_t) +dev_rw_tpm(trident_t) +dev_rw_vhost(trident_t) +dev_search_sysfs(trident_t) +dev_write_sysfs(trident_t) +dev_write_urand(trident_t) +term_getattr_ptmx(trident_t) +term_use_virtio_console(trident_t) +term_getattr_pty_fs(trident_t) +udev_manage_rules_files(trident_t) +udev_read_runtime_files(trident_t) +udev_read_state(trident_t) + +########################################### +# System Command Execution +########################################### +can_exec(trident_t, sudo_exec_t) +corecmd_manage_bin_files(trident_t) +corecmd_bin_entry_type(trident_t) +corecmd_shell_entry_type(trident_t) +corecmd_search_bin(trident_t) +corecmd_exec_bin(trident_t) +corecmd_search_bin(trident_t) +kerberos_exec_kadmind(trident_t) +kerberos_read_config(trident_t) +libs_exec_ldconfig(trident_t) +libs_manage_lib_dirs(trident_t) +modutils_read_module_config(trident_t) +uuidd_manage_lib_dirs(trident_t) +optional_policy(` + require { + type tcsd_var_lib_t; + } + allow trident_t tcsd_var_lib_t:dir relabelto; + tcsd_manage_lib_dirs(trident_t) +') + +########################################### +# Logging and Monitoring +########################################### +logging_read_audit_config(trident_t) +logging_read_audit_log(trident_t) +logging_relabelto_devlog_sock_files(trident_t) +logging_search_logs(trident_t) +logging_stream_connect_journald_varlink(trident_t) +logging_manage_generic_log_dirs(trident_t) +logging_manage_generic_logs(trident_t) + +########################################### +# Miscellaneous +########################################### +optional_policy(` + miscfiles_read_generic_tls_privkey(trident_t) +') +optional_policy(` + miscfiles_read_man_pages(trident_t) +') +optional_policy(` + miscfiles_read_localization(trident_t) +') +optional_policy(` + miscfiles_read_generic_certs(trident_t) +') +optional_policy(` + xserver_read_xkb_libs(trident_t) +') + +#################### +# +# Additional permissions given to external domains +# +#============= bootloader_t ============== +# List the contents of generic tmpfs directories; required for RAID +fs_list_tmpfs(bootloader_t) + +#============= cloud_init_t ============== +allow cloud_init_t unlabeled_t:dir { add_name getattr remove_name search write }; +allow cloud_init_t unlabeled_t:file { create getattr ioctl open read rename write }; +allow cloud_init_t usr_t:dir { add_name create remove_name write }; + +files_exec_usr_files(cloud_init_t) +files_manage_usr_files(cloud_init_t) + +#============= fsadm_t ============== +role unconfined_r types fsadm_t; + +# Get the attributes of efivarfs filesystems +allow fsadm_t efivarfs_t:filesystem getattr; +allow fsadm_t trident_t:process { siginh rlimitinh noatsecure transition sigchld }; +allow fsadm_t fixed_disk_device_t:blk_file { open read write getattr ioctl }; +allow fsadm_t unlabeled_t:file map; + +# Create, read, write, and delete files on a efivarfs filesystem +fs_manage_efivarfs_files(fsadm_t) +fs_manage_tmpfs_dirs(fsadm_t) +fs_manage_tmpfs_files(fsadm_t) + +#============= loadkeys_t ============== +optional_policy(` + require { + type trident_t; + type loadkeys_t; + } + files_read_default_symlinks(loadkeys_t) + fs_search_tmpfs(loadkeys_t) +') + +#============= lvm_t ============== +# This is necessary for Trident to create encrypted volumes +allow lvm_t trident_t:sem { associate read unix_read unix_write write }; +allow lvm_t initrc_t:sem { associate read unix_read unix_write write }; + +#============= mount_t ============= +allow mount_t trident_var_lib_t:dir mounton; + +#============= systemd_pcrphase_t ============== +allow systemd_pcrphase_t tmpfs_t:dir { getattr open read search }; +allow systemd_pcrphase_t tmpfs_t:file { getattr lock open setattr write }; + +#============= rpm_t ============== +allow rpm_t unlabeled_t:dir { add_name getattr remove_name search write }; +allow rpm_t unlabeled_t:file { create getattr ioctl open read rename write }; +allow rpm_t rpm_script_t:process { noatsecure rlimitinh siginh }; + +#============= rpm_script_t ============== +# Allow RPM scripts to read SELinux policy (we currently apply trident.pp as a module in the Trident spec) +allow rpm_script_t security_t:security read_policy; + +allow rpm_script_t kernel_t:fd use; +allow rpm_script_t unlabeled_t:dir { add_name getattr remove_name search write }; +allow rpm_script_t unlabeled_t:file { create getattr ioctl open read rename write }; + +#============= semanage_t ============== +allow semanage_t proc_t:filesystem getattr; +allow semanage_t load_policy_t:process { noatsecure rlimitinh siginh }; +allow semanage_t setfiles_t:process { noatsecure rlimitinh siginh }; + +libs_manage_lib_dirs(semanage_t) +libs_manage_lib_files(semanage_t) + +#============= setfiles_t ============== +allow setfiles_t proc_t:filesystem getattr; + +#============= systemd_generator_t ============== +allow systemd_generator_t home_root_t:dir read; + +#============= udev_t ============== +allow udev_t cloud_init_t:fd use; +allow udev_t cloud_init_t:fifo_file { append write getattr }; +allow udev_t lvm_t:process { noatsecure rlimitinh siginh }; +allow udev_t unlabeled_t:file getattr; + +files_read_generic_tmp_files(udev_t) + +#============= udevadm_t ============== +allow udevadm_t cgroup_t:filesystem getattr; +allow udevadm_t self:netlink_route_socket { bind create getattr getopt nlmsg_read read setopt write }; +allow udevadm_t self:udp_socket { create ioctl }; +allow udevadm_t self:capability { sys_admin }; +allow udevadm_t systemd_hwdb_t:file { getattr map open read }; +allow udevadm_t kernel_t:fd use; + +# Allow udevadm to search kernel modules, read module configurations and dependencies +files_search_kernel_modules(udevadm_t) +modutils_read_module_config(udevadm_t) +modutils_read_module_deps(udevadm_t) + +# Read system network configuration files +sysnet_read_config(udevadm_t) + +# List systemd networkd runtime files +systemd_list_networkd_runtime(udevadm_t) + +# Manage udev rules, runtime directories, and files +udev_manage_rules_files(udevadm_t) +udev_manage_runtime_dirs(udevadm_t) +udev_manage_runtime_files(udevadm_t) + +# Read symbolic links in cgroup directories and search sysfs +read_lnk_files_pattern(udevadm_t, cgroup_t, cgroup_t) +dev_search_sysfs(udevadm_t) + +#============= unlabeled_t ============== +fs_associate_tmpfs(unlabeled_t) \ No newline at end of file diff --git a/setsail/src/translator/partitions.rs b/setsail/src/translator/partitions.rs index fde81507d..e008053d5 100644 --- a/setsail/src/translator/partitions.rs +++ b/setsail/src/translator/partitions.rs @@ -6,7 +6,7 @@ use std::{ use trident_api::{ config::{ Disk, FileSystem, FileSystemSource, HostConfiguration, MountOptions, MountPoint, - NewFileSystemType, Partition, PartitionSize, PartitionTableType, PartitionType, SwapDevice, + NewFileSystemType, Partition, PartitionSize, PartitionTableType, PartitionType, Swap, }, misc::IdGenerator, }; @@ -28,7 +28,7 @@ pub fn translate(input: &ParsedData, hc: &mut HostConfiguration, errors: &mut Ve let mut filesystems: Vec = Vec::new(); // List of all swap devices - let mut swap_devices: Vec = Vec::new(); + let mut swap_devices: Vec = Vec::new(); // Go over all parsed partitions for part in input.partitions.iter() { @@ -92,7 +92,7 @@ pub fn translate(input: &ParsedData, hc: &mut HostConfiguration, errors: &mut Ve }); if let PartitionMount::Swap = part.mntpoint { - swap_devices.push(SwapDevice { + swap_devices.push(Swap { device_id: partition_id.clone(), }); diff --git a/src/engine/boot/grub.rs b/src/engine/boot/grub.rs index e72d63d13..51295301c 100644 --- a/src/engine/boot/grub.rs +++ b/src/engine/boot/grub.rs @@ -263,7 +263,10 @@ pub(crate) mod functional_test { use const_format::formatcp; use maplit::btreemap; - use crate::{engine::storage::raid, OS_MODIFIER_BINARY_PATH}; + use crate::{ + engine::{boot::get_update_esp_dir_name, storage::raid}, + OS_MODIFIER_BINARY_PATH, + }; use osutils::{ block_devices, @@ -285,6 +288,38 @@ pub(crate) mod functional_test { status::ServicingType, }; + struct DropFile(PathBuf); + impl Drop for DropFile { + fn drop(&mut self) { + if let Err(e) = fs::remove_file(&self.0) { + eprintln!("Failed to remove file '{}': {}", self.0.display(), e); + } + } + } + + fn setup_mock_grub_configs(ctx: &EngineContext) -> (DropFile, DropFile) { + let grub_esp = include_str!("test_files/grub_esp.cfg"); + let grub_boot = include_str!("test_files/grub_boot.cfg"); + + let grub_esp_path = Path::new(ESP_MOUNT_POINT_PATH) + .join(ESP_EFI_DIRECTORY) + .join(get_update_esp_dir_name(ctx).expect("Failed to get update esp dir name")) + .join(GRUB2_CONFIG_FILENAME); + let grub_boot_path = Path::new(ROOT_MOUNT_POINT_PATH).join(GRUB2_CONFIG_RELATIVE_PATH); + + fs::create_dir_all(grub_esp_path.parent().unwrap()) + .expect("Failed to create directory for grub esp config"); + fs::create_dir_all(grub_boot_path.parent().unwrap()) + .expect("Failed to create directory for grub boot config"); + + fs::write(&grub_esp_path, grub_esp).expect("Failed to write grub esp config"); + let drop_file_esp = DropFile(grub_esp_path.clone()); + fs::write(&grub_boot_path, grub_boot).expect("Failed to write grub boot config"); + let drop_file_boot = DropFile(grub_boot_path.clone()); + + (drop_file_esp, drop_file_boot) + } + pub fn test_execute_and_resulting_layout(is_single_disk_raid: bool, unequal_partitions: bool) { let disk_bus_path = PathBuf::from(TEST_DISK_DEVICE_PATH); @@ -510,6 +545,7 @@ pub(crate) mod functional_test { "root1".into() => PathBuf::from(formatcp!("{TEST_DISK_DEVICE_PATH}2")), "root2".into() => PathBuf::from(formatcp!("{TEST_DISK_DEVICE_PATH}3")), }, + is_uki: Some(false), ..Default::default() }; @@ -554,6 +590,8 @@ pub(crate) mod functional_test { mkfs::run(root_device_path, MkfsFileSystemType::Ext4).unwrap(); + let _a = setup_mock_grub_configs(ctx); + update_configs(ctx, Path::new(OS_MODIFIER_BINARY_PATH)) } @@ -617,6 +655,8 @@ pub(crate) mod functional_test { let root_device_path = PathBuf::from(formatcp!("{TEST_DISK_DEVICE_PATH}2")); mkfs::run(&root_device_path, MkfsFileSystemType::Ext4).unwrap(); + let _a = setup_mock_grub_configs(&ctx); + update_configs(&ctx, Path::new(OS_MODIFIER_BINARY_PATH)).unwrap(); } @@ -692,6 +732,9 @@ pub(crate) mod functional_test { let root_device_path = PathBuf::from(formatcp!("{TEST_DISK_DEVICE_PATH}2")); mkfs::run(&root_device_path, MkfsFileSystemType::Ext4).unwrap(); + + let _a = setup_mock_grub_configs(&ctx); + update_configs(&ctx, Path::new(OS_MODIFIER_BINARY_PATH)).unwrap(); } @@ -742,6 +785,8 @@ pub(crate) mod functional_test { ..Default::default() }; + let _a = setup_mock_grub_configs(&ctx); + let result = update_configs(&ctx, Path::new(ROOT_MOUNT_POINT_PATH)); assert_eq!( result.unwrap_err().to_string(), @@ -796,6 +841,8 @@ pub(crate) mod functional_test { ..Default::default() }; + let _a = setup_mock_grub_configs(&ctx); + let result = update_configs(&ctx, Path::new(ROOT_MOUNT_POINT_PATH)); assert_eq!(result.unwrap_err().to_string(), "Root device path is none"); diff --git a/src/engine/boot/mod.rs b/src/engine/boot/mod.rs index 30b0067d8..0bacb69e1 100644 --- a/src/engine/boot/mod.rs +++ b/src/engine/boot/mod.rs @@ -4,10 +4,7 @@ use log::debug; use strum::IntoEnumIterator; use trident_api::{ - constants::{ - internal_params::ENABLE_UKI_SUPPORT, AB_VOLUME_A_NAME, AB_VOLUME_B_NAME, - AZURE_LINUX_INSTALL_ID_PREFIX, - }, + constants::{AB_VOLUME_A_NAME, AB_VOLUME_B_NAME, AZURE_LINUX_INSTALL_ID_PREFIX, VAR_TMP_PATH}, error::{ReportError, ServicingError, TridentError}, status::AbVolumeSelection, }; @@ -16,10 +13,10 @@ use crate::{engine::Subsystem, OS_MODIFIER_NEWROOT_PATH}; use super::EngineContext; -pub(super) mod esp; pub(super) mod grub; +pub mod uki; -pub(crate) const ESP_EXTRACTION_DIRECTORY: &str = "/tmp"; +pub(crate) const ESP_EXTRACTION_DIRECTORY: &str = VAR_TMP_PATH; #[derive(Default, Debug)] pub(super) struct BootSubsystem; @@ -28,20 +25,9 @@ impl Subsystem for BootSubsystem { "boot" } - #[tracing::instrument(name = "boot_provision", skip_all)] - fn provision(&mut self, ctx: &EngineContext, mount_point: &Path) -> Result<(), TridentError> { - // Perform file-based deployment of ESP images, if needed, after filesystems have been - // mounted and initialized. - - // Deploy ESP image - esp::deploy_esp(ctx, mount_point).structured(ServicingError::DeployESPImages)?; - - Ok(()) - } - #[tracing::instrument(name = "boot_configuration", skip_all)] fn configure(&mut self, ctx: &EngineContext) -> Result<(), TridentError> { - if ctx.spec.internal_params.get_flag(ENABLE_UKI_SUPPORT) { + if ctx.is_uki_image()? { debug!("Skipping grub configuration because UKI is in use"); return Ok(()); } diff --git a/src/engine/boot/test_files/grub_boot.cfg b/src/engine/boot/test_files/grub_boot.cfg new file mode 100644 index 000000000..6a68d40b8 --- /dev/null +++ b/src/engine/boot/test_files/grub_boot.cfg @@ -0,0 +1,137 @@ +# +# THIS FILE WAS EXTRACTED FROM THE GRUB-BASED VERSION OF THE FUNCTIONAL TEST +# IMAGE AT /boot/grub2/grub.cfg +# + +# +# DO NOT EDIT THIS FILE +# +# It is automatically generated by grub2-mkconfig using templates +# from /etc/grub.d and settings from /etc/default/grub +# + +### BEGIN /etc/grub.d/00_header ### +if [ -s $prefix/grubenv ]; then + load_env +fi +if [ "${next_entry}" ] ; then + set default="${next_entry}" + set next_entry= + save_env next_entry + set boot_once=true +else + set default="0" +fi + +if [ x"${feature_menuentry_id}" = xy ]; then + menuentry_id_option="--id" +else + menuentry_id_option="" +fi + +export menuentry_id_option + +if [ "${prev_saved_entry}" ]; then + set saved_entry="${prev_saved_entry}" + save_env saved_entry + set prev_saved_entry= + save_env prev_saved_entry + set boot_once=true +fi + +function savedefault { + if [ -z "${boot_once}" ]; then + saved_entry="${chosen}" + save_env saved_entry + fi +} + +function load_video { + if [ x$feature_all_video_module = xy ]; then + insmod all_video + else + insmod efi_gop + insmod efi_uga + insmod ieee1275_fb + insmod vbe + insmod vga + insmod video_bochs + insmod video_cirrus + fi +} + +terminal_output console +if [ x$feature_timeout_style = xy ] ; then + set timeout_style=menu + set timeout=0 +# Fallback normal timeout code in case the timeout_style feature is +# unavailable. +else + set timeout=0 +fi +### END /etc/grub.d/00_header ### + +### BEGIN /etc/grub.d/10_linux ### +menuentry 'AzureLinux GNU/Linux, with Linux 6.6.85.1-2.azl3' --class azurelinux --class gnu-linux --class gnu --class os $menuentry_id_option 'gnulinux-6.6.85.1-2.azl3-advanced-/dev/sdb2' { + load_video + set gfxpayload=keep + insmod gzio + insmod part_gpt + insmod ext2 + set root='hd0,gpt2' + if [ x$feature_platform_search_hint = xy ]; then + search --no-floppy --fs-uuid --set=root --hint-bios=hd0,gpt2 --hint-efi=hd0,gpt2 --hint-baremetal=ahci0,gpt2 1adc13e8-f140-4109-9eee-bd8e1efe96b8 + else + search --no-floppy --fs-uuid --set=root 1adc13e8-f140-4109-9eee-bd8e1efe96b8 + fi + echo 'Loading Linux 6.6.85.1-2.azl3 ...' + linux /boot/vmlinuz-6.6.85.1-2.azl3 root=UUID=1adc13e8-f140-4109-9eee-bd8e1efe96b8 ro security=selinux selinux=1 rd.auto=1 net.ifnames=0 lockdown=integrity console=tty0 console=ttyS0 rd.info log_buf_len=1M $kernelopts + echo 'Loading initial ramdisk ...' + initrd /boot/initramfs-6.6.85.1-2.azl3.img +} +menuentry 'AzureLinux GNU/Linux, with Linux 6.6.85.1-2.azl3 (recovery mode)' --class azurelinux --class gnu-linux --class gnu --class os $menuentry_id_option 'gnulinux-6.6.85.1-2.azl3-recovery-/dev/sdb2' { + load_video + set gfxpayload=keep + insmod gzio + insmod part_gpt + insmod ext2 + set root='hd0,gpt2' + if [ x$feature_platform_search_hint = xy ]; then + search --no-floppy --fs-uuid --set=root --hint-bios=hd0,gpt2 --hint-efi=hd0,gpt2 --hint-baremetal=ahci0,gpt2 1adc13e8-f140-4109-9eee-bd8e1efe96b8 + else + search --no-floppy --fs-uuid --set=root 1adc13e8-f140-4109-9eee-bd8e1efe96b8 + fi + echo 'Loading Linux 6.6.85.1-2.azl3 ...' + linux /boot/vmlinuz-6.6.85.1-2.azl3 root=UUID=1adc13e8-f140-4109-9eee-bd8e1efe96b8 ro single security=selinux selinux=1 rd.auto=1 net.ifnames=0 lockdown=integrity + echo 'Loading initial ramdisk ...' + initrd /boot/initramfs-6.6.85.1-2.azl3.img +} + +### END /etc/grub.d/10_linux ### + +### BEGIN /etc/grub.d/20_linux_xen ### + +### END /etc/grub.d/20_linux_xen ### + +### BEGIN /etc/grub.d/30_os-prober ### +### END /etc/grub.d/30_os-prober ### + +### BEGIN /etc/grub.d/30_uefi-firmware ### +menuentry 'UEFI Firmware Settings' $menuentry_id_option 'uefi-firmware' { + fwsetup +} +### END /etc/grub.d/30_uefi-firmware ### + +### BEGIN /etc/grub.d/40_custom ### +# This file provides an easy way to add custom menu entries. Simply type the +# menu entries you want to add after this comment. Be careful not to change +# the 'exec tail' line above. +### END /etc/grub.d/40_custom ### + +### BEGIN /etc/grub.d/41_custom ### +if [ -f ${config_directory}/custom.cfg ]; then + source ${config_directory}/custom.cfg +elif [ -z "${config_directory}" -a -f $prefix/custom.cfg ]; then + source $prefix/custom.cfg +fi +### END /etc/grub.d/41_custom ### diff --git a/src/engine/boot/test_files/grub_esp.cfg b/src/engine/boot/test_files/grub_esp.cfg new file mode 100644 index 000000000..945a1294b --- /dev/null +++ b/src/engine/boot/test_files/grub_esp.cfg @@ -0,0 +1,13 @@ +# +# THIS FILE WAS EXTRACTED FROM THE GRUB-BASED VERSION OF THE FUNCTIONAL TEST +# IMAGE at /boot/efi/boot/grub2/grub.cfg +# + +# The bootUUID in which the menuentry grub.cfg is defined; +# Can be either its own separate partition or part of the rootfs partition. +search -n -u 1adc13e8-f140-4109-9eee-bd8e1efe96b8 -s +# For images using grub2-mkconfig, $prefix is the variable +# grub expects to be populated with the proper path to the grub.cfg, grubenv. +# - $prefix: the path to /boot/grub2/ relative to the bootUUID +set prefix=($root)"/boot/grub2" +configfile $prefix/grub.cfg diff --git a/src/engine/boot/uki.rs b/src/engine/boot/uki.rs new file mode 100644 index 000000000..6e58d570a --- /dev/null +++ b/src/engine/boot/uki.rs @@ -0,0 +1,287 @@ +use std::{ + fs, + path::{Path, PathBuf}, +}; + +use anyhow::{ensure, Context, Error}; +use const_format::formatcp; +use log::{debug, trace}; + +use osutils::efivar; +use osutils::path::join_relative; +use trident_api::error::{ + InternalError, ReportError, ServicingError, TridentError, TridentResultExt, +}; +use trident_api::{ + constants::{ESP_EFI_DIRECTORY, ESP_MOUNT_POINT_PATH}, + status::AbVolumeSelection, +}; + +use crate::engine::EngineContext; + +/// Temporary name for the UKI file before renaming. +const TMP_UKI_NAME: &str = "vmlinuz-0.efi.staged"; +const UKI_DIRECTORY: &str = formatcp!("{ESP_EFI_DIRECTORY}/Linux"); + +fn uki_suffix(ctx: &EngineContext) -> String { + match ctx.ab_active_volume { + Some(AbVolumeSelection::VolumeA) => format!("azlb{}.efi", ctx.install_index), + None | Some(AbVolumeSelection::VolumeB) => format!("azla{}.efi", ctx.install_index), + } +} + +/// Return whether there is a staged UKI file on the ESP. +pub fn is_staged(esp_dir_path: &Path) -> bool { + esp_dir_path.join(UKI_DIRECTORY).join(TMP_UKI_NAME).exists() +} + +/// Copies the UKI file from the mounted image to the ESP directory. +pub fn stage_uki_on_esp(temp_mount_dir: &Path, mount_point: &Path) -> Result<(), Error> { + let uki_source_dir = temp_mount_dir.join(UKI_DIRECTORY); + let ukis: Vec<_> = uki_source_dir + .read_dir() + .context("Could not read UKI directory")? + .collect::, _>>() + .context("Failed while reading UKI directory")? + .into_iter() + .map(|entry| entry.path()) + .collect(); + + ensure!(!ukis.is_empty(), "No UKI files found within the image"); + ensure!(ukis.len() == 1, "Multiple UKI files found within the image"); + + let dest_path = join_relative(mount_point, ESP_MOUNT_POINT_PATH) + .join(UKI_DIRECTORY) + .join(TMP_UKI_NAME); + debug!("Staging UKI file at '{}'", dest_path.display()); + fs::copy(&ukis[0], dest_path).context("Failed to copy UKI to the ESP")?; + + Ok(()) +} + +/// Prepares the ESP directory structure required for UKI boot. +pub fn prepare_esp_for_uki(root_mount_point: &Path) -> Result<(), Error> { + let esp_root_path = join_relative(root_mount_point, ESP_MOUNT_POINT_PATH); + let esp_uki_directory = esp_root_path.join(UKI_DIRECTORY); + + fs::create_dir_all(&esp_uki_directory) + .context(format!("Failed to create '{UKI_DIRECTORY}' on the ESP"))?; + + fs::create_dir_all(esp_root_path.join("loader")) + .context("Failed to create directory loader")?; + fs::write(esp_root_path.join("loader/entries.srel"), "type1\n") + .context("Failed to write entries.srel")?; + + Ok(()) +} + +/// Enumerates existing UKIs in the given directory, returning their indices and suffixes. +fn enumerate_existing_ukis( + esp_uki_directory: &Path, +) -> Result, Error> { + let mut uki_entries = Vec::new(); + + for entry in fs::read_dir(esp_uki_directory).context(format!( + "Failed to read directory '{}'", + esp_uki_directory.display() + ))? { + let entry = entry.context("Failed to read entry")?; + let filename = entry.file_name(); + + if let Some((index, suffix)) = filename + .to_str() + .and_then(|filename| filename.strip_prefix("vmlinuz-")) + .and_then(|f| f.split_once('-')) + .and_then(|(index, suffix)| Some((index.parse::().ok()?, suffix.to_string()))) + { + uki_entries.push((index, suffix, entry.path())); + } else { + trace!( + "Ignoring existing UKI file '{}' that does not match Trident naming scheme", + entry.path().display() + ); + } + } + + Ok(uki_entries) +} + +/// Updates the boot order by renaming the UKI file according to Trident's naming scheme. +pub fn update_uki_boot_order( + ctx: &EngineContext, + esp_dir_path: &Path, + oneshot: bool, +) -> Result<(), TridentError> { + let esp_uki_directory = esp_dir_path.join(UKI_DIRECTORY); + let existing_ukis = + enumerate_existing_ukis(&esp_uki_directory).structured(ServicingError::EnumerateUkis)?; + let uki_suffix = uki_suffix(ctx); + + let mut max_index = 99; + for (index, suffix, path) in existing_ukis { + if suffix == uki_suffix { + fs::remove_file(&path) + .structured(ServicingError::UpdateUki) + .message(format!("Failed to remove file '{}'", path.display()))?; + } else { + max_index = max_index.max(index); + } + } + + let dest_path = esp_uki_directory.join(format!("vmlinuz-{}-{uki_suffix}", max_index + 1)); + let entry_name = dest_path + .file_name() // TODO: should be `file_stem` but systemd-boot doesn't seem to be following the spec. + .structured(InternalError::Internal("Failed to get file stem"))? + .to_str() + .structured(InternalError::Internal("Boot entry name isn't valid UTF-8"))?; + + debug!("Renaming UKI file to '{}'", dest_path.display()); + fs::rename(esp_uki_directory.join(TMP_UKI_NAME), &dest_path) + .structured(ServicingError::UpdateUki) + .message("Failed to rename staged UKI")?; + + if oneshot { + debug!("Setting oneshot boot entry to '{entry_name}'"); + efivar::set_oneshot(entry_name)?; + } else { + debug!("Setting default boot entry to '{entry_name}'"); + efivar::set_default(entry_name)?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs::File; + use tempfile::tempdir; + + #[test] + fn test_uki_suffix() { + use trident_api::status::AbVolumeSelection; + + let mut ctx = EngineContext { + ab_active_volume: Some(AbVolumeSelection::VolumeA), + install_index: 1, + ..Default::default() + }; + assert_eq!(uki_suffix(&ctx), "azlb1.efi"); + + ctx.ab_active_volume = Some(AbVolumeSelection::VolumeB); + ctx.install_index = 2; + assert_eq!(uki_suffix(&ctx), "azla2.efi"); + + ctx.ab_active_volume = None; + ctx.install_index = 3; + assert_eq!(uki_suffix(&ctx), "azla3.efi"); + } + + #[test] + fn test_is_staged() { + let mock_esp = tempdir().unwrap(); + let uki_dir = mock_esp.path().join(UKI_DIRECTORY); + fs::create_dir_all(&uki_dir).unwrap(); + assert!(!is_staged(mock_esp.path())); + + fs::write(uki_dir.join(TMP_UKI_NAME), b"dummy").unwrap(); + assert!(is_staged(mock_esp.path())); + } + + #[test] + fn test_copy_uki_to_esp() { + // Create source EFI/Linux directory and a dummy UKI file + let temp_mount = tempdir().unwrap(); + let src_uki_dir = temp_mount.path().join("EFI/Linux"); + fs::create_dir_all(&src_uki_dir).unwrap(); + fs::write(src_uki_dir.join("dummy-uki.efi"), b"uki-content").unwrap(); + + let mount_point = tempdir().unwrap(); + prepare_esp_for_uki(mount_point.path()).unwrap(); + + // Should succeed when exactly one UKI file is present + stage_uki_on_esp(temp_mount.path(), mount_point.path()).unwrap(); + + // Check that the file was copied to the correct destination + let dest_uki_file = join_relative(mount_point.path(), ESP_MOUNT_POINT_PATH) + .join(UKI_DIRECTORY) + .join(TMP_UKI_NAME); + assert_eq!(fs::read(&dest_uki_file).unwrap(), b"uki-content"); + + // Should fail if there are multiple UKI files + let extra_uki_file = src_uki_dir.join("another.efi"); + fs::write(&extra_uki_file, b"other").unwrap(); + stage_uki_on_esp(temp_mount.path(), mount_point.path()).unwrap_err(); + } + + #[test] + fn test_prepare_esp_for_uki() { + let root_mount = tempdir().unwrap(); + prepare_esp_for_uki(root_mount.path()).unwrap(); + + let esp_root_path = join_relative(root_mount.path(), ESP_MOUNT_POINT_PATH); + assert!(esp_root_path.join(UKI_DIRECTORY).exists()); + assert!(esp_root_path.join("loader").exists()); + assert!(esp_root_path.join("loader/entries.srel").exists()); + let content = fs::read_to_string(esp_root_path.join("loader/entries.srel")).unwrap(); + assert_eq!(content, "type1\n"); + } + + #[test] + fn test_enumerate_existing_ukis_empty_directory() { + let dir = tempdir().unwrap(); + let entries = enumerate_existing_ukis(dir.path()).unwrap(); + assert!(entries.is_empty()); + } + + #[test] + fn test_enumerate_existing_ukis_single_valid_entry() { + let dir = tempdir().unwrap(); + let uki_path = dir.path().join("vmlinuz-1-azla1.efi"); + File::create(&uki_path).unwrap(); + + let entries = enumerate_existing_ukis(dir.path()).unwrap(); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0], (1, "azla1.efi".to_string(), uki_path)); + } + + #[test] + fn test_enumerate_existing_ukis_multiple_valid_entries() { + let dir = tempdir().unwrap(); + let uki_path1 = dir.path().join("vmlinuz-1-azla1.efi"); + let uki_path2 = dir.path().join("vmlinuz-2-azlb2.efi"); + File::create(&uki_path1).unwrap(); + File::create(&uki_path2).unwrap(); + + let mut entries = enumerate_existing_ukis(dir.path()).unwrap(); + entries.sort_by_key(|e| e.0); + + assert_eq!(entries.len(), 2); + assert_eq!(entries[0], (1, "azla1.efi".to_string(), uki_path1)); + assert_eq!(entries[1], (2, "azlb2.efi".to_string(), uki_path2)); + } + + #[test] + fn test_enumerate_existing_ukis_ignores_invalid_entries() { + let dir = tempdir().unwrap(); + let valid_uki = dir.path().join("vmlinuz-3-azla3.efi"); + let invalid_uki1 = dir.path().join("invalid-file.efi"); + let invalid_uki2 = dir.path().join("vmlinuz-noindex-azla.efi"); + File::create(&valid_uki).unwrap(); + File::create(&invalid_uki1).unwrap(); + File::create(&invalid_uki2).unwrap(); + + let entries = enumerate_existing_ukis(dir.path()).unwrap(); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0], (3, "azla3.efi".to_string(), valid_uki)); + } + + #[test] + fn test_enumerate_existing_ukis_non_numeric_index() { + let dir = tempdir().unwrap(); + let invalid_uki = dir.path().join("vmlinuz-abc-azla.efi"); + File::create(&invalid_uki).unwrap(); + + let entries = enumerate_existing_ukis(dir.path()).unwrap(); + assert!(entries.is_empty()); + } +} diff --git a/src/engine/bootentries.rs b/src/engine/bootentries.rs index 12301f84e..c29d0363f 100644 --- a/src/engine/bootentries.rs +++ b/src/engine/bootentries.rs @@ -18,7 +18,10 @@ use trident_api::{ BlockDeviceId, }; -use super::{boot, EngineContext}; +use super::{ + boot::{self, uki}, + EngineContext, +}; /// Boot EFI executable const BOOT_EFI: &str = BootloaderExecutable::Boot.current_name(); @@ -104,7 +107,14 @@ pub fn create_and_update_boot_variables( )?; // Update boot variables - set_boot_next_and_update_boot_order(ctx, added_entry_numbers) + set_boot_next_and_update_boot_order(ctx, added_entry_numbers)?; + + if uki::is_staged(esp_path) { + let oneshot = ctx.servicing_type != ServicingType::CleanInstall; + uki::update_uki_boot_order(ctx, esp_path, oneshot)?; + } + + Ok(()) } /// Update the `BootNext` and potentially also `BootOrder`. diff --git a/src/engine/clean_install.rs b/src/engine/clean_install.rs index 891fbb04a..786d1c5a0 100644 --- a/src/engine/clean_install.rs +++ b/src/engine/clean_install.rs @@ -13,8 +13,8 @@ use osutils::{chroot, container, mount, mountpoint, path::join_relative}; use trident_api::{ config::{HostConfiguration, Operations}, constants::{ - internal_params::NO_TRANSITION, ESP_MOUNT_POINT_PATH, ROOT_MOUNT_POINT_PATH, - UPDATE_ROOT_PATH, + internal_params::{ENABLE_UKI_SUPPORT, NO_TRANSITION}, + ESP_MOUNT_POINT_PATH, ROOT_MOUNT_POINT_PATH, UPDATE_ROOT_PATH, }, error::{ InitializationError, InternalError, InvalidInputError, ReportError, ServicingError, @@ -25,9 +25,11 @@ use trident_api::{ use crate::{ datastore::DataStore, - engine::{self, boot::esp, bootentries, osimage, storage, EngineContext, SUBSYSTEMS}, + engine::{self, bootentries, install_index, storage, EngineContext, SUBSYSTEMS}, + monitor_metrics, + osimage::OsImage, subsystems::hooks::HooksSubsystem, - SAFETY_OVERRIDE_CHECK_PATH, + ExitKind, SAFETY_OVERRIDE_CHECK_PATH, }; #[cfg(feature = "grpc-dangerous")] use crate::{grpc, GrpcSender}; @@ -40,8 +42,9 @@ pub(crate) fn clean_install( state: &mut DataStore, allowed_operations: &Operations, multiboot: bool, + image: OsImage, #[cfg(feature = "grpc-dangerous")] sender: &mut Option, -) -> Result<(), TridentError> { +) -> Result { info!("Starting clean install"); tracing::info!(metric_name = "clean_install_start", value = true); let clean_install_start_time = Instant::now(); @@ -72,6 +75,7 @@ pub(crate) fn clean_install( &mut subsystems, state, host_config, + image, #[cfg(feature = "grpc-dangerous")] sender, )?; @@ -90,6 +94,7 @@ pub(crate) fn clean_install( debug!("Unmounting '{}'", root_mount.path().display()); root_mount.unmount_all()?; + Ok(ExitKind::Done) } else { finalize_clean_install( state, @@ -97,10 +102,8 @@ pub(crate) fn clean_install( Some(clean_install_start_time), #[cfg(feature = "grpc-dangerous")] sender, - )?; + ) } - - Ok(()) } /// Performs a safety check to ensure that the clean install can proceed. @@ -169,10 +172,20 @@ fn stage_clean_install( subsystems: &mut MutexGuard>>, state: &mut DataStore, host_config: &HostConfiguration, + image: OsImage, #[cfg(feature = "grpc-dangerous")] sender: &mut Option< mpsc::UnboundedSender>, >, ) -> Result { + // Best effort to measure memory, CPU, and network usage during execution + let monitor = match monitor_metrics::MonitorMetrics::new("stage_clean_install".to_string()) { + Ok(monitor) => Some(monitor), + Err(e) => { + warn!("Failed to create metrics monitor: {e:?}"); + None + } + }; + // Initialize a copy of the Host Status with the changes that are planned. We make a copy // rather than modifying the datastore's version so that we can wait until the clean install is // staged before committing the changes. @@ -184,9 +197,10 @@ fn stage_clean_install( partition_paths: Default::default(), // Will be initialized later disk_uuids: Default::default(), // Will be initialized later install_index: 0, // Will be initialized later - image: osimage::load_os_image(host_config)?, + image: Some(image), storage_graph: engine::build_storage_graph(&host_config.storage)?, // Build storage graph filesystems: Vec::new(), // Will be populated after dynamic validation + is_uki: Some(host_config.internal_params.get_flag(ENABLE_UKI_SUPPORT)), }; // Execute pre-servicing scripts @@ -215,7 +229,7 @@ fn stage_clean_install( &ctx.partition_paths, AbVolumeSelection::VolumeA, )?; - ctx.install_index = esp::next_install_index(newroot_mount.path())?; + ctx.install_index = install_index::next_install_index(newroot_mount.path())?; engine::provision(subsystems, &ctx, newroot_mount.path())?; @@ -224,6 +238,13 @@ fn stage_clean_install( .message("Failed to enter chroot")? .execute_and_exit(|| engine::configure(subsystems, &ctx)); + if let Some(mut monitor) = monitor { + // If the monitor was created successfully, stop it after execution + if let Err(e) = monitor.stop() { + warn!("Failed to stop metrics monitor: {e:?}"); + } + } + if let Err(original_error) = result { if let Err(e) = newroot_mount.unmount_all() { warn!("While handling an earlier error: {e:?}"); @@ -267,7 +288,7 @@ pub(crate) fn finalize_clean_install( new_root: Option, clean_install_start_time: Option, #[cfg(feature = "grpc-dangerous")] sender: &mut Option, -) -> Result<(), TridentError> { +) -> Result { info!("Finalizing clean install"); let ctx = EngineContext { @@ -281,6 +302,7 @@ pub(crate) fn finalize_clean_install( image: None, // Not used in finalize_clean_install storage_graph: engine::build_storage_graph(&state.host_status().spec.storage)?, // Build storage graph filesystems: Vec::new(), // Left empty since context does not have image + is_uki: None, }; let new_root = match new_root { @@ -343,12 +365,12 @@ pub(crate) fn finalize_clean_install( .internal_params .get_flag(NO_TRANSITION) { - engine::reboot() + Ok(ExitKind::NeedsReboot) } else { warn!( "Skipping reboot as requested by internal parameter '{}'", NO_TRANSITION ); - Ok(()) + Ok(ExitKind::Done) } } diff --git a/src/engine/context/mod.rs b/src/engine/context/mod.rs index db4f08d6b..2103e7fc6 100644 --- a/src/engine/context/mod.rs +++ b/src/engine/context/mod.rs @@ -1,15 +1,16 @@ use std::{ collections::{BTreeMap, HashMap}, - path::PathBuf, + path::{Path, PathBuf}, }; -use anyhow::{Context, Error}; +use anyhow::{bail, Context, Error}; use filesystem::FileSystemData; use log::{debug, trace}; use trident_api::{ - config::{HostConfiguration, VerityDevice}, + config::{HostConfiguration, Partition, VerityDevice}, constants::ROOT_MOUNT_POINT_PATH, + error::TridentError, status::{AbVolumeSelection, ServicingType}, storage_graph::graph::StorageGraph, BlockDeviceId, @@ -71,6 +72,9 @@ pub struct EngineContext { /// All of the filesystems in the system. pub filesystems: Vec, + + /// Whether the image will use a UKI or not. + pub is_uki: Option, } impl EngineContext { /// Returns the update volume selection for all A/B volume pairs. The update volume is the one @@ -242,20 +246,78 @@ impl EngineContext { Ok(verity_device_config) } + + /// Returns the first partition that backs the given block device, or Err if the block device ID + /// does not correspond to a partition or software RAID array. + pub(crate) fn get_first_backing_partition<'a>( + &'a self, + block_device_id: &BlockDeviceId, + ) -> Result<&'a Partition, Error> { + if let Some(partition) = self.spec.storage.get_partition(block_device_id) { + Ok(partition) + } else if let Some(array) = self + .spec + .storage + .raid + .software + .iter() + .find(|r| &r.id == block_device_id) + { + let partition_id = array + .devices + .first() + .context(format!("RAID array '{}' has no partitions", array.id))?; + + self.spec + .storage + .get_partition(partition_id) + .context(format!( + "RAID array '{}' doesn't reference partition", + block_device_id + )) + } else { + bail!("Block device '{block_device_id}' is not a partition or RAID array") + } + } + + /// Returns the estimated size of the block device holding the filesystem that contains the + /// given path. If the path is not mounted anywhere, or if the block device size cannot be + /// estimated, returns None. + pub(crate) fn filesystem_block_device_size(&self, path: impl AsRef) -> Option { + let device = self + .spec + .storage + .path_to_mount_point_info(path) + .and_then(|mp| mp.device_id)?; + + self.storage_graph.block_device_size(device) + } + + pub(crate) fn is_uki_image(&self) -> Result { + if let Some(is_uki) = self.is_uki { + return Ok(is_uki); + } + Err(TridentError::internal( + "is_uki() called without it being set", + )) + } } #[cfg(test)] mod tests { use super::*; + use std::str::FromStr; + use const_format::formatcp; use maplit::btreemap; use osutils::testutils::repart::TEST_DISK_DEVICE_PATH; use trident_api::config::{ - self, AbUpdate, AbVolumePair, Disk, FileSystem, FileSystemSource, MountOptions, MountPoint, - Partition, PartitionType, + self, AbUpdate, AbVolumePair, Disk, FileSystem, FileSystemSource, HostConfiguration, + MountOptions, MountPoint, Partition, PartitionSize, PartitionType, Raid, RaidLevel, + SoftwareRaidArray, Storage, VerityDevice, }; #[test] @@ -554,4 +616,101 @@ mod tests { "Failed to find configuration for verity device 'non-existent'" ); } + + #[test] + fn test_filesystem_block_device_size() { + let ctx = EngineContext::default().with_spec(HostConfiguration { + storage: Storage { + disks: vec![Disk { + id: "disk1".to_owned(), + device: PathBuf::from("/dev/sda"), + partitions: vec![Partition { + id: "part1".to_owned(), + size: 4096.into(), + partition_type: PartitionType::Root, + }], + ..Default::default() + }], + filesystems: vec![FileSystem { + device_id: Some("part1".to_owned()), + mount_point: Some("/data".into()), + source: FileSystemSource::Image, + }], + ..Default::default() + }, + ..Default::default() + }); + + assert_eq!(ctx.filesystem_block_device_size("/data"), Some(4096)); + + assert_eq!(ctx.filesystem_block_device_size("/data/subdir"), Some(4096)); + + assert_eq!(ctx.filesystem_block_device_size("/nonexistent"), None); + } + + #[test] + fn test_get_first_backing_partition() { + let ctx = EngineContext { + spec: HostConfiguration { + storage: Storage { + disks: vec![Disk { + id: "os".to_owned(), + partitions: vec![ + Partition { + id: "esp".to_owned(), + partition_type: PartitionType::Esp, + size: PartitionSize::from_str("1G").unwrap(), + }, + Partition { + id: "root".to_owned(), + partition_type: PartitionType::Root, + size: PartitionSize::from_str("8G").unwrap(), + }, + Partition { + id: "rootb".to_owned(), + partition_type: PartitionType::Root, + size: PartitionSize::from_str("8G").unwrap(), + }, + ], + ..Default::default() + }], + raid: Raid { + software: vec![SoftwareRaidArray { + id: "root-raid1".to_owned(), + devices: vec!["root".to_string(), "rootb".to_string()], + name: "raid1".to_string(), + level: RaidLevel::Raid1, + }], + ..Default::default() + }, + ..Default::default() + }, + ..Default::default() + }, + ..Default::default() + }; + + assert_eq!( + ctx.get_first_backing_partition(&"esp".to_owned()).unwrap(), + &ctx.spec.storage.disks[0].partitions[0] + ); + assert_eq!( + ctx.get_first_backing_partition(&"root".to_owned()).unwrap(), + &ctx.spec.storage.disks[0].partitions[1] + ); + assert_eq!( + ctx.get_first_backing_partition(&"rootb".to_owned()) + .unwrap(), + &ctx.spec.storage.disks[0].partitions[2] + ); + assert_eq!( + ctx.get_first_backing_partition(&"root-raid1".to_owned()) + .unwrap(), + &ctx.spec.storage.disks[0].partitions[1] + ); + ctx.get_first_backing_partition(&"os".to_owned()) + .unwrap_err(); + ctx.get_first_backing_partition(&"non-existant".to_owned()) + .unwrap_err(); + } } diff --git a/src/engine/install_index.rs b/src/engine/install_index.rs new file mode 100644 index 000000000..c26171b5a --- /dev/null +++ b/src/engine/install_index.rs @@ -0,0 +1,177 @@ +use std::path::Path; + +use log::{debug, trace}; + +use trident_api::{ + constants::{ESP_EFI_DIRECTORY, ESP_RELATIVE_MOUNT_POINT_PATH}, + error::{ReportError, TridentError, TridentResultExt, UnsupportedConfigurationError}, +}; + +use crate::engine::boot; + +/// Returns the next available install index for the current install. +pub fn next_install_index(mount_point: &Path) -> Result { + let esp_efi_path = mount_point + .join(ESP_RELATIVE_MOUNT_POINT_PATH) + .join(ESP_EFI_DIRECTORY); + + debug!( + "Looking for next available install index in '{}'", + esp_efi_path.display() + ); + let first_available_install_index = find_first_available_install_index(&esp_efi_path) + .message("Failed to find the first available install index")?; + + debug!("Selected first available install index: '{first_available_install_index}'",); + Ok(first_available_install_index) +} + +/// Tries to find the next available AzL install index by looking at the +/// ESP directory names present in the specified ESP EFI path. +fn find_first_available_install_index(esp_efi_path: &Path) -> Result { + Ok(boot::make_esp_dir_name_candidates() + // Take a limited number of candidates to avoid an infinite loop. + .take(1000) + // Go over all the candidates and find the first one that doesn't exist. + .find(|(idx, dir_names)| { + trace!("Checking if an install with index '{}' exists", idx); + // Returns true if all possible ESP directory names for this index + // do NOT exist. + dir_names.iter().all(|dir_names| { + let path = esp_efi_path.join(dir_names); + trace!("Checking if path '{}' exists", path.display()); + !path.exists() + }) + }) + .structured(UnsupportedConfigurationError::NoAvailableInstallIndex)? + .0) +} + +#[cfg(test)] +mod tests { + use super::*; + + use std::fs; + use tempfile::TempDir; + + use crate::engine::boot::make_esp_dir_name_candidates; + + /// Simple case for find_first_available_install_index + #[test] + fn test_find_first_available_install_index_simple() { + let test_dir = TempDir::new().unwrap(); + let index = find_first_available_install_index(test_dir.path()).unwrap(); + assert_eq!(index, 0, "First available index should be 0"); + } + + /// Test that find_first_available_install_index will skip unavailable + /// indices + #[test] + fn test_find_first_available_install_index_existing_all() { + let test_dir = TempDir::new().unwrap(); + + // Create all ESP directories for indices 0-9 + make_esp_dir_name_candidates() + .take(10) + .for_each(|(_, dir_names)| { + for dir_name in dir_names { + fs::create_dir(test_dir.path().join(dir_name)).unwrap(); + } + }); + + // The first available index should be 10 + let index = find_first_available_install_index(test_dir.path()).unwrap(); + assert_eq!(index, 10, "First available index should be 10"); + } + + /// Test that find_first_available_install_index will skip unavailable + /// indices, even when only the A volume IDs are present + #[test] + fn test_find_first_available_install_index_existing_a() { + let test_dir = TempDir::new().unwrap(); + + // Create Volume A ESP directories for indices 0-9 + make_esp_dir_name_candidates() + .take(10) + .for_each(|(_, dir_names)| { + fs::create_dir(test_dir.path().join(&dir_names[0])).unwrap(); + }); + + // The first available index should be 10 + let index = find_first_available_install_index(test_dir.path()).unwrap(); + assert_eq!(index, 10, "First available index should be 10"); + } + + /// Test that find_first_available_install_index will skip unavailable + /// indices, even when only the B volume IDs are present + #[test] + fn test_find_first_available_install_index_existing_b() { + let test_dir = TempDir::new().unwrap(); + + // Create Volume B ESP directories for indices 0-9 + make_esp_dir_name_candidates() + .take(10) + .for_each(|(_, dir_names)| { + fs::create_dir(test_dir.path().join(&dir_names[1])).unwrap(); + }); + + // The first available index should be 10 + let index = find_first_available_install_index(test_dir.path()).unwrap(); + assert_eq!(index, 10, "First available index should be 10"); + } + + /// Test that find_first_available_install_index will skip unavailable + /// indices, even when only ONE ID is present per install. + #[test] + fn test_find_first_available_install_index_existing_mixed_1() { + let test_dir = TempDir::new().unwrap(); + + // Iterator to cycle between 0 and 1 + let mut volume_selector = (0..=1).cycle(); + + // Create alternating A/B Volume ESP directories for indices 0-9, starting with A + make_esp_dir_name_candidates() + .take(10) + .for_each(|(_, dir_names)| { + fs::create_dir( + test_dir + .path() + .join(&dir_names[volume_selector.next().unwrap()]), + ) + .unwrap(); + }); + + // The first available index should be 10 + let index = find_first_available_install_index(test_dir.path()).unwrap(); + assert_eq!(index, 10, "First available index should be 10"); + } + + /// Test that find_first_available_install_index will skip unavailable + /// indices, even when only ONE ID is present per install. + #[test] + fn test_find_first_available_install_index_existing_mixed_2() { + let test_dir = TempDir::new().unwrap(); + + // Iterator to cycle between 0 and 1 + let mut volume_selector = (0..=1).cycle(); + + // Advance the volume selector to start with B + volume_selector.next(); + + // Create alternating A/B Volume ESP directories for indices 0-9, starting with B + make_esp_dir_name_candidates() + .take(10) + .for_each(|(_, dir_names)| { + fs::create_dir( + test_dir + .path() + .join(&dir_names[volume_selector.next().unwrap()]), + ) + .unwrap(); + }); + + // The first available index should be 10 + let index = find_first_available_install_index(test_dir.path()).unwrap(); + assert_eq!(index, 10, "First available index should be 10"); + } +} diff --git a/src/engine/mod.rs b/src/engine/mod.rs index f30efb0e1..41ae19d7e 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -12,7 +12,7 @@ use log::{debug, error, info, warn}; use osutils::{dependencies::Dependency, path::join_relative}; use trident_api::{ config::Storage, - constants::{self, internal_params::ENABLE_UKI_SUPPORT}, + constants, error::{InternalError, ReportError, ServicingError, TridentError, TridentResultExt}, status::{ServicingState, ServicingType}, storage_graph::graph::StorageGraph, @@ -21,6 +21,7 @@ use trident_api::{ use crate::{ engine::boot::BootSubsystem, subsystems::{ + esp::EspSubsystem, hooks::HooksSubsystem, initrd::InitrdSubsystem, management::ManagementSubsystem, @@ -38,7 +39,6 @@ mod clean_install; mod context; mod kexec; mod newroot; -mod osimage; pub mod provisioning_network; pub mod rollback; mod update; @@ -49,6 +49,7 @@ pub mod storage; // Helper modules mod etc_overlay; +pub(crate) mod install_index; pub(crate) use clean_install::{clean_install, finalize_clean_install}; pub(crate) use context::{filesystem, EngineContext}; @@ -105,6 +106,7 @@ pub(crate) trait Subsystem: Send { lazy_static::lazy_static! { static ref SUBSYSTEMS: Mutex>> = Mutex::new(vec![ Box::::default(), + Box::::default(), Box::::default(), Box::::default(), Box::::default(), @@ -290,7 +292,7 @@ fn configure( let use_overlay = (ctx.servicing_type == ServicingType::CleanInstall || ctx.servicing_type == ServicingType::AbUpdate) && ctx.storage_graph.root_fs_is_verity() - && !ctx.spec.internal_params.get_flag(ENABLE_UKI_SUPPORT); + && !ctx.is_uki_image()?; info!("Starting step 'Configure'"); for subsystem in subsystems { @@ -409,6 +411,18 @@ mod functional_test { // Create mock datastore directory and log file fs::create_dir_all(&datastore_path).unwrap(); + // ENSURE THE LOG AND METRICS FILES EXIST + fs::write( + TRIDENT_BACKGROUND_LOG_PATH, + "{\"message\":\"This is a mock background log file.\"}", + ) + .unwrap(); + fs::write( + TRIDENT_METRICS_FILE_PATH, + "{\"metric\":\"This is a mock metrics file.\"}", + ) + .unwrap(); + // Compose the log dir let log_dir = join_relative(newroot_path, datastore_dir); fs::create_dir_all(&log_dir).unwrap(); @@ -433,17 +447,13 @@ mod functional_test { // Create mock datastore directory and log file fs::create_dir_all(&datastore_path).unwrap(); - // Create a temp copy of TRIDENT_BACKGROUND_LOG_PATH - let temp_log_path = TRIDENT_BACKGROUND_LOG_PATH.to_owned() + ".temp"; - fs::copy(TRIDENT_BACKGROUND_LOG_PATH, &temp_log_path).unwrap(); - // Remove TRIDENT_BACKGROUND_LOG_PATH - fs::remove_file(TRIDENT_BACKGROUND_LOG_PATH).unwrap(); - - // Create a temp copy of TRIDENT_METRICS_FILE_PATH - let temp_metrics_path = TRIDENT_METRICS_FILE_PATH.to_owned() + ".temp"; - fs::copy(TRIDENT_METRICS_FILE_PATH, &temp_metrics_path).unwrap(); - // Remove TRIDENT_METRICS_FILE_PATH - fs::remove_file(TRIDENT_METRICS_FILE_PATH).unwrap(); + // ENSURE THE LOG AND METRICS FILES DO NOT EXIST + if Path::new(TRIDENT_BACKGROUND_LOG_PATH).exists() { + fs::remove_file(TRIDENT_BACKGROUND_LOG_PATH).unwrap(); + } + if Path::new(TRIDENT_METRICS_FILE_PATH).exists() { + fs::remove_file(TRIDENT_METRICS_FILE_PATH).unwrap(); + } // Persist the background log and metrics file let servicing_state = ServicingState::AbUpdateFinalized; @@ -453,11 +463,5 @@ mod functional_test { !persisted_log_and_metrics_exist(datastore_dir, servicing_state), "Trident background log and metrics should not be persisted." ); - - // Re-create TRIDENT_BACKGROUND_LOG_PATH by copying from the temp file - fs::copy(&temp_log_path, TRIDENT_BACKGROUND_LOG_PATH).unwrap(); - - // Re-create TRIDENT_METRICS_FILE_PATH by copying from the temp file - fs::copy(&temp_metrics_path, TRIDENT_METRICS_FILE_PATH).unwrap(); } } diff --git a/src/engine/newroot.rs b/src/engine/newroot.rs index b65425ce9..c36b1f323 100644 --- a/src/engine/newroot.rs +++ b/src/engine/newroot.rs @@ -6,7 +6,7 @@ use std::{ time::Duration, }; -use anyhow::{bail, ensure, Context, Error}; +use anyhow::{anyhow, bail, ensure, Context, Error}; use log::{debug, error, trace, warn}; use sys_mount::{MountBuilder, MountFlags}; @@ -496,11 +496,31 @@ fn prepare_mount_directory(target_path: &Path, is_newroot: bool) -> Result<(), E ); // Check if the directory is empty if let Ok(entries) = fs::read_dir(target_path) { - ensure!( - entries.count() == 0, - "Mount path '{}' is not empty", - target_path.display() - ); + let entries_list = entries + .filter_map(|e| match e { + Ok(ee) => Some(ee.path().to_string_lossy().into_owned()), + Err(err) => { + warn!( + "Failed to read entry in mount path '{}': {}", + target_path.display(), + err + ); + None + } + }) + .collect::>() + .join(", "); + if !entries_list.is_empty() { + error!( + "Mount path '{}' already exists and is non-empty: {}\n", + target_path.display(), + entries_list + ); + return Err(anyhow!( + "Mount path '{}' is not empty", + target_path.display() + )); + } } Ok(()) } else { @@ -697,10 +717,7 @@ mod functional_test { mkfs, mountpoint, repart::{RepartEmptyMode, RepartPartitionEntry, SystemdRepartInvoker}, testutils::{ - repart::{ - self, CDROM_DEVICE_PATH, CDROM_MOUNT_PATH, OS_DISK_DEVICE_PATH, - TEST_DISK_DEVICE_PATH, - }, + repart::{self, OS_DISK_DEVICE_PATH, TEST_DISK_DEVICE_PATH}, tmp_mount, }, udevadm, @@ -718,10 +735,19 @@ mod functional_test { #[functional_test(feature = "engine")] fn test_mount_and_umount() { - // CDROM device to be mounted - let device = Path::new(CDROM_DEVICE_PATH); + let loopback = repart::make_loopback_filesystem(MkfsFileSystemType::Vfat); + + let loop_device = Dependency::Losetup + .cmd() + .arg("-f") + .arg("--show") + .arg(loopback.path()) + .output_and_check() + .unwrap(); + let loop_device = loop_device.trim(); + // Mount point - let mount_point = Path::new(CDROM_MOUNT_PATH); + let mount_point = Path::new("/mnt/mountpoint"); if mountpoint::check_is_mountpoint(mount_point).unwrap() { mount::umount(mount_point, false).unwrap(); @@ -757,7 +783,7 @@ mod functional_test { }, partition_paths: btreemap! { "os".into() => PathBuf::from("/dev/sr"), - "sr0".into() => PathBuf::from(CDROM_DEVICE_PATH) + "sr0".into() => PathBuf::from(&loop_device), }, ..Default::default() }; @@ -767,17 +793,9 @@ mod functional_test { .mount_newroot_partitions(&ctx.spec, &ctx.partition_paths, AbVolumeSelection::VolumeA) .unwrap(); - // If device is a file, fetch the name of loop device that was mounted at mount point; - // otherwise, use the device path itself - let loop_device = if device.is_file() { - find_loop_device(device).unwrap() - } else { - device.to_string_lossy().to_string() - }; - // Validate that the device has been successfully mounted assert!( - is_device_mounted_at(&loop_device, mount_point), + is_device_mounted_at(loop_device, mount_point), "Device not mounted at the expected mount point" ); @@ -881,7 +899,7 @@ mod functional_test { // Validate that the device has been successfully unmounted assert!( - !is_device_mounted_at(&loop_device, mount_point), + !is_device_mounted_at(loop_device, mount_point), "Device '{loop_device}' still mounted at '{}'", mount_point.display() ); @@ -926,6 +944,8 @@ mod functional_test { #[functional_test(feature = "engine", negative = true)] fn test_mount_failure() { + let loopback = repart::make_loopback_filesystem(MkfsFileSystemType::Ext4); + let temp_mount_dir = TempDir::new().unwrap(); // bad mount path @@ -956,7 +976,7 @@ mod functional_test { }, partition_paths: btreemap! { "os".into() => PathBuf::from("/dev/sr"), - "sr0".into() => PathBuf::from(CDROM_DEVICE_PATH) + "sr0".into() => loopback.path().to_owned(), }, ..Default::default() }; @@ -1284,19 +1304,17 @@ mod functional_test { /// Attempt to prepare a directory within a read-only mounted filesystem #[functional_test(feature = "engine", negative = true)] fn test_prepare_mount_directory_ro() { - let temp_dir = TempDir::new().unwrap(); - - // CDROM device to be mounted for testing - let device = Path::new(CDROM_DEVICE_PATH); + let loopback = repart::make_loopback_filesystem(MkfsFileSystemType::Vfat); // Target path for the mount + let temp_dir = TempDir::new().unwrap(); let mount_point = temp_dir.path().join("mount_point"); fs::create_dir_all(&mount_point).unwrap(); // Mount the CDROM device and attempt to prepare a directory inside the read-only mount tmp_mount::mount( - device, - MountFileSystemType::Iso9660, + loopback.path(), + MountFileSystemType::Vfat, &["ro".into()], |mount_dir| { // Target path within the read-only mount diff --git a/src/engine/osimage.rs b/src/engine/osimage.rs deleted file mode 100644 index 0f7ced71c..000000000 --- a/src/engine/osimage.rs +++ /dev/null @@ -1,53 +0,0 @@ -use std::fmt::Write; - -use log::{debug, info}; - -use sysdefs::arch::SystemArchitecture; -use trident_api::{ - config::HostConfiguration, - error::{InvalidInputError, ReportError, TridentError}, -}; - -use crate::osimage::OsImage; - -/// Attempts to load an OS image based on the provided host configuration. -pub(super) fn load_os_image( - host_config: &HostConfiguration, -) -> Result, TridentError> { - let Some(os_image_source) = &host_config.image else { - return Err(TridentError::new(InvalidInputError::MissingOsImage)); - }; - - debug!("Loading COSI file '{}'", os_image_source.url); - let os_image = OsImage::cosi(os_image_source).structured(InvalidInputError::LoadCosi { - url: os_image_source.url.clone(), - })?; - - info!( - "Successfully loaded OS image of type '{}' from '{}'", - os_image.name(), - os_image.source() - ); - - // Ensure the OS image architecture matches the current system architecture - if SystemArchitecture::current() != os_image.architecture() { - return Err(TridentError::new( - InvalidInputError::MismatchedArchitecture { - expected: SystemArchitecture::current().into(), - actual: os_image.architecture().into(), - }, - )); - } - - debug!( - "OS image provides the following mount points:\n{}", - os_image - .available_mount_points() - .fold(String::new(), |mut acc, p| { - let _ = writeln!(acc, " - {}", p.display()); - acc - }) - ); - - Ok(Some(os_image)) -} diff --git a/src/engine/rollback.rs b/src/engine/rollback.rs index 9a9ac7189..ef44ba6bb 100644 --- a/src/engine/rollback.rs +++ b/src/engine/rollback.rs @@ -3,7 +3,7 @@ use std::path::PathBuf; use anyhow::{Context, Error}; use log::{debug, info, trace, warn}; -use osutils::{block_devices, lsblk, veritysetup}; +use osutils::{block_devices, efivar, lsblk, veritysetup}; use trident_api::{ constants::internal_params::VIRTDEPLOY_BOOT_ORDER_WORKAROUND, error::{InternalError, ReportError, ServicingError, TridentError, TridentResultExt}, @@ -37,6 +37,7 @@ pub fn validate_boot(datastore: &mut DataStore) -> Result<(), TridentError> { image: None, // Not used for boot validation logic storage_graph: engine::build_storage_graph(&datastore.host_status().spec.storage)?, // Build storage graph filesystems: Vec::new(), // Left empty since context does not have image + is_uki: None, }; // Get the block device path of the current root @@ -67,6 +68,13 @@ pub fn validate_boot(datastore: &mut DataStore) -> Result<(), TridentError> { bootentries::persist_boot_order() .message("Failed to persist boot order after reboot")?; } + + // If the bootloader set the LoaderEntrySelected variable, then make its value the default + // boot entry. Systemd-boot sets this variable, but GRUB does not. + if efivar::current_var_set() { + efivar::set_default_to_current() + .message("Failed to set default boot entry to current")?; + } } else if datastore.host_status().servicing_state == ServicingState::CleanInstallStaged || datastore.host_status().servicing_state == ServicingState::CleanInstallFinalized { diff --git a/src/engine/storage/encryption.rs b/src/engine/storage/encryption.rs index 43dee68ff..89c3af976 100644 --- a/src/engine/storage/encryption.rs +++ b/src/engine/storage/encryption.rs @@ -5,7 +5,7 @@ use std::{ path::{Path, PathBuf}, }; -use anyhow::{bail, Context, Error}; +use anyhow::{Context, Error}; use enumflags2::BitFlags; use log::{debug, info}; use serde::{Deserialize, Serialize}; @@ -18,10 +18,11 @@ use osutils::{ }; use sysdefs::tpm2::Pcr; use trident_api::{ - config::{HostConfiguration, HostConfigurationStaticValidationError, Partition, PartitionSize}, - constants::internal_params::{NO_CLOSE_ENCRYPTED_VOLUMES, REENCRYPT_ON_CLEAN_INSTALL}, + config::{HostConfiguration, HostConfigurationStaticValidationError, PartitionSize}, + constants::internal_params::{ + NO_CLOSE_ENCRYPTED_VOLUMES, OVERRIDE_ENCRYPTION_PCRS, REENCRYPT_ON_CLEAN_INSTALL, + }, error::{InvalidInputError, ReportError, ServicingError, TridentError}, - BlockDeviceId, }; use crate::engine::EngineContext; @@ -58,9 +59,9 @@ enum EncryptionType { Reencrypt, } -/// Provisions all configured encrypted volumes. -#[tracing::instrument(name = "encryption_provision", fields(total_partition_size_bytes = tracing::field::Empty), skip_all)] -pub(super) fn provision( +/// Sets up and opens encrypted devices. +#[tracing::instrument(name = "encrypted_devices_creation", fields(total_partition_size_bytes = tracing::field::Empty), skip_all)] +pub(super) fn create_encrypted_devices( ctx: &EngineContext, host_config: &HostConfiguration, ) -> Result<(), TridentError> { @@ -110,7 +111,7 @@ pub(super) fn provision( // Get the block device indicated by device_id if it is a partition; the first // partition of device_id if it is a RAID array; or, an error if device_id is neither // a partition nor a RAID array. - let partition = get_first_backing_partition(ctx, &ev.device_id).structured( + let partition = ctx.get_first_backing_partition(&ev.device_id).structured( InvalidInputError::from( HostConfigurationStaticValidationError::EncryptedVolumeNotPartitionOrRaid { encrypted_volume: ev.id.clone(), @@ -134,20 +135,43 @@ pub(super) fn provision( }, )?; + let encryption_type = if host_config + .internal_params + .get_flag(REENCRYPT_ON_CLEAN_INSTALL) + { + EncryptionType::Reencrypt + } else { + EncryptionType::LuksFormat + }; + + let pcrs = ctx + .spec + .internal_params + // Extract the parameter as a `Vec`, which is a list of PCRs to bind the + // encryption key to. + .get::>(OVERRIDE_ENCRYPTION_PCRS) + // Get the result from under the option. + .transpose() + .structured(InvalidInputError::InvalidInternalParameter { + name: OVERRIDE_ENCRYPTION_PCRS.to_string(), + explanation: format!( + "Failed to parse internal parameter '{}' as BitFlags", + OVERRIDE_ENCRYPTION_PCRS + ), + })? + // Convert the `Vec` into a `BitFlags`, which is a bitmask of PCRs. + .map(|v| BitFlags::::from_iter(v.into_iter())) + // If the internal parameter is not set, default to PCR 7. + .unwrap_or(Pcr::Pcr7.into()); + // Check if `REENCRYPT_ON_CLEAN_INSTALL` internal param is set to true; if so, re-encrypt // the device in-place. Otherwise, initialize a new LUKS2 volume. encrypt_and_open_device( &device_path, &ev.device_name, &key_file_path, - if host_config - .internal_params - .get_flag(REENCRYPT_ON_CLEAN_INSTALL) - { - EncryptionType::Reencrypt - } else { - EncryptionType::LuksFormat - }, + encryption_type, + pcrs, ) .structured(ServicingError::EncryptBlockDevice { device_path: device_path.to_string_lossy().to_string(), @@ -177,6 +201,7 @@ fn encrypt_and_open_device( device_name: &String, key_file: &Path, encryption_type: EncryptionType, + pcrs: BitFlags, ) -> Result<(), Error> { match encryption_type { EncryptionType::Reencrypt => { @@ -208,7 +233,7 @@ fn encrypt_and_open_device( // Enroll the TPM 2.0 device for the underlying device. Currently, we bind the enrollment to // PCR 7 by default. - encryption::systemd_cryptenroll(key_file, device_path, BitFlags::from(Pcr::Pcr7))?; + encryption::systemd_cryptenroll(key_file, device_path, pcrs)?; debug!( "Opening underlying encrypted device '{}' as '{}'", @@ -243,109 +268,3 @@ struct LuksDumpSegment { encryption: String, sector_size: u64, } - -/// Returns the first partition that backs the given block device, or Err if the block device ID -/// does not correspond to a partition or software RAID array. -fn get_first_backing_partition<'a>( - ctx: &'a EngineContext, - block_device_id: &BlockDeviceId, -) -> Result<&'a Partition, Error> { - if let Some(partition) = ctx.spec.storage.get_partition(block_device_id) { - Ok(partition) - } else if let Some(array) = ctx - .spec - .storage - .raid - .software - .iter() - .find(|r| &r.id == block_device_id) - { - let partition_id = array - .devices - .first() - .context(format!("RAID array '{}' has no partitions", array.id))?; - - ctx.spec - .storage - .get_partition(partition_id) - .context(format!( - "RAID array '{}' doesn't reference partition", - block_device_id - )) - } else { - bail!("Block device '{block_device_id}' is not a partition or RAID array") - } -} - -#[cfg(test)] -mod tests { - use super::*; - - use std::str::FromStr; - - use trident_api::config::{ - Disk, Partition, PartitionSize, PartitionType, Raid, RaidLevel, SoftwareRaidArray, Storage, - }; - - #[test] - fn test_get_first_backing_partition() { - let ctx = EngineContext { - spec: HostConfiguration { - storage: Storage { - disks: vec![Disk { - id: "os".to_owned(), - partitions: vec![ - Partition { - id: "esp".to_owned(), - partition_type: PartitionType::Esp, - size: PartitionSize::from_str("1G").unwrap(), - }, - Partition { - id: "root".to_owned(), - partition_type: PartitionType::Root, - size: PartitionSize::from_str("8G").unwrap(), - }, - Partition { - id: "rootb".to_owned(), - partition_type: PartitionType::Root, - size: PartitionSize::from_str("8G").unwrap(), - }, - ], - ..Default::default() - }], - raid: Raid { - software: vec![SoftwareRaidArray { - id: "root-raid1".to_owned(), - devices: vec!["root".to_string(), "rootb".to_string()], - name: "raid1".to_string(), - level: RaidLevel::Raid1, - }], - ..Default::default() - }, - ..Default::default() - }, - ..Default::default() - }, - ..Default::default() - }; - - assert_eq!( - get_first_backing_partition(&ctx, &"esp".to_owned()).unwrap(), - &ctx.spec.storage.disks[0].partitions[0] - ); - assert_eq!( - get_first_backing_partition(&ctx, &"root".to_owned()).unwrap(), - &ctx.spec.storage.disks[0].partitions[1] - ); - assert_eq!( - get_first_backing_partition(&ctx, &"rootb".to_owned()).unwrap(), - &ctx.spec.storage.disks[0].partitions[2] - ); - assert_eq!( - get_first_backing_partition(&ctx, &"root-raid1".to_owned()).unwrap(), - &ctx.spec.storage.disks[0].partitions[1] - ); - get_first_backing_partition(&ctx, &"os".to_owned()).unwrap_err(); - get_first_backing_partition(&ctx, &"non-existant".to_owned()).unwrap_err(); - } -} diff --git a/src/engine/storage/mod.rs b/src/engine/storage/mod.rs index 13ea77e91..65c84bc9a 100644 --- a/src/engine/storage/mod.rs +++ b/src/engine/storage/mod.rs @@ -20,8 +20,6 @@ pub mod verity; use super::EngineContext; -const ENCRYPTION_SUBSYSTEM_NAME: &str = "encryption"; - #[tracing::instrument(skip_all)] pub(super) fn create_block_devices(ctx: &mut EngineContext) -> Result<(), TridentError> { trace!( @@ -39,9 +37,8 @@ pub(super) fn create_block_devices(ctx: &mut EngineContext) -> Result<(), Triden partitioning::create_partitions(ctx).structured(ServicingError::CreatePartitions)?; raid::create_sw_raid(ctx, &ctx.spec).structured(ServicingError::CreateRaid)?; - encryption::provision(ctx, &ctx.spec).message(format!( - "Step 'Provision' failed for subsystem '{ENCRYPTION_SUBSYSTEM_NAME}'" - ))?; + encryption::create_encrypted_devices(ctx, &ctx.spec) + .message("Failed to create and open encrypted devices")?; Ok(()) } diff --git a/src/engine/storage/raid.rs b/src/engine/storage/raid.rs index e6f62ad41..9b0718124 100644 --- a/src/engine/storage/raid.rs +++ b/src/engine/storage/raid.rs @@ -15,6 +15,7 @@ use osutils::{block_devices, dependencies::Dependency, mdadm, udevadm}; use trident_api::{ config::{HostConfiguration, SoftwareRaidArray}, constants::MDSTAT_PATH, + error::TridentResultExt, BlockDeviceId, }; @@ -42,8 +43,17 @@ fn create(config: SoftwareRaidArray, ctx: &EngineContext) -> Result<(), Error> { let device_paths = get_device_paths(ctx, devices).context("Failed to get device paths")?; info!("Initializing '{}': creating RAID array", config.id); - mdadm::create(&config.device_path(), &config.level, device_paths) - .context("Failed to create RAID array")?; + + if ctx.is_uki_image().unstructured("UKI setting unknown")? { + // If UKI support is enabled, we need to create the RAID array with the + // homehost=any option to ensure that the RAID array can be opened by the + // runtime OS. + mdadm::create_homehost(&config.device_path(), &config.level, device_paths, "any") + } else { + mdadm::create(&config.device_path(), &config.level, device_paths) + } + .context("Failed to create RAID array")?; + Ok(()) } @@ -559,6 +569,7 @@ mod functional_test { }, ..Default::default() }, + is_uki: Some(false), ..Default::default() }; @@ -609,6 +620,7 @@ mod functional_test { }, ..Default::default() }, + is_uki: Some(false), ..Default::default() }; @@ -658,6 +670,7 @@ mod functional_test { }, ..Default::default() }, + is_uki: Some(false), ..Default::default() }; diff --git a/src/engine/update.rs b/src/engine/update.rs index a4c3c950a..7bd0f38f6 100644 --- a/src/engine/update.rs +++ b/src/engine/update.rs @@ -7,7 +7,10 @@ use tokio::sync::mpsc; use osutils::{chroot, container, path::join_relative}; use trident_api::{ config::{HostConfiguration, Operations}, - constants::{internal_params::NO_TRANSITION, ESP_MOUNT_POINT_PATH}, + constants::{ + internal_params::{ENABLE_UKI_SUPPORT, NO_TRANSITION}, + ESP_MOUNT_POINT_PATH, + }, error::{ InternalError, InvalidInputError, ReportError, ServicingError, TridentError, TridentResultExt, @@ -18,11 +21,14 @@ use trident_api::{ use crate::{ datastore::DataStore, engine::{ - self, bootentries, osimage, rollback, + self, bootentries, rollback, storage::{self, verity}, EngineContext, NewrootMount, SUBSYSTEMS, }, + monitor_metrics, + osimage::OsImage, subsystems::hooks::HooksSubsystem, + ExitKind, }; #[cfg(feature = "grpc-dangerous")] use crate::{grpc, GrpcSender}; @@ -34,8 +40,9 @@ pub(crate) fn update( host_config: &HostConfiguration, state: &mut DataStore, allowed_operations: &Operations, + image: OsImage, #[cfg(feature = "grpc-dangerous")] sender: &mut Option, -) -> Result<(), TridentError> { +) -> Result { info!("Starting update"); let mut subsystems = SUBSYSTEMS.lock().unwrap(); @@ -57,9 +64,10 @@ pub(crate) fn update( ab_active_volume: state.host_status().ab_active_volume, disk_uuids: state.host_status().disk_uuids.clone(), install_index: state.host_status().install_index, - image: osimage::load_os_image(host_config)?, + image: Some(image), storage_graph: engine::build_storage_graph(&host_config.storage)?, // Build storage graph filesystems: Vec::new(), // Will be populated after dynamic validation + is_uki: Some(host_config.internal_params.get_flag(ENABLE_UKI_SUPPORT)), }; // Before starting an update servicing, need to validate that the active volume is set @@ -84,7 +92,7 @@ pub(crate) fn update( .unwrap_or(ServicingType::NoActiveServicing); // Never None b/c select_servicing_type() returns a value if servicing_type == ServicingType::NoActiveServicing { info!("No update servicing required"); - return Ok(()); + return Ok(ExitKind::Done); } debug!( "Update of servicing type '{:?}' is required", @@ -137,6 +145,7 @@ pub(crate) fn update( None, state.host_status().servicing_state, ); + Ok(ExitKind::Done) } else { finalize_update( state, @@ -145,10 +154,8 @@ pub(crate) fn update( #[cfg(feature = "grpc-dangerous")] sender, ) - .message("Failed to finalize update")?; + .message("Failed to finalize update") } - - Ok(()) } ServicingType::NormalUpdate | ServicingType::HotPatch => { state.with_host_status(|host_status| { @@ -165,7 +172,7 @@ pub(crate) fn update( ); info!("Update of servicing type '{:?}' succeeded", servicing_type); - Ok(()) + Ok(ExitKind::Done) } ServicingType::CleanInstall => Err(TridentError::new( InvalidInputError::CleanInstallOnProvisionedHost, @@ -207,6 +214,15 @@ fn stage_update( } } + // Best effort to measure memory, CPU, and network usage during execution + let monitor = match monitor_metrics::MonitorMetrics::new("stage_update".to_string()) { + Ok(monitor) => Some(monitor), + Err(e) => { + warn!("Failed to create metrics monitor: {e:?}"); + None + } + }; + engine::prepare(subsystems, &ctx)?; if let ServicingType::AbUpdate = ctx.servicing_type { @@ -266,6 +282,13 @@ fn stage_update( #[cfg(feature = "grpc-dangerous")] grpc::send_host_status_state(sender, state)?; + if let Some(mut monitor) = monitor { + // If the monitor was created successfully, stop it after execution + if let Err(e) = monitor.stop() { + warn!("Failed to stop metrics monitor: {e:?}"); + } + } + info!("Staging of update '{:?}' succeeded", ctx.servicing_type); Ok(()) @@ -282,7 +305,7 @@ pub(crate) fn finalize_update( #[cfg(feature = "grpc-dangerous")] sender: &mut Option< mpsc::UnboundedSender>, >, -) -> Result<(), TridentError> { +) -> Result { info!("Finalizing update"); if servicing_type != ServicingType::AbUpdate { @@ -302,6 +325,7 @@ pub(crate) fn finalize_update( image: None, // Not used in finalize_update storage_graph: engine::build_storage_graph(&state.host_status().spec.storage)?, // Build storage graph filesystems: Vec::new(), // Left empty since context does not have image + is_uki: None, }; let esp_path = if container::is_running_in_container() @@ -345,12 +369,12 @@ pub(crate) fn finalize_update( .internal_params .get_flag(NO_TRANSITION) { - engine::reboot() + Ok(ExitKind::NeedsReboot) } else { warn!( "Skipping reboot as requested by internal parameter '{}'", NO_TRANSITION ); - Ok(()) + Ok(ExitKind::Done) } } diff --git a/src/lib.rs b/src/lib.rs index 5e53813f8..20949e7e2 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -32,6 +32,7 @@ mod datastore; mod engine; mod harpoon_hc; mod logging; +mod monitor_metrics; pub mod offline_init; mod orchestrate; pub mod osimage; @@ -45,13 +46,15 @@ use engine::{rollback, storage::rebuild}; use harpoon_hc::HostConfigUpdate; pub use datastore::DataStore; -pub use engine::provisioning_network; +pub use engine::{provisioning_network, reboot}; pub use logging::{ background_log::BackgroundLog, logstream::Logstream, multilog::MultiLogger, tracestream::TraceStream, }; pub use orchestrate::OrchestratorConnection; +use crate::osimage::OsImage; + /// Trident version as provided by environment variables at build time pub const TRIDENT_VERSION: &str = match option_env!("TRIDENT_VERSION") { Some(v) => v, @@ -81,6 +84,14 @@ const SAFETY_OVERRIDE_CHECK_PATH: &str = "/override-trident-safety-check"; /// Temporary location of the datastore for multiboot install scenarios. const TEMPORARY_DATASTORE_PATH: &str = "/tmp/trident-datastore.sqlite"; +#[must_use] +pub enum ExitKind { + /// Requested operation completed successfully. + Done, + /// Reboot is needed to complete the operation. + NeedsReboot, +} + pub struct Trident { host_config: Option, orchestrator: Option, @@ -173,6 +184,14 @@ impl Trident { info!("Running Trident in a container"); } + if let Ok(selinux_context) = fs::read_to_string("/proc/self/attr/current") { + debug!("selinux debug: Trident is running in SELinux domain '{selinux_context}'"); + } else { + error!( + "selinux debug: Failed to retrieve the SELinux context in which Trident is running" + ); + } + if !Uid::effective().is_root() { return Err(TridentError::new( ExecutionEnvironmentMisconfigurationError::CheckRootPrivileges, @@ -290,12 +309,14 @@ impl Trident { } => { info!("Server replied with new Host configuration v{version}, applying..."); self.host_config = Some(*host_config); - self.update( + if let ExitKind::NeedsReboot = self.update( datastore, Operations::all(), #[cfg(feature = "grpc-dangerous")] &mut None, - )?; + )? { + reboot().message("Failed to reboot after harpoon update")?; + } } HostConfigUpdate::NoUpdate => { warn!("No update available. No action will be taken."); @@ -313,20 +334,24 @@ impl Trident { if let Some((host_config, allowed_operations, sender)) = receiver.blocking_recv() { self.host_config = Some(host_config); - self.update(datastore, allowed_operations, &mut Some(sender))?; + if let ExitKind::NeedsReboot = + self.update(datastore, allowed_operations, &mut Some(sender))? + { + reboot().message("Failed to reboot after grpc update")?; + } } } Ok(()) } - fn execute_and_record_error( + fn execute_and_record_error( &mut self, datastore: &mut DataStore, f: F, - ) -> Result<(), TridentError> + ) -> Result where - F: FnOnce(&mut DataStore) -> Result<(), TridentError>, + F: FnOnce(&mut DataStore) -> Result, { datastore.with_host_status(|host_status| { if let Some(e) = host_status.last_error.take() { @@ -335,36 +360,40 @@ impl Trident { } })?; - if let Err(e) = f(datastore) { - // Record error in datastore. - let error = serde_yaml::to_value(&e).structured(InternalError::SerializeError)?; - if let Err(e2) = datastore.with_host_status(|status| status.last_error = Some(error)) { - error!("Failed to record error in datastore: {e2:?}"); - } + match f(datastore) { + Ok(t) => Ok(t), + Err(e) => { + // Record error in datastore. + let error = serde_yaml::to_value(&e).structured(InternalError::SerializeError)?; + if let Err(e2) = + datastore.with_host_status(|status| status.last_error = Some(error)) + { + error!("Failed to record error in datastore: {e2:?}"); + } - // Report error via phonehome. - if let Some(ref orchestrator) = self.orchestrator { - orchestrator.report_error( - format!("{e:?}"), - Some( - serde_yaml::to_string(&datastore.host_status()) - .unwrap_or("Failed to serialize Host Status".into()), - ), - ); - } + // Report error via phonehome. + if let Some(ref orchestrator) = self.orchestrator { + orchestrator.report_error( + format!("{e:?}"), + Some( + serde_yaml::to_string(&datastore.host_status()) + .unwrap_or("Failed to serialize Host Status".into()), + ), + ); + } - // Report error to Harpoon if enabled. - harpoon_hc::on_harpoon_enabled_event( - &datastore.host_status().spec, - harpoon::EventType::Install, - harpoon::EventResult::Error, - ); + // Report error to Harpoon if enabled. + harpoon_hc::on_harpoon_enabled_event( + &datastore.host_status().spec, + harpoon::EventType::Install, + harpoon::EventResult::Error, + ); - // TODO: report gPRC error + // TODO: report gPRC error - return Err(e); + Err(e) + } } - Ok(()) } /// Rebuilds RAID devices on replaced disks on the host @@ -407,6 +436,7 @@ impl Trident { image: None, storage_graph: engine::build_storage_graph(&host_config.storage)?, // Build storage graph filesystems: Vec::new(), // Left empty since context does not have image + is_uki: None, }; if ctx.ab_active_volume.is_none() { @@ -432,8 +462,8 @@ impl Trident { allowed_operations: Operations, multiboot: bool, #[cfg(feature = "grpc-dangerous")] sender: &mut Option, - ) -> Result<(), TridentError> { - let host_config = self + ) -> Result { + let mut host_config = self .host_config .clone() .structured(InternalError::Internal( @@ -483,6 +513,8 @@ impl Trident { } } + let image = OsImage::load(&mut host_config.image)?; + if datastore.host_status().spec != host_config { debug!("Host Configuration has been updated"); @@ -492,6 +524,7 @@ impl Trident { datastore, &allowed_operations, multiboot, + image, #[cfg(feature = "grpc-dangerous")] sender, ) @@ -502,7 +535,7 @@ impl Trident { 'stage'. Add 'stage' and re-run to stage the clean install" ); - Ok(()) + Ok(ExitKind::Done) } } else { debug!("Host Configuration has not been updated"); @@ -528,7 +561,7 @@ impl Trident { to finalize the clean install" ); - Ok(()) + Ok(ExitKind::Done) } } ServicingState::NotProvisioned => { @@ -539,6 +572,7 @@ impl Trident { datastore, &allowed_operations, multiboot, + image, #[cfg(feature = "grpc-dangerous")] sender, ) @@ -560,7 +594,7 @@ impl Trident { datastore: &mut DataStore, allowed_operations: Operations, #[cfg(feature = "grpc-dangerous")] sender: &mut Option, - ) -> Result<(), TridentError> { + ) -> Result { let mut host_config = self .host_config .clone() @@ -587,16 +621,18 @@ impl Trident { .map_err(Into::into) .message("Invalid Host Configuration provided")?; + let image = OsImage::load(&mut host_config.image)?; + // If HS.spec in the datastore is different from the new HC, need to both stage and // finalize the update, regardless of state if datastore.host_status().spec != host_config { debug!("Host Configuration has been updated"); // If allowed operations include 'stage', start update if allowed_operations.has_stage() { - engine::update(&host_config, datastore, &allowed_operations, #[cfg(feature = "grpc-dangerous")] sender).message("Failed to execute an update") + engine::update(&host_config, datastore, &allowed_operations, image, #[cfg(feature = "grpc-dangerous")] sender).message("Failed to execute an update") } else { warn!("Host Configuration has been updated but allowed operations do not include 'stage'. Add 'stage' and re-run to stage the update"); - Ok(()) + Ok(ExitKind::Done) } } else { debug!("Host Configuration has not been updated"); @@ -616,13 +652,13 @@ impl Trident { .message("Failed to finalize update") } else { warn!("There is an update staged on the host, but allowed operations do not include 'finalize'. Add 'finalize' and re-run to finalize the update"); - Ok(()) + Ok(ExitKind::Done) } } ServicingState::AbUpdateFinalized | ServicingState::Provisioned => { // Need to either re-execute the failed update OR inform the user that no update // is needed. - engine::update(&host_config, datastore, &allowed_operations,#[cfg(feature = "grpc-dangerous")] sender).message("Failed to update host") + engine::update(&host_config, datastore, &allowed_operations, image, #[cfg(feature = "grpc-dangerous")] sender).message("Failed to update host") } servicing_state => { Err(TridentError::new(InternalError::UnexpectedServicingState { diff --git a/src/main.rs b/src/main.rs index d7b9c4a04..8ea6f5aef 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,8 +6,8 @@ use log::{error, info, LevelFilter}; use trident::{ cli::{self, Cli, Commands, GetKind}, - offline_init, validation, BackgroundLog, DataStore, Logstream, MultiLogger, TraceStream, - Trident, TRIDENT_BACKGROUND_LOG_PATH, + offline_init, validation, BackgroundLog, DataStore, ExitKind, Logstream, MultiLogger, + TraceStream, Trident, TRIDENT_BACKGROUND_LOG_PATH, }; use trident_api::{ config::HostConfigurationSource, @@ -39,29 +39,30 @@ fn run_trident( mut logstream: Logstream, mut tracestream: TraceStream, args: &Cli, -) -> Result<(), TridentError> { +) -> Result { // Log version ASAP info!("Trident version: {}", trident::TRIDENT_VERSION); // Catch exit fast commands match &args.command { Commands::Validate { config } => { - return validation::validate_host_config_file(config); + return validation::validate_host_config_file(config).map(|()| ExitKind::Done); } #[cfg(feature = "pytest-generator")] Commands::Pytest => { pytest::generate_functional_test_manifest(); - return Ok(()); + return Ok(ExitKind::Done); } Commands::OfflineInitialize { hs_path } => { - return offline_init::execute(hs_path.as_deref()); + return offline_init::execute(hs_path.as_deref()).map(|()| ExitKind::Done); } Commands::Get { kind, outfile } => { return Trident::get(&load_agent_config()?.datastore, outfile, *kind) - .message("Failed to retrieve Host Status"); + .message("Failed to retrieve Host Status") + .map(|()| ExitKind::Done); } Commands::StartNetwork { config } => { @@ -70,7 +71,8 @@ fn run_trident( logstream.disable(); tracestream.disable(); - return Trident::start_network(HostConfigurationSource::File(config.clone())); + return Trident::start_network(HostConfigurationSource::File(config.clone())) + .map(|()| ExitKind::Done); } _ => (), @@ -139,9 +141,15 @@ fn run_trident( #[cfg(feature = "grpc-dangerous")] &mut None, ), - Commands::Commit { .. } => trident.commit(&mut datastore), - Commands::Listen { .. } => trident.listen(&mut datastore), - Commands::RebuildRaid { .. } => trident.rebuild_raid(&mut datastore), + Commands::Commit { .. } => { + trident.commit(&mut datastore).map(|()| ExitKind::Done) + } + Commands::Listen { .. } => { + trident.listen(&mut datastore).map(|()| ExitKind::Done) + } + Commands::RebuildRaid { .. } => trident + .rebuild_raid(&mut datastore) + .map(|()| ExitKind::Done), _ => Err(TridentError::internal("Invalid command")), }; @@ -167,12 +175,10 @@ fn run_trident( } } - res.message(format!("Failed to execute '{}' command", args.command))?; + res.message(format!("Failed to execute '{}' command", args.command)) } _ => unreachable!(), } - - Ok(()) }); match res { @@ -259,10 +265,18 @@ fn main() -> ExitCode { } // Invoke Trident - if let Err(e) = run_trident(logstream.unwrap(), tracestream.unwrap(), &args) { - error!("Trident failed: {e:?}"); - return ExitCode::from(2); + match run_trident(logstream.unwrap(), tracestream.unwrap(), &args) { + Ok(ExitKind::Done) => {} + Err(e) => { + error!("Trident failed: {e:?}"); + return ExitCode::from(2); + } + Ok(ExitKind::NeedsReboot) => { + if let Err(e) = trident::reboot() { + error!("Failed to reboot: {e:?}"); + return ExitCode::from(3); + } + } } - ExitCode::SUCCESS } diff --git a/src/monitor_metrics.rs b/src/monitor_metrics.rs new file mode 100644 index 000000000..8b41c8555 --- /dev/null +++ b/src/monitor_metrics.rs @@ -0,0 +1,630 @@ +use anyhow::{Error, Result}; +use log::{error, trace}; +use procfs::{net::DeviceStatus, process::Process, ticks_per_second}; +use std::{ + collections::HashMap, + sync::{ + atomic::{AtomicBool, AtomicU64}, + Arc, + }, + thread, + time::Duration, +}; + +// This constant defines the interval at which the monitoring thread will check for updates. +const MONITORING_INTERVAL_MS: u64 = 100; // in milliseconds + +/// `MonitorMetrics` is a struct with shareable fields providing methods to monitor and log CPU, memory, and network metrics. +pub struct MonitorMetrics { + metric_count: Arc, + stop: Arc, + join_handle: Option>, +} + +/// `CpuStat` is a struct that tracks CPU usage metrics. +/// It stores the phase, start CPU ticks, last CPU ticks, and ticks per second. +/// It provides methods to update CPU ticks and calculate CPU time. +/// It also provides a method to log the CPU time summary. +#[derive(Debug, Clone, Default)] +struct CpuStat { + phase: String, + start_cpu_ticks: f64, + last_cpu_ticks: f64, + ticks_per_second: f64, +} +impl CpuStat { + /// Creates a new `CpuStat` instance with the given phase, start CPU ticks, and ticks per second. + fn new(phase: String, start_cpu_ticks: f64, ticks_per_second: f64) -> Self { + Self { + phase, + start_cpu_ticks, + last_cpu_ticks: start_cpu_ticks, + ticks_per_second, + } + } + /// Updates the last CPU ticks with the given value. + fn update(&mut self, cpu_ticks: f64) { + self.last_cpu_ticks = cpu_ticks; + } + /// Calculates the CPU time based on the start and last CPU ticks. + fn get_cpu_time(&self) -> f64 { + (self.last_cpu_ticks - self.start_cpu_ticks) / self.ticks_per_second + } + /// Logs the CPU time summary. + fn summary_trace(&mut self) { + let total_cpu_time = self.get_cpu_time(); + tracing::info!( + metric_name = "total_cpu_time", + phase = &self.phase, + total_cpu_time = total_cpu_time, + ); + trace!("Total cpu time for {}: {}", &self.phase, total_cpu_time); + } +} + +/// `MemoryStat` is a struct that tracks memory usage metrics. +#[derive(Debug, Clone, Default)] +struct MemoryStat { + phase: String, + total_rss: u64, + peak_rss: u64, + number_measurements: u64, +} +impl MemoryStat { + /// Creates a new `MemoryStat` instance with the given phase. + fn new(phase: String) -> Self { + Self { + phase, + total_rss: 0, + peak_rss: 0, + number_measurements: 0, + } + } + /// Updates the memory usage metrics with the given RSS value. + fn update(&mut self, rss: u64) { + self.total_rss += rss; + if rss > self.peak_rss { + self.peak_rss = rss; + } + self.number_measurements += 1; + } + /// Calculates the average memory usage. + fn get_average_memory_usage(&self) -> f64 { + if self.number_measurements == 0 { + return 0.0; + } + self.total_rss as f64 / self.number_measurements as f64 + } + /// Returns the peak memory usage. + fn get_peak_memory_usage(&self) -> u64 { + self.peak_rss + } + /// Logs the memory usage summary. + /// It logs the average and peak memory usage. + fn summary_trace(&mut self) { + let average_memory_usage = self.get_average_memory_usage(); + let peak_memory_usage = self.get_peak_memory_usage(); + tracing::info!( + metric_name = "average_memory_usage", + phase = &self.phase, + average_memory_usage = average_memory_usage, + ); + trace!( + "Average memory usage for {}: {}", + &self.phase, + average_memory_usage + ); + tracing::info!( + metric_name = "peak_memory_usage", + phase = &self.phase, + peak_memory_usage = peak_memory_usage, + ); + trace!( + "Peak memory usage for {}: {}", + &self.phase, + peak_memory_usage + ); + } +} + +/// `NetworkStat` is a struct that tracks network usage metrics. +#[derive(Debug, Clone, Default)] +struct NetworkStat { + phase: String, + iface_start_bytes: HashMap, + iface_bytes: HashMap, +} +impl NetworkStat { + /// Creates a new `NetworkStat` instance with the given phase and initial network statistics. + fn new(phase: String, init_stats: HashMap) -> Self { + let mut iface_start_bytes = HashMap::new(); + for stat in init_stats.values() { + iface_start_bytes.insert(stat.name.clone(), (stat.recv_bytes, stat.sent_bytes)); + } + + Self { + phase: phase.clone(), + iface_start_bytes, + iface_bytes: HashMap::new(), + } + } + /// Updates the network usage metrics with the given interface name and received/sent bytes. + /// It calculates the difference from the initial values and stores it in `iface_bytes`. + /// If the interface name is not found in `iface_start_bytes`, it simply stores the received/sent bytes. + fn update(&mut self, name: String, recv_bytes: u64, sent_bytes: u64) { + let mut trace_measurement = (recv_bytes, sent_bytes); + if let Some(start_bytes) = self.iface_start_bytes.get(&name) { + trace_measurement = (recv_bytes - start_bytes.0, sent_bytes - start_bytes.1); + } + self.iface_bytes.insert(name.clone(), trace_measurement); + } + /// Logs the network usage summary for each interface. + fn summary_trace(&mut self) { + for (name, trace_measurement) in &self.iface_bytes { + tracing::info!( + metric_name = "total_network_usage", + phase = &self.phase, + iface_name = &name, + rx_bytes = trace_measurement.0, + tx_bytes = trace_measurement.1, + ); + trace!( + "Total network usage for {}: iface: {}, rx_bytes: {}, tx_bytes: {}", + &self.phase, + &name, + trace_measurement.0, + trace_measurement.1, + ); + } + } +} + +impl MonitorMetrics { + /// Creates a new `MonitorMetrics` instance that starts monitoring process and network metrics. + /// The `phase` parameter is used to tag the logs with the current phase of the application. + pub fn new(phase: String) -> Result { + let mut monitor = MonitorMetrics { + stop: Arc::new(AtomicBool::new(false)), + metric_count: Arc::new(AtomicU64::new(0)), + join_handle: None, + }; + monitor.start_monitoring(phase.clone())?; + Ok(monitor) + } + + /// Stops the monitoring thread by setting stop. + /// This method is called when the `MonitorMetrics` instance is dropped. + /// It ensures that the thread is stopped gracefully. + pub fn stop(&mut self) -> Result<(), Error> { + self.stop.store(true, std::sync::atomic::Ordering::SeqCst); + Ok(()) + } + + /// Joins the monitoring thread. + #[allow(dead_code)] + pub fn join(&mut self) -> thread::Result<()> { + if let Some(h) = self.join_handle.take() { + h.join()?; + } else { + error!("No monitoring thread to join"); + } + Ok(()) + } + + /// Creates and starts the monitoring thread and returns the thread handle. + /// The thread will run in a loop, updating the metrics at regular intervals. + /// It will stop when stop is set. + fn start_monitoring(&mut self, phase: String) -> Result<(), Error> { + let polling_interval = Duration::from_millis(MONITORING_INTERVAL_MS); + + let local_stop = self.stop.clone(); + let local_metric_count = self.metric_count.clone(); + + let process = Process::myself()?; + let init_process_stats = process.stat()?; + let init_net_stats = procfs::net::dev_status()?; + + let mut cpu_stat = CpuStat::new( + phase.clone(), + (init_process_stats.utime + init_process_stats.stime) as f64, + ticks_per_second() as f64, + ); + let mut memory_stat = MemoryStat::new(phase.clone()); + let mut network_stat = NetworkStat::new(phase.clone(), init_net_stats); + + let join_handle = thread::spawn(move || { + loop { + // Update CPU and memory statistics + if let Ok(process) = Process::myself() { + if let Ok(stat) = process.stat() { + cpu_stat.update((stat.utime + stat.stime) as f64); + memory_stat.update(stat.rss); + } + } + // Update network statistics + if let Ok(dev_stats) = procfs::net::dev_status() { + let stats: Vec<_> = dev_stats.values().collect(); + for stat in stats { + network_stat.update(stat.name.clone(), stat.recv_bytes, stat.sent_bytes); + } + } + + local_metric_count.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + + if local_stop.load(std::sync::atomic::Ordering::SeqCst) { + break; + } + + // Sleep for the polling interval + thread::sleep(polling_interval); + } + // Perform summary trace for CPU, memory, and network metrics + // after the monitoring thread is stopped. + cpu_stat.summary_trace(); + memory_stat.summary_trace(); + network_stat.summary_trace(); + }); + + self.join_handle = Some(join_handle); + Ok(()) + } +} + +impl Drop for MonitorMetrics { + fn drop(&mut self) { + if let Err(e) = self.stop() { + trace!("Failed to stop monitoring threads: {:?}", e); + } + } +} + +#[cfg(test)] +mod stat_tests { + use super::*; + use std::{ + collections::HashMap, + sync::{Arc, Mutex}, + }; + use tracing_subscriber::{layer::SubscriberExt, Registry}; + + #[derive(Debug, Clone, Default)] + struct TestTraceWriter { + logs: Arc>>, + } + + impl std::io::Write for TestTraceWriter { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + let s = String::from_utf8_lossy(buf).to_string(); + self.logs.lock().unwrap().push(s); + Ok(buf.len()) + } + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } + + fn init_monitor_metrics_tracing_validation_for_thread( + ) -> (Arc>>, tracing::subscriber::DefaultGuard) { + let logs = Arc::new(Mutex::new(Vec::new())); + let writer = TestTraceWriter { logs: logs.clone() }; + let guard = tracing::subscriber::set_default( + Registry::default().with( + tracing_subscriber::fmt::layer() + .with_writer(move || writer.clone()) + .with_ansi(false) + .with_target(false) + .with_level(true), + ), + ); + (logs, guard) + } + + #[test] + fn test_cpu_stat_update() { + let phase = "test_phase".to_string(); + let start_cpu_ticks = 40.0; + let ticks_per_second = 70.0; + + let mut cpu_stat = CpuStat::new(phase, start_cpu_ticks, ticks_per_second); + assert_eq!(cpu_stat.last_cpu_ticks, start_cpu_ticks); + let first_update = 200.0; + cpu_stat.update(first_update); + assert_eq!(cpu_stat.last_cpu_ticks, first_update); + + let last_update = 600.0; + cpu_stat.update(last_update); + assert_eq!(cpu_stat.last_cpu_ticks, last_update); + + assert_eq!(cpu_stat.start_cpu_ticks, start_cpu_ticks); + assert_eq!(cpu_stat.last_cpu_ticks, last_update); + assert_eq!(cpu_stat.ticks_per_second, ticks_per_second); + + assert_eq!( + cpu_stat.get_cpu_time(), + (last_update - start_cpu_ticks) / ticks_per_second + ); + + let (trace_logs, _guard) = init_monitor_metrics_tracing_validation_for_thread(); + cpu_stat.summary_trace(); + + let logs = trace_logs.lock().unwrap().join(""); + println!("Trace logs: {}", logs); + let expected_log = format!( + "total_cpu_time={}", + (last_update - start_cpu_ticks) / ticks_per_second + ); + assert!(logs.contains(&expected_log)); + } + + #[test] + fn test_memory_stat_initialization() { + let phase = "test_phase".to_string(); + + let memory_stat = MemoryStat::new(phase.clone()); + + assert_eq!(memory_stat.phase, phase); + assert_eq!(memory_stat.total_rss, 0); + assert_eq!(memory_stat.peak_rss, 0); + assert_eq!(memory_stat.number_measurements, 0); + } + + #[test] + fn test_memory_stat_update() { + let phase = "test_phase".to_string(); + let mut memory_stat = MemoryStat::new(phase); + + let first_rss = 2048; + memory_stat.update(first_rss); + assert_eq!(memory_stat.total_rss, first_rss); + assert_eq!(memory_stat.peak_rss, first_rss); + assert_eq!(memory_stat.number_measurements, 1); + + let second_rss = 1024; + memory_stat.update(second_rss); + assert_eq!(memory_stat.total_rss, first_rss + second_rss); + assert_eq!(memory_stat.peak_rss, first_rss); + assert_eq!(memory_stat.number_measurements, 2); + + assert_eq!( + memory_stat.get_average_memory_usage(), + (first_rss + second_rss) as f64 / 2.0 + ); + assert_eq!(memory_stat.get_peak_memory_usage(), first_rss); + + let (trace_logs, _guard) = init_monitor_metrics_tracing_validation_for_thread(); + memory_stat.summary_trace(); + + let logs = trace_logs.lock().unwrap().join(""); + println!("Trace logs: {}", logs); + let expected_log = format!( + "average_memory_usage={}", + (first_rss + second_rss) as f64 / 2.0 + ); + assert!(logs.contains(&expected_log)); + let expected_log = format!("peak_memory_usage={}", first_rss); + assert!(logs.contains(&expected_log)); + } + + fn create_mock_device_status(name: &str, recv_bytes: u64, sent_bytes: u64) -> DeviceStatus { + DeviceStatus { + name: name.to_string(), + recv_bytes, + sent_bytes, + recv_packets: 0, + recv_errs: 0, + recv_drop: 0, + recv_fifo: 0, + recv_frame: 0, + recv_compressed: 0, + recv_multicast: 0, + sent_packets: 0, + sent_errs: 0, + sent_drop: 0, + sent_fifo: 0, + sent_colls: 0, + sent_carrier: 0, + sent_compressed: 0, + } + } + + #[test] + fn test_network_stat_initialization() { + let phase = "test_phase".to_string(); + let mut init_stats = HashMap::new(); + let mock_device_status = create_mock_device_status("eth0", 1000, 2000); + init_stats.insert("eth0".to_string(), mock_device_status); + + let network_stat = NetworkStat::new(phase.clone(), init_stats); + + assert_eq!(network_stat.phase, phase); + assert!(network_stat.iface_start_bytes.contains_key("eth0")); + assert_eq!(network_stat.iface_start_bytes["eth0"], (1000, 2000)); + } + + #[test] + fn test_network_stat_update() { + let phase = "test_phase".to_string(); + let mut init_stats = HashMap::new(); + let mock_device_status = create_mock_device_status("eth0", 1000, 2000); + init_stats.insert("eth0".to_string(), mock_device_status); + + let mut network_stat = NetworkStat::new(phase, init_stats); + network_stat.update("eth0".to_string(), 3000, 4000); + assert!(network_stat.iface_bytes.contains_key("eth0")); + assert_eq!(network_stat.iface_bytes["eth0"], (2000, 2000)); + network_stat.update("eth1".to_string(), 5000, 6000); + assert!(network_stat.iface_bytes.contains_key("eth1")); + assert_eq!(network_stat.iface_bytes["eth1"], (5000, 6000)); + assert!(network_stat.iface_bytes.contains_key("eth0")); + assert_eq!(network_stat.iface_bytes["eth0"], (2000, 2000)); + + let (trace_logs, _guard) = init_monitor_metrics_tracing_validation_for_thread(); + network_stat.summary_trace(); + + let logs = trace_logs.lock().unwrap().join(""); + println!("Trace logs: {}", logs); + let expected_log = "iface_name=\"eth0\" rx_bytes=2000 tx_bytes=2000".to_string(); + assert!(logs.contains(&expected_log)); + let expected_log = "iface_name=\"eth1\" rx_bytes=5000 tx_bytes=6000".to_string(); + assert!(logs.contains(&expected_log)); + } + + #[test] + fn test_stop() { + let monitor = MonitorMetrics { + stop: Arc::new(AtomicBool::new(false)), + metric_count: Arc::new(AtomicU64::new(0)), + join_handle: None, + }; + assert!(!monitor.stop.load(std::sync::atomic::Ordering::SeqCst)); + monitor + .stop + .store(true, std::sync::atomic::Ordering::SeqCst); + assert!(monitor.stop.load(std::sync::atomic::Ordering::SeqCst)); + assert_eq!( + monitor + .metric_count + .load(std::sync::atomic::Ordering::SeqCst), + 0 + ); + } + + #[test] + fn test_join() { + let mut monitor = MonitorMetrics { + stop: Arc::new(AtomicBool::new(false)), + metric_count: Arc::new(AtomicU64::new(0)), + join_handle: None, + }; + assert!(monitor.join_handle.is_none()); + // Validate that join without handle returns Ok + assert!(monitor.join().is_ok()); + + // Create thread and validate that join works + let started = Arc::new(AtomicBool::new(false)); + let ended = Arc::new(AtomicBool::new(false)); + let thread_started = started.clone(); + let thread_ended = ended.clone(); + monitor.join_handle = Some(thread::spawn(move || { + thread_started.store(true, std::sync::atomic::Ordering::SeqCst); + // Simulate some work + thread::sleep(Duration::from_millis(100)); + thread_ended.store(true, std::sync::atomic::Ordering::SeqCst); + })); + // Validate that join with handle returns Ok + assert!(monitor.join().is_ok()); + assert!(started.load(std::sync::atomic::Ordering::SeqCst)); + assert!(ended.load(std::sync::atomic::Ordering::SeqCst)); + assert!(monitor.join_handle.is_none()); + // Validate that join without handle returns Ok + assert!(monitor.join().is_ok()); + } + + #[test] + fn test_start_monitoring() { + let mut monitor = MonitorMetrics { + // Initialize monitor with stop=true, forcing thead to + // collect 1 set of metrics and exit + stop: Arc::new(AtomicBool::new(true)), + metric_count: Arc::new(AtomicU64::new(0)), + join_handle: None, + }; + + monitor.start_monitoring("test-phase".to_string()).unwrap(); + assert!(monitor.join_handle.is_some()); + + // Validate that join with handle returns Ok + assert!(monitor.join().is_ok()); + assert!(monitor.join_handle.is_none()); + // Validate that join without handle returns Ok + assert!(monitor.join().is_ok()); + assert_eq!( + monitor + .metric_count + .load(std::sync::atomic::Ordering::SeqCst), + 1 + ); + } +} + +#[cfg(feature = "functional-test")] +#[cfg_attr(not(test), allow(unused_imports, dead_code))] +mod functional_test { + use super::*; + + use pytest_gen::functional_test; + use std::sync::{Arc, Mutex}; + use tracing_subscriber::{layer::SubscriberExt, Registry}; + + #[derive(Debug, Clone, Default)] + struct TestTraceWriter { + logs: Arc>>, + } + + impl std::io::Write for TestTraceWriter { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + let s = String::from_utf8_lossy(buf).to_string(); + self.logs.lock().unwrap().push(s); + Ok(buf.len()) + } + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } + + fn init_monitor_metrics_tracing_validation_global() -> Arc>> { + let logs = Arc::new(Mutex::new(Vec::new())); + let writer = TestTraceWriter { logs: logs.clone() }; + assert!(tracing::subscriber::set_global_default( + Registry::default().with( + tracing_subscriber::fmt::layer() + .with_writer(move || writer.clone()) + .with_ansi(false) + .with_target(false) + .with_level(true), + ), + ) + .is_ok()); + logs + } + + #[functional_test] + fn test_monitor_metrics() { + let trace_logs = init_monitor_metrics_tracing_validation_global(); + + let mut test_metrics = MonitorMetrics::new("test_phase".to_string()).unwrap(); + + // Wait for a while to allow the thread to run and collect metrics + let sleep_ms = 1000; + thread::sleep(Duration::from_millis(sleep_ms)); + + // Tell monitor loop to stop + test_metrics.stop().unwrap(); + + // Join the thread to wait monitor loop to end + test_metrics.join().unwrap(); + + // Loop is stopped after X ms, each iteration waits Y ms, check + // that metric count is roughly (maybe within 20%) between 0 and + // (X / Y) + assert_ne!( + test_metrics + .metric_count + .load(std::sync::atomic::Ordering::SeqCst), + 0 + ); + assert!( + test_metrics + .metric_count + .load(std::sync::atomic::Ordering::SeqCst) + <= (1.2 * sleep_ms as f64 / MONITORING_INTERVAL_MS as f64) as u64 + ); + + // Validate that tracing output contains expected metrics + let logs = trace_logs.lock().unwrap().join(""); + assert!(logs.contains("total_cpu_time")); + assert!(logs.contains("average_memory_usage")); + assert!(logs.contains("peak_memory_usage")); + assert!(logs.contains("total_network_usage")); + } +} diff --git a/src/offline_init/aksee_prism_history.json b/src/offline_init/aksee_prism_history.json new file mode 100644 index 000000000..adb962f4e --- /dev/null +++ b/src/offline_init/aksee_prism_history.json @@ -0,0 +1,408 @@ +[ + { + "timestamp": "2025-05-08T01:00:36Z", + "toolVersion": "0.13.0", + "imageUuid": "7671f584-b262-15ce-9fb0-14519e97dceb", + "config": { + "input": { + "image": {} + }, + "storage": { + "bootType": "efi", + "disks": [ + { + "partitionTableType": "gpt", + "maxSize": "17791M", + "partitions": [ + { + "id": "esp", + "label": "esp", + "start": "1M", + "size": "8M", + "type": "esp" + }, + { + "id": "boot-a", + "label": "boot-a", + "start": "9M", + "size": "192M" + }, + { + "id": "root-a", + "label": "root-a", + "start": "201M", + "size": "4G" + }, + { + "id": "boot-b", + "label": "boot-b", + "start": "4297M", + "size": "192M" + }, + { + "id": "root-b", + "label": "root-b", + "start": "4489M", + "size": "4G" + }, + { + "id": "root-hash-a", + "label": "root-hash-a", + "start": "8585M", + "size": "256M" + }, + { + "id": "root-hash-b", + "label": "root-hash-b", + "start": "8841M", + "size": "256M" + }, + { + "id": "trident", + "label": "trident", + "start": "9097M", + "size": "256M" + }, + { + "id": "trident-overlay-a", + "label": "trident-overlay-a", + "start": "9353M", + "size": "256M" + }, + { + "id": "trident-overlay-b", + "label": "trident-overlay-b", + "start": "9609M", + "size": "256M" + }, + { + "id": "log", + "label": "log", + "start": "9865M", + "size": "1G" + }, + { + "id": "data", + "label": "data", + "start": "16009M", + "end": "17790M", + "size": null + } + ] + } + ], + "filesystems": [ + { + "deviceId": "esp", + "type": "fat32", + "mountPoint": { + "idType": "uuid", + "options": "umask=0077", + "path": "/boot/efi" + } + }, + { + "deviceId": "boot-a", + "type": "ext4", + "mountPoint": { + "idType": "uuid", + "path": "/boot" + } + }, + { + "deviceId": "rootverity", + "type": "ext4", + "mountPoint": { + "options": "defaults,ro", + "path": "/" + } + }, + { + "deviceId": "boot-b", + "type": "ext4" + }, + { + "deviceId": "root-b", + "type": "ext4" + }, + { + "deviceId": "trident", + "type": "ext4", + "mountPoint": { + "idType": "part-label", + "path": "/var/lib/trident" + } + }, + { + "deviceId": "trident-overlay-a", + "type": "ext4", + "mountPoint": { + "idType": "part-label", + "path": "/var/lib/trident-overlay" + } + }, + { + "deviceId": "trident-overlay-b", + "type": "ext4" + }, + { + "deviceId": "log", + "type": "ext4", + "mountPoint": { + "idType": "part-label", + "path": "/var/log" + } + }, + { + "deviceId": "data", + "type": "ext4", + "mountPoint": { + "idType": "part-label", + "path": "/var" + } + } + ], + "verity": [ + { + "id": "rootverity", + "name": "root", + "dataDeviceId": "root-a", + "dataDeviceMountIdType": "part-label", + "hashDeviceId": "root-hash-a", + "hashDeviceMountIdType": "part-label", + "corruptionOption": "ignore" + } + ] + }, + "os": { + "hostname": "iotedge-vm", + "packages": { + "install": [ + "aziot-identity-service", + "aziot-edge", + "babeltrace2", + "bash", + "bc", + "bzip2", + "ca-certificates", + "ca-certificates-base", + "chrony", + "cloud-init", + "cloud-utils-growpart", + "conntrack-tools", + "containerd", + "cpio", + "cracklib-dicts", + "cryptsetup", + "curl", + "device-mapper", + "defender-iot-micro-agent-edge-os-config", + "dbus", + "dosfstools", + "dracut", + "dracut-overlayfs", + "dmidecode", + "dnf", + "e2fsprogs", + "efibootmgr", + "elfutils-libelf", + "expat", + "eflow-proxy", + "file", + "filesystem", + "findutils", + "fio", + "gdbm", + "grep", + "gzip", + "git", + "hyperv-daemons", + "iana-etc", + "icu", + "iproute", + "ipset", + "iptables", + "iputils", + "irqbalance", + "iscsi-initiator-utils", + "jq", + "keyutils", + "libevent", + "libnfsidmap", + "libnvidia-container1", + "libnvidia-container-tools", + "libtool", + "lvm2", + "lsof", + "nano", + "ncurses-libs", + "net-tools", + "netplan", + "nfs-utils", + "nspr", + "nss-libs", + "nvidia-container-runtime", + "nvidia-container-toolkit-base", + "nvidia-container-toolkit", + "opengcs", + "openssh", + "openssh-clients", + "openssl", + "procps-ng", + "pkgconf", + "rpcbind", + "readline", + "rpm", + "sed", + "selinux-policy", + "socat", + "sqlite-libs", + "sudo", + "systemd", + "systemd-udev", + "tar", + "tdnf", + "tdnf-plugin-repogpgcheck", + "tpm2-abrmd", + "tpm2-tss", + "tzdata", + "util-linux", + "vim", + "veritysetup", + "wget", + "which", + "xz", + "zlib", + "trident" + ] + }, + "selinux": { + "mode": "disabled" + }, + "kernelCommandLine": { + "extraCommandLine": [ + "rd.shell=0", + "log_buf_len=1M", + "ipv6.disable=1" + ] + }, + "additionalFiles": [ + { + "destination": "etc/yum.repos.d/cloud-native.repo", + "source": "../repos/cloud-native.repo", + "sha256hash": "75237565dc573bd00afbee902f2187cedab7e644a9753d18bc9d9c9d5d5e15f8" + }, + { + "destination": "/usr/lib/systemd/system/getty@.service", + "source": "configs/getty@.service", + "sha256hash": "e5432ef8100b84f2a396f7744501c5de5a2811cba9e29cd70c510704cab0eca5" + }, + { + "destination": "/usr/lib/systemd/system/serial-getty@.service", + "source": "configs/serial-getty@.service", + "sha256hash": "92ca782e9aec3fa95b05b513ad102f3319b3aa3bae0de11e0fb3cf3f97bcb896" + }, + { + "destination": "/etc/cloud/cloud.cfg", + "source": "configs/cloud-init.cfg", + "sha256hash": "5238cf3b0ccda3def748a58f3d6daadf457eb959637131ff146743a391a6728e" + }, + { + "destination": "/etc/ssh/sshd_config", + "source": "configs/sshd_config", + "permissions": 436, + "sha256hash": "3b5fa89136b71aed6010851cde85b071e0e02fbf97ddfe79b81322e2615194b7" + }, + { + "destination": "/lib/systemd/system/sshd_vsock.socket", + "source": "configs/sshd_vsock.socket", + "permissions": 436, + "sha256hash": "5ca9f0768a981aabde6c8bfeed5687f553e5040753829c4e4b79b3dfc36039df" + }, + { + "destination": "/lib/systemd/system/sshd_vsock@.service", + "source": "configs/sshd_vsock@.service", + "permissions": 436, + "sha256hash": "ade6b152e68138ab200734991b3d9ecfde2196465e9711fe71dcc783c9e0909a" + }, + { + "destination": "/etc/docker/daemon.json", + "source": "configs/daemon.json", + "sha256hash": "84439b0836c8df0bb7d44192d92275879f0fbde2d460437c14999b781b1e10c5" + }, + { + "destination": "/etc/aziot/tpmd/config.d/50-eflow.toml", + "source": "configs/50-eflow.toml", + "sha256hash": "00d53f167de6929408340da36dfdb79b6d3676f19afc67bdaf2dfdb62c8a15ef" + }, + { + "destination": "/etc/trident/install/host_status.yaml", + "source": "configs/trident/install/host_status.yaml", + "sha256hash": "d7c7aa8f0227480bff84583a626022a4fa6d7e5dee1d8488e83fc253734077df" + }, + { + "destination": "/etc/trident/config.yaml", + "source": "configs/trident/config.yaml", + "sha256hash": "daffb31ac8ccab85a7c33aeb6af6fb68e4b30ea461660b7edb6a107147248799" + }, + { + "destination": "/etc/trident/trident-init.sh", + "source": "configs/trident/trident-init.sh", + "permissions": 493, + "sha256hash": "110d43a9b48ab5f3c541a227d2fa0de353e1a6edf05b292c41529c09b7b24ddc" + }, + { + "destination": "/etc/systemd/system/var-etc-mount.service", + "source": "configs/services/var-etc-mount.service", + "sha256hash": "f28e55de5e15dc960363a1a2da404ce120516a75a9023a50ca8eb063c1518db1" + }, + { + "destination": "/usr/local/bin/var-etc-mount.sh", + "source": "configs/services/var-etc-mount.sh", + "permissions": 511, + "sha256hash": "69373ce99e86f37ceb7c38eb1ce85d751b9767f6b83d1df0569849e15609c2f3" + } + ], + "services": { + "enable": [ + "gcs", + "sshd_vsock.socket", + "var-etc-mount" + ] + }, + "modules": [ + { + "name": "hv_sock", + "loadMode": "always" + }, + { + "name": "ip_tables", + "loadMode": "always" + } + ], + "bootloader": { + "resetType": "hard-reset" + } + }, + "scripts": { + "postCustomization": [ + { + "path": "configure-image-gen.sh", + "sha256hash": "954d21e75d87ab238b1aa9c10c5e522eb5432da8d096dc095f99fa1c7a3cbc29" + }, + { + "path": "configure-image-post.sh", + "sha256hash": "5826585a2c8c45fba3f36853be21beaddb18e0a0ecec8d943a8509d1987e3f52" + }, + { + "path": "create-rw-overlay-dirs.sh", + "sha256hash": "84deef7817380aaa9cfca6c22f7ae307695aa3ed68efac7ccc96ee162567eb70" + } + ] + }, + "output": { + "image": {} + } + } + } +] \ No newline at end of file diff --git a/src/offline_init/mod.rs b/src/offline_init/mod.rs index 2d1806592..4e2b06033 100644 --- a/src/offline_init/mod.rs +++ b/src/offline_init/mod.rs @@ -35,7 +35,7 @@ struct PrismPartition { #[allow(unused)] start: String, #[allow(unused)] - size: String, + size: Option, #[serde(rename = "type")] ty: Option, @@ -75,7 +75,10 @@ struct PrismVerity { #[derive(Debug, serde::Deserialize)] #[serde(rename_all = "camelCase")] struct PrismStorage { + #[serde(default)] disks: Vec, + + #[serde(default)] filesystems: Vec, #[serde(default)] @@ -110,7 +113,7 @@ fn generate_host_status( .iter() .rev() .map(|entry| entry.config.storage.as_ref()) - .find(|storage| storage.is_some()) + .find(|storage| storage.is_some_and(|s| !s.disks.is_empty())) .flatten() else { return Err(TridentError::new(InvalidInputError::ParsePrismHistory)) @@ -134,12 +137,12 @@ fn generate_host_status( } else { PartitionType::LinuxGeneric }, - size: PartitionSize::from_str(&partition.size) - .structured(InvalidInputError::ParsePrismHistory) - .message(format!( - "Failed to parse partition size '{}'", - partition.size - ))?, + size: match &partition.size { + Some(s) => PartitionSize::from_str(s) + .structured(InvalidInputError::ParsePrismHistory) + .message(format!("Failed to parse partition size '{s}'"))?, + None => PartitionSize::Grow, + }, }); } @@ -408,6 +411,9 @@ mod tests { assert_eq!(disk.partitions.len(), 14); assert_eq!(disk.partitions[0].id, "esp"); assert_eq!(disk.partitions[1].id, "boot-a"); + + let _history2: Vec = + serde_json::from_str(include_str!("aksee_prism_history.json")).unwrap(); } #[test] diff --git a/src/osimage/cosi/metadata.rs b/src/osimage/cosi/metadata.rs index 94423f2d5..57fb9412a 100644 --- a/src/osimage/cosi/metadata.rs +++ b/src/osimage/cosi/metadata.rs @@ -7,7 +7,9 @@ use uuid::Uuid; use osutils::osrelease::OsRelease; use sysdefs::{ - arch::SystemArchitecture, osuuid::OsUuid, partition_types::DiscoverablePartitionType, + arch::{PackageArchitecture, SystemArchitecture}, + osuuid::OsUuid, + partition_types::DiscoverablePartitionType, }; use trident_api::primitives::hash::Sha384Hash; @@ -174,7 +176,7 @@ pub(crate) struct OsPackage { #[allow(dead_code)] #[serde(default)] - pub arch: Option, + pub arch: Option, } impl<'de> Deserialize<'de> for MetadataVersion { @@ -393,4 +395,44 @@ mod tests { ]; assert_eq!(metadata.get_regular_filesystems().count(), 0); } + + #[test] + fn test_noarch_os_packages() { + let noarch_os_package_json = r#" + { + "name": "package1", + "version": "1.0.0", + "arch": "noarch" + } + "#; + let _noarch_os_package: OsPackage = serde_json::from_str(noarch_os_package_json).unwrap(); + + let amd64_os_package_json = r#" + { + "name": "package1", + "version": "1.0.0", + "arch": "x86_64" + } + "#; + let _amd64_os_package: OsPackage = serde_json::from_str(amd64_os_package_json).unwrap(); + + let amd64_os_package_json = r#" + { + "name": "package1", + "version": "1.0.0", + "arch": "amd64" + } + "#; + let _amd64_os_package: OsPackage = serde_json::from_str(amd64_os_package_json).unwrap(); + + let none_os_package_json = r#" + { + "name": "gpg-pubkey", + "version": "3135ce90", + "release": "5e6fda74", + "arch": "(none)" + } + "#; + let _none_os_package: OsPackage = serde_json::from_str(none_os_package_json).unwrap(); + } } diff --git a/src/osimage/cosi/mod.rs b/src/osimage/cosi/mod.rs index fe5dc7b04..9e75e9671 100644 --- a/src/osimage/cosi/mod.rs +++ b/src/osimage/cosi/mod.rs @@ -8,7 +8,10 @@ use anyhow::{bail, ensure, Context, Error}; use log::{debug, trace}; use osutils::hashing_reader::{HashingReader, HashingReader384}; use tar::Archive; -use trident_api::config::{ImageSha384, OsImage}; +use trident_api::{ + config::{ImageSha384, OsImage}, + primitives::hash::Sha384Hash, +}; use url::Url; use sysdefs::arch::SystemArchitecture; @@ -34,6 +37,7 @@ pub(super) struct Cosi { source: Url, entries: HashMap, metadata: CosiMetadata, + metadata_sha384: Sha384Hash, reader: CosiReader, } @@ -57,13 +61,16 @@ impl Cosi { let entries = read_entries_from_tar_archive(cosi_reader.reader()?)?; trace!("Collected {} COSI entries", entries.len()); + let (metadata, sha384) = read_cosi_metadata(&cosi_reader, &entries, source.sha384.clone()) + .context("Failed to read COSI file metadata.")?; + // Create a new COSI instance. Ok(Cosi { - metadata: read_cosi_metadata(&cosi_reader, &entries, source.sha384.clone()) - .context("Failed to read COSI file metadata.")?, + metadata, entries, source: source.url.clone(), reader: cosi_reader, + metadata_sha384: sha384, }) } @@ -97,6 +104,10 @@ impl Cosi { pub(super) fn architecture(&self) -> SystemArchitecture { self.metadata.os_arch } + + pub(super) fn metadata_sha384(&self) -> Sha384Hash { + self.metadata_sha384.clone() + } } /// Converts a COSI metadata Image to an OsImageFileSystem. @@ -202,7 +213,7 @@ fn read_cosi_metadata( cosi_reader: &CosiReader, entries: &HashMap, expected_sha384: ImageSha384, -) -> Result { +) -> Result<(CosiMetadata, Sha384Hash), Error> { trace!( "Retrieving metadata from COSI file from '{}'", COSI_METADATA_PATH @@ -228,9 +239,10 @@ fn read_cosi_metadata( .read_to_string(&mut raw_metadata) .context("Failed to read COSI metadata")?; + let actual_sha384 = Sha384Hash::from(metadata_reader.hash()); if let ImageSha384::Checksum(ref sha384) = expected_sha384 { - if metadata_reader.hash() != sha384.as_str() { - bail!("COSI metadata hash does not match expected hash"); + if actual_sha384 != *sha384 { + bail!("COSI metadata hash '{actual_sha384}' does not match expected hash '{sha384}'"); } } trace!("Raw COSI metadata:\n{}", raw_metadata); @@ -257,7 +269,7 @@ fn read_cosi_metadata( metadata.version.major, metadata.version.minor ); - Ok(metadata) + Ok((metadata, actual_sha384)) } /// Validates the COSI metadata version. @@ -564,7 +576,8 @@ mod tests { &entries, ImageSha384::Checksum(metadata_sha384.into()), ) - .unwrap(); + .unwrap() + .0; // Now check that the images in the metadata have the correct entries. for (image, (path, offset, size)) in metadata.images.iter().zip(image_paths.iter()) { @@ -793,6 +806,7 @@ mod tests { images, }, reader: CosiReader::Mock(data), + metadata_sha384: Sha384Hash::from("0".repeat(96)), } } @@ -811,6 +825,7 @@ mod tests { os_packages: None, }, reader: CosiReader::Mock(Cursor::new(Vec::::new())), + metadata_sha384: Sha384Hash::from("0".repeat(96)), }; // Weird behavior with none/multiple ESPs is primarily tested by the diff --git a/src/osimage/cosi/reader.rs b/src/osimage/cosi/reader.rs index b604b5b7d..4eafd34c7 100644 --- a/src/osimage/cosi/reader.rs +++ b/src/osimage/cosi/reader.rs @@ -4,10 +4,12 @@ use std::{ fs::File, io::{Error as IoError, ErrorKind as IoErrorKind, Read, Result as IoResult, Seek, SeekFrom}, path::PathBuf, + thread, + time::{Duration, Instant}, }; use anyhow::{bail, ensure, Error}; -use log::{debug, trace}; +use log::{debug, trace, warn}; use reqwest::blocking::{Client, Response}; use url::Url; @@ -113,6 +115,7 @@ pub struct HttpFile { position: u64, size: u64, client: Client, + timeout_in_seconds: u64, } impl HttpFile { @@ -120,17 +123,12 @@ impl HttpFile { pub fn new(url: &Url) -> IoResult { debug!("Opening HTTP file '{}'", url); + let timeout_in_seconds = 5; + // Create a new client for this file. let client = Client::new(); - - // Query the server for the file size - let response = client - .head(url.as_str()) - .send() - .map_err(Self::http_to_io_err)? - .error_for_status() - .map_err(Self::http_to_io_err)?; - + let request_sender = || client.head(url.as_str()).send(); + let response = Self::retriable_request_sender(request_sender, timeout_in_seconds)?; trace!("HTTP file '{}' has status: {}", url, response.status()); // Get the file size from the response headers @@ -189,6 +187,7 @@ impl HttpFile { position: 0, size, client, + timeout_in_seconds, }) } @@ -218,26 +217,73 @@ impl HttpFile { /// Performs a request with optional range headers to get the file content. /// Returns the HTTP response. fn reader(&self, start: Option, end: Option) -> IoResult { - let mut request = self.client.get(self.url.as_str()); - - // Generate the range header when appropriate - let range_header = match (start, end) { - (Some(start), Some(end)) => Some(format!("bytes={}-{}", start, end)), - (Some(start), None) => Some(format!("bytes={}-", start)), - (None, Some(end)) => Some(format!("bytes=0-{}", end)), - (None, None) => None, + let request_sender = || { + let mut request = self.client.get(self.url.as_str()); + + // Generate the range header when appropriate + let range_header = match (start, end) { + (Some(start), Some(end)) => Some(format!("bytes={}-{}", start, end)), + (Some(start), None) => Some(format!("bytes={}-", start)), + (None, Some(end)) => Some(format!("bytes=0-{}", end)), + (None, None) => None, + }; + + // Add the range header to the request + if let Some(range) = range_header { + request = request.header("Range", range); + } + + request.send() }; - // Add the range header to the request - if let Some(range) = range_header { - request = request.header("Range", range); - } + Self::retriable_request_sender(request_sender, self.timeout_in_seconds) + } - request - .send() - .map_err(Self::http_to_io_err)? - .error_for_status() - .map_err(Self::http_to_io_err) + /// Performs an HTTP request and retries it for up to `timeout_in_seconds` if + /// it fails. The HTTP request is created and invoked by `request_sender`, a + /// closure that that returns a `reqwest::Result`. If the request is + /// successful, it returns the response. If the request fails after all retries, + /// it returns an IO error. + fn retriable_request_sender(request_sender: F, timeout_in_seconds: u64) -> IoResult + where + F: Fn() -> reqwest::Result, + { + let mut retry = 0; + let now = Instant::now(); + let timeout_time = now + Duration::from_secs(timeout_in_seconds); + let mut sleep_duration = Duration::from_millis(10); + loop { + if retry != 0 { + trace!("Retrying HTTP request (attempt {})", retry + 1); + } + match request_sender() { + Ok(response) => { + if response.status().is_success() { + return Ok(response); + } else if std::time::Instant::now() > timeout_time { + return response.error_for_status().map_err(Self::http_to_io_err); + } else { + warn!("HTTP request failed with status: {}", response.status()); + } + } + Err(e) => { + if Instant::now() > timeout_time { + return Err(Self::http_to_io_err(e)); + } + warn!("HTTP request failed: {}", e); + } + }; + // Sleep for a short duration before retrying + if Instant::now() + sleep_duration > timeout_time { + return Err(IoError::new( + IoErrorKind::TimedOut, + "HTTP request timed out", + )); + } + thread::sleep(sleep_duration); + sleep_duration *= 2; + retry += 1; + } } /// Performs a request of a specific section of the file. Returns the HTTP @@ -345,7 +391,62 @@ mod tests { use super::*; - use std::io::{SeekFrom, Write}; + use std::{ + io::{SeekFrom, Write}, + sync::{ + atomic::{AtomicU16, Ordering}, + Arc, + }, + }; + + #[test] + fn test_retriable_request_sender_retry_count() { + let tries = Arc::new(AtomicU16::new(0)); + let closure_tries = tries.clone(); + let request_sender = || { + closure_tries.fetch_add(1, Ordering::SeqCst); + let client = Client::new(); + client.get("").send() + }; + HttpFile::retriable_request_sender(request_sender, 2).unwrap_err(); + assert!(tries.load(Ordering::SeqCst) > 1); + } + + #[test] + fn test_retriable_request_sender_initial_failure() { + let relative_file_path = "/test.yaml"; + let mut server = mockito::Server::new(); + let data = "test document"; + let document_mock = server + .mock("GET", relative_file_path) + .with_body(data) + .with_header("content-length", &data.len().to_string()) + .with_header("content-type", "text/plain") + .with_status(200) + .expect(1) + .create(); + let url = Url::parse(&server.url()).unwrap(); + let request_url = url.join(relative_file_path).unwrap().to_string(); + + let tries = Arc::new(AtomicU16::new(0)); + let closure_tries = tries.clone(); + let request_sender = || { + closure_tries.fetch_add(1, Ordering::SeqCst); + if closure_tries.load(Ordering::SeqCst) < 2 { + let client = Client::new(); + return client.get("").send(); + } + let client = Client::new(); + client.get(&request_url).send() + }; + let document = HttpFile::retriable_request_sender(request_sender, 5) + .unwrap() + .text() + .unwrap(); + assert!(tries.load(Ordering::SeqCst) > 1); + assert_eq!(document, data); + document_mock.assert(); + } #[test] fn test_http_file_seek() { @@ -354,6 +455,7 @@ mod tests { position: 0, size: 100, // We have indices from 0 to 99 client: Client::new(), + timeout_in_seconds: 1, }; assert_eq!(http_file.seek(SeekFrom::Start(50)).unwrap(), 50); @@ -515,15 +617,17 @@ mod tests { .unwrap() .split('-') .collect::>(); - let start = ranges[0] - .is_empty() - .then_some(0) - .unwrap_or_else(|| ranges[0].parse::().expect("Failed to parse start")); - let end = ranges[1] - .is_empty() - .then_some(body.len()) - .unwrap_or_else(|| ranges[1].parse::().expect("Failed to parse end")) - .min(body.len() - 1); + let start = if ranges[0].is_empty() { + 0 + } else { + ranges[0].parse::().expect("Failed to parse start") + }; + let end = if ranges[1].is_empty() { + body.len() + } else { + ranges[1].parse::().expect("Failed to parse end") + } + .min(body.len() - 1); trace!("Mocking range {} to {}", start, end); body.as_bytes()[start..=end].to_vec() }) diff --git a/src/osimage/mock.rs b/src/osimage/mock.rs index f44c1259e..16bbe6339 100644 --- a/src/osimage/mock.rs +++ b/src/osimage/mock.rs @@ -144,6 +144,10 @@ impl MockOsImage { pub fn architecture(&self) -> SystemArchitecture { self.os_arch } + + pub fn metadata_sha384(&self) -> Sha384Hash { + Sha384Hash::from("0".repeat(96)) + } } impl MockImage { diff --git a/src/osimage/mod.rs b/src/osimage/mod.rs index a078d8596..a3be7fc8b 100644 --- a/src/osimage/mod.rs +++ b/src/osimage/mod.rs @@ -1,10 +1,11 @@ use std::{ - fmt::{Display, Formatter}, + fmt::{Display, Formatter, Write}, io::{Error as IoError, Read}, path::{Path, PathBuf}, }; use anyhow::Error; +use log::{debug, info}; use serde::{Deserialize, Serialize}; use url::Url; @@ -12,7 +13,12 @@ use sysdefs::{ arch::SystemArchitecture, filesystems::RealFilesystemType, osuuid::OsUuid, partition_types::DiscoverablePartitionType, }; -use trident_api::{config, constants::ROOT_MOUNT_POINT_PATH, primitives::hash::Sha384Hash}; +use trident_api::{ + config::{self, ImageSha384}, + constants::ROOT_MOUNT_POINT_PATH, + error::{InvalidInputError, ReportError, TridentError}, + primitives::hash::Sha384Hash, +}; mod cosi; @@ -34,6 +40,7 @@ use mock::MockOsImage; #[derive(Debug, Clone)] pub struct OsImage(OsImageInner); +#[cfg_attr(test, allow(clippy::large_enum_variant))] #[derive(Debug, Clone)] enum OsImageInner { /// Composable OS Image (COSI) @@ -54,6 +61,50 @@ impl OsImage { Self(OsImageInner::Mock(Box::new(mock_os_image))) } + /// Load the OS given the image source from the Host Configuration and either validate or + /// populate the associated metadata sha384 checksum. + pub(crate) fn load(image_source: &mut Option) -> Result { + let Some(ref mut image_source) = image_source else { + return Err(TridentError::new(InvalidInputError::MissingOsImage)); + }; + + debug!("Loading COSI file '{}'", image_source.url); + let os_image = OsImage::cosi(image_source).structured(InvalidInputError::LoadCosi { + url: image_source.url.clone(), + })?; + if image_source.sha384 == ImageSha384::Ignored { + image_source.sha384 = ImageSha384::Checksum(os_image.metadata_sha384()); + } + + info!( + "Successfully loaded OS image of type '{}' from '{}'", + os_image.name(), + os_image.source() + ); + + // Ensure the OS image architecture matches the current system architecture + if SystemArchitecture::current() != os_image.architecture() { + return Err(TridentError::new( + InvalidInputError::MismatchedArchitecture { + expected: SystemArchitecture::current().into(), + actual: os_image.architecture().into(), + }, + )); + } + + debug!( + "OS image provides the following mount points:\n{}", + os_image + .available_mount_points() + .fold(String::new(), |mut acc, p| { + let _ = writeln!(acc, " - {}", p.display()); + acc + }) + ); + + Ok(os_image) + } + /// Returns the name of the OS image type. pub(crate) fn name(&self) -> &'static str { match &self.0 { @@ -121,6 +172,14 @@ impl OsImage { self.filesystems() .find(|fs| fs.mount_point == Path::new(ROOT_MOUNT_POINT_PATH)) } + + pub(crate) fn metadata_sha384(&self) -> Sha384Hash { + match &self.0 { + OsImageInner::Cosi(cosi) => cosi.metadata_sha384(), + #[cfg(test)] + OsImageInner::Mock(mock) => mock.metadata_sha384(), + } + } } #[derive(Debug)] diff --git a/src/engine/boot/esp.rs b/src/subsystems/esp.rs similarity index 66% rename from src/engine/boot/esp.rs rename to src/subsystems/esp.rs index 4a6f19910..a2d0d4bb6 100644 --- a/src/engine/boot/esp.rs +++ b/src/subsystems/esp.rs @@ -15,24 +15,20 @@ use osutils::{ hashing_reader::{HashingReader, HashingReader384}, image_streamer, mount::{self, MountGuard}, - path::join_relative, + path, }; use trident_api::{ constants::{ - internal_params::{DISABLE_GRUB_NOPREFIX_CHECK, ENABLE_UKI_SUPPORT}, - ESP_MOUNT_POINT_PATH, + internal_params::DISABLE_GRUB_NOPREFIX_CHECK, EFI_DEFAULT_BIN_RELATIVE_PATH, + ESP_EFI_DIRECTORY, ESP_RELATIVE_MOUNT_POINT_PATH, GRUB2_CONFIG_FILENAME, + GRUB2_CONFIG_RELATIVE_PATH, }, - error::{ReportError, TridentError, TridentResultExt, UnsupportedConfigurationError}, - status::AbVolumeSelection, + error::{ReportError, ServicingError, TridentError, TridentResultExt}, }; use crate::engine::{ - boot::ESP_EXTRACTION_DIRECTORY, - constants::{ - EFI_DEFAULT_BIN_RELATIVE_PATH, ESP_EFI_DIRECTORY, ESP_RELATIVE_MOUNT_POINT_PATH, - GRUB2_CONFIG_FILENAME, GRUB2_CONFIG_RELATIVE_PATH, - }, - EngineContext, + boot::{self, uki, ESP_EXTRACTION_DIRECTORY}, + EngineContext, Subsystem, }; /// Bootloader executables @@ -40,18 +36,90 @@ const BOOT_EFI: &str = BootloaderExecutable::Boot.current_name(); const GRUB_EFI: &str = BootloaderExecutable::Grub.current_name(); const GRUB_NOPREFIX_EFI: &str = BootloaderExecutable::GrubNoPrefix.current_name(); +#[derive(Default, Debug)] +pub struct EspSubsystem; +impl Subsystem for EspSubsystem { + fn name(&self) -> &'static str { + "esp" + } + + #[tracing::instrument(name = "esp_provision", skip_all)] + fn provision(&mut self, ctx: &EngineContext, mount_path: &Path) -> Result<(), TridentError> { + // Perform file-based deployment of ESP images, if needed, after filesystems have been + // mounted and initialized. + + // Deploy ESP image + deploy_esp(ctx, mount_path).structured(ServicingError::DeployESPImages)?; + + Ok(()) + } +} + +/// Performs file-based deployment of ESP images from the OS image. +fn deploy_esp(ctx: &EngineContext, mount_point: &Path) -> Result<(), Error> { + trace!("Deploying ESP from OS image"); + + let os_image = ctx + .image + .as_ref() + .context("OS image is required to deploy ESP from OS image")?; + + let esp_img = os_image + .esp_filesystem() + .context("Failed to get ESP image from OS image")?; + + let stream = esp_img + .image_file + .reader() + .context("Failed to get reader for ESP image from OS image")?; + + // Extract the ESP image to a temporary file in + // `/ESP_EXTRACTION_DIRECTORY`. This location is generally + // guaranteed to be writable and backed by a real block device, so we don't + // have to store a potentially large ESP image in memory. + let esp_extraction_dir = path::join_relative(mount_point, ESP_EXTRACTION_DIRECTORY); + + let (temp_file, computed_sha384) = load_raw_image( + &esp_extraction_dir, + os_image.source(), + HashingReader384::new(stream), + ) + .context("Failed to load raw image")?; + + if esp_img.image_file.sha384 != computed_sha384 { + bail!( + "SHA384 mismatch for disk image {}: expected {}, got {}", + os_image.source(), + esp_img.image_file.sha384, + computed_sha384 + ); + } + + copy_file_artifacts(temp_file.path(), ctx, mount_point) +} + /// Takes in a reader to the raw zstd-compressed ESP image and decompresses it -/// into a temporary file. Returns a tuple containing the temporary file and the -/// computed hash (SHA256 or SHA384) of the image. +/// into a temporary file under `//`. +/// Returns a tuple containing the temporary file and the computed hash (SHA256 +/// or SHA384) of the image. /// /// It also takes in the URL of the image to be shown in case of errors. -fn load_raw_image(source: &Url, reader: R) -> Result<(NamedTempFile, String), Error> +fn load_raw_image( + esp_extraction_dir: &Path, + source: &Url, + reader: R, +) -> Result<(NamedTempFile, String), Error> where R: Read + HashingReader, { // Create a temporary file to download ESP image - let temp_image = NamedTempFile::new_in(ESP_EXTRACTION_DIRECTORY) - .context("Failed to create a temporary file")?; + trace!( + "Creating temporary file for ESP image extraction at {}", + esp_extraction_dir.display() + ); + + let temp_image = + NamedTempFile::new_in(esp_extraction_dir).context("Failed to create a temporary file")?; let temp_image_path = temp_image.path().to_path_buf(); debug!("Extracting ESP image to {}", temp_image_path.display()); @@ -126,103 +194,12 @@ fn copy_file_artifacts( esp_dir_path.display() ))?; - if ctx.spec.internal_params.get_flag(ENABLE_UKI_SUPPORT) { - let esp_root_path = join_relative(mount_point, ESP_MOUNT_POINT_PATH); - - // The directory where systemd-boot looks for UKIs. - let esp_uki_directory = esp_root_path.join("EFI/Linux"); - - // Create the EFI/Linux directory on the ESP if it doesn't exist. - fs::create_dir_all(&esp_uki_directory) - .context("Failed to create 'EFI/Linux' on the ESP")?; - - // Find all UKIs within the image. There should only be one. - let ukis: Vec<_> = temp_mount_dir - .join("EFI/Linux") - .read_dir() - .context("Could not read UKI directory")? - .collect::, _>>() - .context("Failed while reading UKI directory")? - .into_iter() - .map(|entry| entry.path()) - .collect(); - ensure!(!ukis.is_empty(), "No UKI files found within the image"); - ensure!(ukis.len() == 1, "Multiple UKI files found within the image"); - - // Copy the UKI from the image into the AZLA/ALZB directory. Eventually the UKI will be - // placed into /EFI/Linux on the ESP. But in order to do that atomically, the file needs to - // already be located somewhere on the ESP. - const TMP_UKI_NAME: &str = "vmlinuz.efi"; - fs::copy(&ukis[0], esp_dir_path.join(TMP_UKI_NAME)) - .context("Failed to copy UKI to the ESP")?; - - // Create 'loader/entries.srel' on the ESP as required by the Boot Loader Specification. - fs::create_dir_all(esp_root_path.join("loader")) - .context("Failed to create directory loader")?; - fs::write(esp_root_path.join("loader/entries.srel"), "type1\n") - .context("Failed to write entries.srel")?; - - // Update the boot order used by systemd-boot. - // - // Every UKI placed by Trident will have a name of the form - // 'vmlinuz--azl.efi'. Due to the way systemd-boot works, the UKI with the - // highest N will be first in the boot order. The volume and install index portions of the - // name are used to map UKIs to the particular install index and A/B volume that created - // them. - // - // In the loop below, we delete any existing UKIs with the current install index and A/B - // update volume, and record the highest N of any UKI that remains. Once the loop is - // finished, this enables us to place the new UKI at the next highest N so that it will be - // first in the boot order. - // - // TODO: The rename should really happen during 'finalize' rather than when the ESP image is - // being written. - let uki_suffix = match ctx.ab_active_volume { - Some(AbVolumeSelection::VolumeA) => format!("azlb{}.efi", ctx.install_index), - None | Some(AbVolumeSelection::VolumeB) => { - format!("azla{}.efi", ctx.install_index) - } - }; - let mut max_index = 99; - let entries = fs::read_dir(&esp_uki_directory).context(format!( - "Failed to read directory '{}'", - esp_uki_directory.display() - ))?; - for entry in entries { - let entry = entry.context("Failed to read entry")?; - let filename = entry.file_name(); - - // Parse the filename according to Trident's naming scheme. Any UKIs that don't match - // the naming scheme are for some other unknown install and will be left in place. This - // means they'll either be prioritized before or after the UKI Trident is placing, but - // they won't be deleted. - let Some((index, suffix)) = filename - .to_str() - .and_then(|filename| filename.strip_prefix("vmlinuz-")) - .and_then(|f| f.split_once('-')) - .and_then(|(index, suffix)| Some((index.parse::().ok()?, suffix))) - else { - trace!( - "Ignoring existing UKI file '{}' that does not match Trident naming scheme", - entry.path().display() - ); - continue; - }; - - if suffix == uki_suffix { - fs::remove_file(entry.path()).context(format!( - "Failed to remove file '{}'", - entry.path().display() - ))?; - } else { - max_index = max_index.max(index); - } - } - fs::rename( - esp_dir_path.join(TMP_UKI_NAME), - esp_uki_directory.join(format!("vmlinuz-{}-{uki_suffix}", max_index + 1)), - ) - .context("Failed to rename UKI file")?; + if ctx.is_uki_image().unstructured("UKI setting unknown")? { + // Prepare ESP directory structure for UKI boot + uki::prepare_esp_for_uki(mount_point)?; + + // Copy the UKI from the image into the ESP directory + uki::stage_uki_on_esp(temp_mount_dir, mount_point)?; } else { // In non-UKI mode, bail if grub_noprefix.efi is not found in the image. ensure!( @@ -369,31 +346,14 @@ fn generate_boot_filepaths(temp_mount_dir: &Path) -> Result, Error> Ok(paths) } -pub fn next_install_index(mount_point: &Path) -> Result { - let esp_efi_path = mount_point - .join(ESP_RELATIVE_MOUNT_POINT_PATH) - .join(ESP_EFI_DIRECTORY); - - debug!( - "Looking for next available install index in '{}'", - esp_efi_path.display() - ); - let first_available_install_index = find_first_available_install_index(&esp_efi_path) - .message("Failed to find the first available install index")?; - - debug!("Selected first available install index: '{first_available_install_index}'",); - Ok(first_available_install_index) -} - -/// Returns the path to the ESP directory where the boot files need to be copied -/// to. +/// Returns the path to the ESP directory where the boot files need to be copied to. /// -/// Path will be in the form of /boot/efi/EFI/, where is the install ID -/// as determined by ctx. +/// Path will be in the form of `/boot/efi/EFI/`, where `` is the install ID as determined +/// by ctx. /// -/// The function will find the next available install ID for this install and -/// update the install index in the engine context. -pub fn generate_efi_bin_base_dir_path( +/// The function will find the next available install ID for this install and update the install +/// index in the engine context. +fn generate_efi_bin_base_dir_path( ctx: &EngineContext, mount_point: &Path, ) -> Result { @@ -404,67 +364,12 @@ pub fn generate_efi_bin_base_dir_path( // Return the path to the ESP directory with the ESP dir name Ok( - esp_efi_path.join(super::get_update_esp_dir_name(ctx).context( + esp_efi_path.join(boot::get_update_esp_dir_name(ctx).context( "Failed to get ESP directory name for the new OS. Engine context is in an invalid state.", )?), ) } -/// Tries to find the next available AzL install index by looking at the -/// ESP directory names present in the specified ESP EFI path. -fn find_first_available_install_index(esp_efi_path: &Path) -> Result { - Ok(super::make_esp_dir_name_candidates() - // Take a limited number of candidates to avoid an infinite loop. - .take(1000) - // Go over all the candidates and find the first one that doesn't exist. - .find(|(idx, dir_names)| { - trace!("Checking if an install with index '{}' exists", idx); - // Returns true if all possible ESP directory names for this index - // do NOT exist. - dir_names.iter().all(|dir_names| { - let path = esp_efi_path.join(dir_names); - trace!("Checking if path '{}' exists", path.display()); - !path.exists() - }) - }) - .structured(UnsupportedConfigurationError::NoAvailableInstallIndex)? - .0) -} - -/// Performs file-based deployment of ESP images from the OS image. -pub(super) fn deploy_esp(ctx: &EngineContext, mount_point: &Path) -> Result<(), Error> { - trace!("Deploying ESP from OS image"); - - let os_image = ctx - .image - .as_ref() - .context("OS image is required to deploy ESP from OS image")?; - - let esp_img = os_image - .esp_filesystem() - .context("Failed to get ESP image from OS image")?; - - let stream = esp_img - .image_file - .reader() - .context("Failed to get reader for ESP image from OS image")?; - - let (temp_file, computed_sha384) = - load_raw_image(os_image.source(), HashingReader384::new(stream)) - .context("Failed to load raw image")?; - - if esp_img.image_file.sha384 != computed_sha384 { - bail!( - "SHA384 mismatch for disk image {}: expected {}, got {}", - os_image.source(), - esp_img.image_file.sha384, - computed_sha384 - ); - } - - copy_file_artifacts(temp_file.path(), ctx, mount_point) -} - #[cfg(test)] mod tests { use super::*; @@ -478,126 +383,10 @@ mod tests { status::{AbVolumeSelection, ServicingType}, }; - use crate::engine::boot::{get_update_esp_dir_name, make_esp_dir_name_candidates}; - - /// Simple case for find_first_available_install_index - #[test] - fn test_find_first_available_install_index_simple() { - let test_dir = TempDir::new().unwrap(); - let index = find_first_available_install_index(test_dir.path()).unwrap(); - assert_eq!(index, 0, "First available index should be 0"); - } - - /// Test that find_first_available_install_index will skip unavailable - /// indices - #[test] - fn test_find_first_available_install_index_existing_all() { - let test_dir = TempDir::new().unwrap(); - - // Create all ESP directories for indices 0-9 - make_esp_dir_name_candidates() - .take(10) - .for_each(|(_, dir_names)| { - for dir_name in dir_names { - fs::create_dir(test_dir.path().join(dir_name)).unwrap(); - } - }); - - // The first available index should be 10 - let index = find_first_available_install_index(test_dir.path()).unwrap(); - assert_eq!(index, 10, "First available index should be 10"); - } - - /// Test that find_first_available_install_index will skip unavailable - /// indices, even when only the A volume IDs are present - #[test] - fn test_find_first_available_install_index_existing_a() { - let test_dir = TempDir::new().unwrap(); - - // Create Volume A ESP directories for indices 0-9 - make_esp_dir_name_candidates() - .take(10) - .for_each(|(_, dir_names)| { - fs::create_dir(test_dir.path().join(&dir_names[0])).unwrap(); - }); - - // The first available index should be 10 - let index = find_first_available_install_index(test_dir.path()).unwrap(); - assert_eq!(index, 10, "First available index should be 10"); - } - - /// Test that find_first_available_install_index will skip unavailable - /// indices, even when only the B volume IDs are present - #[test] - fn test_find_first_available_install_index_existing_b() { - let test_dir = TempDir::new().unwrap(); - - // Create Volume B ESP directories for indices 0-9 - make_esp_dir_name_candidates() - .take(10) - .for_each(|(_, dir_names)| { - fs::create_dir(test_dir.path().join(&dir_names[1])).unwrap(); - }); - - // The first available index should be 10 - let index = find_first_available_install_index(test_dir.path()).unwrap(); - assert_eq!(index, 10, "First available index should be 10"); - } - - /// Test that find_first_available_install_index will skip unavailable - /// indices, even when only ONE ID is present per install. - #[test] - fn test_find_first_available_install_index_existing_mixed_1() { - let test_dir = TempDir::new().unwrap(); - - // Iterator to cycle between 0 and 1 - let mut volume_selector = (0..=1).cycle(); - - // Create alternating A/B Volume ESP directories for indices 0-9, starting with A - make_esp_dir_name_candidates() - .take(10) - .for_each(|(_, dir_names)| { - fs::create_dir( - test_dir - .path() - .join(&dir_names[volume_selector.next().unwrap()]), - ) - .unwrap(); - }); - - // The first available index should be 10 - let index = find_first_available_install_index(test_dir.path()).unwrap(); - assert_eq!(index, 10, "First available index should be 10"); - } - - /// Test that find_first_available_install_index will skip unavailable - /// indices, even when only ONE ID is present per install. - #[test] - fn test_find_first_available_install_index_existing_mixed_2() { - let test_dir = TempDir::new().unwrap(); - - // Iterator to cycle between 0 and 1 - let mut volume_selector = (0..=1).cycle(); - - // Advance the volume selector to start with B - volume_selector.next(); - - // Create alternating A/B Volume ESP directories for indices 0-9, starting with B - make_esp_dir_name_candidates() - .take(10) - .for_each(|(_, dir_names)| { - fs::create_dir( - test_dir - .path() - .join(&dir_names[volume_selector.next().unwrap()]), - ) - .unwrap(); - }); - - // The first available index should be 10 - let index = find_first_available_install_index(test_dir.path()).unwrap(); - assert_eq!(index, 10, "First available index should be 10"); - } + use crate::engine::{ + boot::{get_update_esp_dir_name, make_esp_dir_name_candidates}, + install_index, + }; #[test] fn test_generate_efi_bin_base_dir_path_clean_install() { @@ -623,7 +412,7 @@ mod tests { idx, test_dir.path().display() ); - ctx.install_index = next_install_index(test_dir.path()).unwrap(); + ctx.install_index = install_index::next_install_index(test_dir.path()).unwrap(); assert_eq!(idx, ctx.install_index); let esp_dir_path = generate_efi_bin_base_dir_path(&ctx, test_dir.path()).unwrap(); diff --git a/src/subsystems/initrd.rs b/src/subsystems/initrd.rs index e893b1798..4cddf8bd1 100644 --- a/src/subsystems/initrd.rs +++ b/src/subsystems/initrd.rs @@ -1,7 +1,7 @@ use log::{debug, info}; use osutils::mkinitrd; -use trident_api::{constants::internal_params::ENABLE_UKI_SUPPORT, error::TridentError}; +use trident_api::error::TridentError; use crate::engine::{EngineContext, Subsystem}; @@ -18,7 +18,7 @@ impl Subsystem for InitrdSubsystem { #[tracing::instrument(name = "initrd_regeneration", skip_all)] fn configure(&mut self, ctx: &EngineContext) -> Result<(), TridentError> { - if ctx.spec.internal_params.get_flag(ENABLE_UKI_SUPPORT) { + if ctx.is_uki_image()? { debug!("Skipping initrd regeneration because UKI is in use"); return Ok(()); } diff --git a/src/subsystems/mod.rs b/src/subsystems/mod.rs index 90a8cf4a9..2f882bf87 100644 --- a/src/subsystems/mod.rs +++ b/src/subsystems/mod.rs @@ -1,3 +1,4 @@ +pub(crate) mod esp; pub(crate) mod hooks; pub(crate) mod initrd; pub(crate) mod management; diff --git a/src/subsystems/network.rs b/src/subsystems/network.rs index 72d094a21..6faea67aa 100644 --- a/src/subsystems/network.rs +++ b/src/subsystems/network.rs @@ -1,3 +1,6 @@ +use std::fs; + +use anyhow::Context; use log::debug; use osutils::netplan; @@ -5,6 +8,9 @@ use trident_api::error::{ReportError, ServicingError, TridentError}; use crate::engine::{EngineContext, Subsystem}; +const CLOUD_INIT_DISABLE_FILE: &str = "/etc/cloud/cloud.cfg.d/99-use-trident-networking.cfg"; +const CLOUD_INIT_DISABLE_CONTENT: &str = "network: {config: disabled}"; + #[derive(Default, Debug)] pub struct NetworkSubsystem; impl Subsystem for NetworkSubsystem { @@ -19,6 +25,12 @@ impl Subsystem for NetworkSubsystem { debug!("Configuring network"); netplan::write(config).structured(ServicingError::WriteNetplanConfig)?; netplan::generate().structured(ServicingError::GenerateNetplanConfig)?; + + // We need to disable cloud-init's network configuration when + // Trident is configuring the network, otherwise cloud-init may + // deploy additional configurations that are undesired and may + // conflict with or otherwise affect Trident's network setup. + disable_cloud_init_networking()?; } None => { debug!("Network config not provided"); @@ -27,3 +39,14 @@ impl Subsystem for NetworkSubsystem { Ok(()) } } + +fn disable_cloud_init_networking() -> Result<(), TridentError> { + fs::write(CLOUD_INIT_DISABLE_FILE, CLOUD_INIT_DISABLE_CONTENT) + .with_context(|| { + format!( + "Failed to write to cloud-init disable file at {}", + CLOUD_INIT_DISABLE_FILE + ) + }) + .structured(ServicingError::DisableCloudInitNetworking) +} diff --git a/src/subsystems/osconfig/mod.rs b/src/subsystems/osconfig/mod.rs index bc029225e..8bece1f3d 100644 --- a/src/subsystems/osconfig/mod.rs +++ b/src/subsystems/osconfig/mod.rs @@ -6,7 +6,7 @@ use log::{debug, error, info, warn}; use osutils::{osmodifier::OSModifierConfig, path}; use trident_api::{ config::{ManagementOs, SshMode}, - constants::internal_params::{DISABLE_HOSTNAME_CARRY_OVER, ENABLE_UKI_SUPPORT}, + constants::internal_params::DISABLE_HOSTNAME_CARRY_OVER, error::{ExecutionEnvironmentMisconfigurationError, ReportError, ServicingError, TridentError}, status::ServicingType, }; @@ -123,9 +123,7 @@ impl Subsystem for OsConfigSubsystem { self.name() ); return Ok(()); - } else if ctx.spec.internal_params.get_flag(ENABLE_UKI_SUPPORT) - && ctx.storage_graph.root_fs_is_verity() - { + } else if ctx.is_uki_image()? && ctx.storage_graph.root_fs_is_verity() { error!("Skipping OS configuration changes requested in Host Configuration because UKI root verity is in use."); return Ok(()); } @@ -176,6 +174,13 @@ impl Subsystem for OsConfigSubsystem { os_modifier_config.kernel_command_line = Some(ctx.spec.os.kernel_command_line.clone()); } + // If we have a UKI image, update SELinux mode here since it cannot be set via kernel + // command line. + if ctx.is_uki_image()? && ctx.spec.os.selinux.mode.is_some() { + debug!("Updating SELinux config"); + os_modifier_config.selinux = Some(ctx.spec.os.selinux.clone()); + } + os_modifier_config .call_os_modifier(Path::new(OS_MODIFIER_NEWROOT_PATH)) .structured(ServicingError::RunOsModifier)?; @@ -390,6 +395,7 @@ mod functional_test { }, ..Default::default() }, + is_uki: Some(false), ..Default::default() }; assert!(os_config_requires_os_modifier(&ctx)); @@ -430,6 +436,7 @@ mod functional_test { // Create EngineContext with no hostname specified let ctx = EngineContext { servicing_type: ServicingType::AbUpdate, + is_uki: Some(false), ..Default::default() }; assert!(os_config_requires_os_modifier(&ctx)); diff --git a/src/subsystems/selinux.rs b/src/subsystems/selinux.rs index cd983a143..b058c8bc6 100644 --- a/src/subsystems/selinux.rs +++ b/src/subsystems/selinux.rs @@ -13,7 +13,7 @@ use osutils::dependencies::{Dependency, DependencyResultExt}; use sysdefs::filesystems::{KernelFilesystemType, RealFilesystemType}; use trident_api::{ config::{HostConfigurationDynamicValidationError, SelinuxMode}, - constants::{internal_params::ENABLE_UKI_SUPPORT, SELINUX_CONFIG}, + constants::SELINUX_CONFIG, error::{InvalidInputError, ReportError, ServicingError, TridentError}, status::ServicingType, }; @@ -124,13 +124,11 @@ impl Subsystem for SelinuxSubsystem { // If a verity filesystem is mounted at root, ensure that SELinux is not // in enforcing mode and warn if it is in permissive mode - if ctx.storage_graph.root_fs_is_verity() - && !ctx.spec.internal_params.get_flag(ENABLE_UKI_SUPPORT) - { + if ctx.storage_graph.root_fs_is_verity() && !ctx.is_uki_image()? { match final_selinux_mode { SelinuxMode::Enforcing => { return Err(TridentError::new(InvalidInputError::from( - HostConfigurationDynamicValidationError::VerityAndSelinuxUnsupported { + HostConfigurationDynamicValidationError::RootVerityAndSelinuxUnsupported { selinux_mode: final_selinux_mode.to_string(), }, ))); diff --git a/src/subsystems/storage/encryption.rs b/src/subsystems/storage/encryption.rs index 337067ff4..dd95f2891 100644 --- a/src/subsystems/storage/encryption.rs +++ b/src/subsystems/storage/encryption.rs @@ -1,16 +1,14 @@ use std::{fs, os::unix::fs::PermissionsExt, path::PathBuf}; -use anyhow::{bail, Context, Error}; use log::{info, trace}; use osutils::{encryption, files}; use trident_api::{ config::{ HostConfiguration, HostConfigurationDynamicValidationError, - HostConfigurationStaticValidationError, Partition, PartitionType, + HostConfigurationStaticValidationError, PartitionType, }, error::{InvalidInputError, ReportError, ServicingError, TridentError}, - BlockDeviceId, }; use crate::engine::EngineContext; @@ -91,11 +89,12 @@ pub fn configure(ctx: &EngineContext) -> Result<(), TridentError> { for ev in encryption.volumes.iter() { let backing_partition = - get_first_backing_partition(ctx, &ev.device_id).structured(InvalidInputError::from( - HostConfigurationStaticValidationError::EncryptedVolumeNotPartitionOrRaid { - encrypted_volume: ev.id.clone(), - }, - ))?; + ctx.get_first_backing_partition(&ev.device_id) + .structured(InvalidInputError::from( + HostConfigurationStaticValidationError::EncryptedVolumeNotPartitionOrRaid { + encrypted_volume: ev.id.clone(), + }, + ))?; let device_path = &ctx.get_block_device_path(&ev.device_id).structured( ServicingError::FindEncryptedVolumeBlockDevice { device_id: ev.device_id.clone(), @@ -154,39 +153,6 @@ pub fn configure(ctx: &EngineContext) -> Result<(), TridentError> { Ok(()) } -/// Returns the first partition that backs the given block device, or Err if the block device ID -/// does not correspond to a partition or software RAID array. -fn get_first_backing_partition<'a>( - ctx: &'a EngineContext, - block_device_id: &BlockDeviceId, -) -> Result<&'a Partition, Error> { - if let Some(partition) = ctx.spec.storage.get_partition(block_device_id) { - Ok(partition) - } else if let Some(array) = ctx - .spec - .storage - .raid - .software - .iter() - .find(|r| &r.id == block_device_id) - { - let partition_id = array - .devices - .first() - .context(format!("RAID array '{}' has no partitions", array.id))?; - - ctx.spec - .storage - .get_partition(partition_id) - .context(format!( - "RAID array '{}' doesn't reference partition", - block_device_id - )) - } else { - bail!("Block device '{block_device_id}' is not a partition or RAID array") - } -} - #[cfg(test)] mod tests { use super::*; @@ -197,76 +163,13 @@ mod tests { use trident_api::{ config::{ - Disk, EncryptedVolume, Encryption, Partition, PartitionSize, PartitionType, Raid, - RaidLevel, SoftwareRaidArray, Storage, + Disk, EncryptedVolume, Encryption, Partition, PartitionSize, PartitionType, Storage, }, error::ErrorKind, }; use crate::subsystems::storage::tests as storage_tests; - #[test] - fn test_get_first_backing_partition() { - let ctx = EngineContext { - spec: HostConfiguration { - storage: Storage { - disks: vec![Disk { - id: "os".to_owned(), - partitions: vec![ - Partition { - id: "esp".to_owned(), - partition_type: PartitionType::Esp, - size: PartitionSize::from_str("1G").unwrap(), - }, - Partition { - id: "root".to_owned(), - partition_type: PartitionType::Root, - size: PartitionSize::from_str("8G").unwrap(), - }, - Partition { - id: "rootb".to_owned(), - partition_type: PartitionType::Root, - size: PartitionSize::from_str("8G").unwrap(), - }, - ], - ..Default::default() - }], - raid: Raid { - software: vec![SoftwareRaidArray { - id: "root-raid1".to_owned(), - devices: vec!["root".to_string(), "rootb".to_string()], - name: "raid1".to_string(), - level: RaidLevel::Raid1, - }], - ..Default::default() - }, - ..Default::default() - }, - ..Default::default() - }, - ..Default::default() - }; - - assert_eq!( - get_first_backing_partition(&ctx, &"esp".to_owned()).unwrap(), - &ctx.spec.storage.disks[0].partitions[0] - ); - assert_eq!( - get_first_backing_partition(&ctx, &"root".to_owned()).unwrap(), - &ctx.spec.storage.disks[0].partitions[1] - ); - assert_eq!( - get_first_backing_partition(&ctx, &"rootb".to_owned()).unwrap(), - &ctx.spec.storage.disks[0].partitions[2] - ); - assert_eq!( - get_first_backing_partition(&ctx, &"root-raid1".to_owned()).unwrap(), - &ctx.spec.storage.disks[0].partitions[1] - ); - get_first_backing_partition(&ctx, &"os".to_owned()).unwrap_err(); - get_first_backing_partition(&ctx, &"non-existant".to_owned()).unwrap_err(); - } - fn get_storage(recovery_key_file: &tempfile::NamedTempFile) -> Storage { Storage { disks: vec![Disk { diff --git a/src/subsystems/storage/fstab.rs b/src/subsystems/storage/fstab.rs index 062b371b8..8370d368b 100644 --- a/src/subsystems/storage/fstab.rs +++ b/src/subsystems/storage/fstab.rs @@ -7,7 +7,7 @@ use osutils::{ filesystems::TabFileSystemType, tabfile::{TabFile, TabFileEntry}, }; -use trident_api::{config::SwapDevice, BlockDeviceId}; +use trident_api::{config::Swap, BlockDeviceId}; use crate::engine::{filesystem::FileSystemData, EngineContext}; @@ -109,7 +109,7 @@ fn entry_from_fs_data( fn entry_from_swap( device_finder: impl Fn(&BlockDeviceId) -> Result, - swap: &SwapDevice, + swap: &Swap, ) -> Result { Ok(TabFileEntry::new_swap(device_finder(&swap.device_id)?)) } @@ -297,7 +297,7 @@ mod tests { assert_eq!( entry_from_swap( device_finder, - &SwapDevice { + &Swap { device_id: "swap".to_owned(), }, ) @@ -319,7 +319,7 @@ mod tests { servicing_type: ServicingType::CleanInstall, spec: HostConfiguration { storage: Storage { - swap: vec![SwapDevice { + swap: vec![Swap { device_id: "swap".to_owned(), }], ..Default::default() @@ -453,7 +453,7 @@ mod tests { source: FileSystemSource::New(NewFileSystemType::Ext4), }, ], - swap: vec![SwapDevice { + swap: vec![Swap { device_id: "swap".to_owned(), }], ..Default::default() diff --git a/src/subsystems/storage/mod.rs b/src/subsystems/storage/mod.rs index a7c41d473..646aa1157 100644 --- a/src/subsystems/storage/mod.rs +++ b/src/subsystems/storage/mod.rs @@ -8,7 +8,7 @@ use log::{debug, error, warn}; use osutils::lsblk; use trident_api::{ config::HostConfigurationDynamicValidationError, - constants::internal_params::{ENABLE_UKI_SUPPORT, RELAXED_COSI_VALIDATION}, + constants::internal_params::RELAXED_COSI_VALIDATION, error::{ InvalidInputError, ReportError, ServicingError, TridentError, TridentResultExt, UnsupportedConfigurationError, @@ -166,12 +166,12 @@ impl Subsystem for StorageSubsystem { Ok(Some(ServicingType::NoActiveServicing)) } - fn provision(&mut self, ctx: &EngineContext, mount_point: &Path) -> Result<(), TridentError> { + fn provision(&mut self, ctx: &EngineContext, mount_path: &Path) -> Result<(), TridentError> { if ctx.servicing_type == ServicingType::CleanInstall && ctx.storage_graph.root_fs_is_verity() { debug!("Root verity is enabled, setting up machine-id"); - verity::create_machine_id(mount_point).structured(ServicingError::CreateMachineId)?; + verity::create_machine_id(mount_path).structured(ServicingError::CreateMachineId)?; } Ok(()) @@ -179,9 +179,7 @@ impl Subsystem for StorageSubsystem { #[tracing::instrument(name = "storage_configuration", skip_all)] fn configure(&mut self, ctx: &EngineContext) -> Result<(), TridentError> { - if ctx.spec.internal_params.get_flag(ENABLE_UKI_SUPPORT) - && ctx.storage_graph.root_fs_is_verity() - { + if ctx.is_uki_image()? && ctx.storage_graph.root_fs_is_verity() { debug!("Skipping storage configuration because UKI root verity is in use"); return Ok(()); } diff --git a/src/subsystems/storage/osimage.rs b/src/subsystems/storage/osimage.rs index 17a6280b9..191cf0fc8 100644 --- a/src/subsystems/storage/osimage.rs +++ b/src/subsystems/storage/osimage.rs @@ -1,12 +1,12 @@ use std::{ collections::{HashMap, HashSet}, - path::Path, + path::{Path, PathBuf}, }; use const_format::formatcp; use log::{debug, error, trace, warn}; -use osutils::{df, lsblk}; +use osutils::lsblk; use trident_api::{ config::FileSystemSource, constants::{ @@ -53,7 +53,7 @@ pub fn validate_host_config(ctx: &EngineContext) -> Result<(), TridentError> { validate_verity_match(os_image, &ctx.storage_graph)?; debug!("Validating ESP image in OS image"); - validate_esp(os_image)?; + validate_esp(os_image, ctx)?; debug!("Validating filesystem mounted at or containing /boot"); ensure_boot_is_ext4(os_image)?; @@ -297,7 +297,7 @@ fn validate_verity_match( /// Checks that the ESP filesystem never has its verity entry populated. In addition, checks that /// there is enough space in /tmp to perform file-based copy of ESP image, and warns the user if not /// (this will not produce a fatal error). -fn validate_esp(os_image: &OsImage) -> Result<(), TridentError> { +fn validate_esp(os_image: &OsImage, ctx: &EngineContext) -> Result<(), TridentError> { let Ok(esp_img) = os_image.esp_filesystem() else { trace!("Unable to access ESP filesystem."); return Ok(()); @@ -308,11 +308,11 @@ fn validate_esp(os_image: &OsImage) -> Result<(), TridentError> { return Err(TridentError::new(InvalidInputError::UnexpectedVerityOnEsp)); } - // Ensure there is enough space in /tmp to perform file-based copy of ESP image - let Ok(available_space) = df::available_space_in_fs(ESP_EXTRACTION_DIRECTORY) else { + let Some(available_space) = ctx.filesystem_block_device_size(ESP_EXTRACTION_DIRECTORY) else { warn!("Failed to check if there is enough space available on '{ESP_EXTRACTION_DIRECTORY}' to copy ESP image."); return Ok(()); }; + trace!("Found {available_space} bytes of available space in {ESP_EXTRACTION_DIRECTORY}."); let esp_img_size = esp_img.image_file.uncompressed_size; @@ -320,15 +320,15 @@ fn validate_esp(os_image: &OsImage) -> Result<(), TridentError> { if esp_img_size >= available_space { error!( - "There is not enough space to copy the ESP image into {ESP_EXTRACTION_DIRECTORY}. The \ - uncompressed size of the ESP image is {}, while {ESP_EXTRACTION_DIRECTORY} has {} available.", + "There is not enough space to copy the ESP image into '{ESP_EXTRACTION_DIRECTORY}'. The \ + uncompressed size of the ESP image is {}, while '{ESP_EXTRACTION_DIRECTORY}' has {} available.", ByteCount::from(esp_img_size).to_human_readable_approx(), ByteCount::from(available_space).to_human_readable_approx() ); } else if esp_img_size >= available_space / 2 { warn!( - "There may not be enough space to copy the ESP image into {ESP_EXTRACTION_DIRECTORY}. \ - The uncompressed size of the ESP image is {}, while {ESP_EXTRACTION_DIRECTORY} has {} available.", + "There may not be enough space to copy the ESP image into '{ESP_EXTRACTION_DIRECTORY}'. \ + The uncompressed size of the ESP image is {}, while '{ESP_EXTRACTION_DIRECTORY}' has {} available.", ByteCount::from(esp_img_size).to_human_readable_approx(), ByteCount::from(available_space).to_human_readable_approx() ); @@ -408,6 +408,16 @@ fn validate_filesystem_blkdev_fit( let fs_size = fs.image_file.uncompressed_size; trace!("The size of the filesystem associated with block device '{device_id}' is {fs_size} bytes."); + if let Some(fs_verity) = fs.verity.as_ref() { + // If the filesystem has a verity hash, we need to check the size of the + // block device that will contain the verity hash. + validate_hash_filesystem_blkdev_fit( + fs_verity.hash_image_file.uncompressed_size, + fs.mount_point.clone(), + graph, + )?; + } + let Some(blkdev_size) = graph.block_device_size(device_id) else { debug!("Could not find the size of the block device with id '{device_id}'. Block device may not have a fixed size."); continue; @@ -424,8 +434,10 @@ fn validate_filesystem_blkdev_fit( if fs_size > blkdev_size { return Err(TridentError::new( InvalidInputError::FilesystemSizeExceedsBlockDevice { + mount_point: fs.mount_point.display().to_string(), device_id: device_id.to_string(), - min_size: fs_size, + fs_size: ByteCount::from(fs_size), + device_size: ByteCount::from(blkdev_size), }, )); }; @@ -433,6 +445,53 @@ fn validate_filesystem_blkdev_fit( Ok(()) } +/// Validate that the size of a verity filesystem hash fits in the configured block device. +fn validate_hash_filesystem_blkdev_fit( + fs_verity_hash_file_size: u64, + fs_mount_point: PathBuf, + graph: &StorageGraph, +) -> Result<(), TridentError> { + // Get the verity block device corresponding to the filesystem + let verity_device = graph + .verity_device_for_filesystem(&fs_mount_point) + .structured(InternalError::Internal( + "No verity device found for mount point", + )) + .message(format!( + "Failed to find verity device for filesystem mounted at '{}'", + fs_mount_point.display() + ))?; + + // Get the size of the block device configured for the verity hash + let Some(blkdev_hash_size) = graph.block_device_size(&verity_device.hash_device_id) else { + debug!( + "Could not find the size of the block device with id '{}'. Block device may not have a fixed size.", + verity_device.hash_device_id + ); + return Ok(()); + }; + + debug!( + "Found filesystem with verity hash of size {} and block device with size {} for device '{}'", + ByteCount::from(fs_verity_hash_file_size).to_human_readable_approx(), + ByteCount::from(blkdev_hash_size).to_human_readable_approx(), + &verity_device.hash_device_id, + ); + + // Ensure that the filesystem hash will fit in the block device + if fs_verity_hash_file_size > blkdev_hash_size { + return Err(TridentError::new( + InvalidInputError::FilesystemSizeExceedsBlockDevice { + mount_point: fs_mount_point.to_string_lossy().into(), + device_id: verity_device.hash_device_id.to_string(), + fs_size: ByteCount::from(fs_verity_hash_file_size), + device_size: ByteCount::from(blkdev_hash_size), + }, + )); + }; + Ok(()) +} + #[cfg(test)] mod tests { use super::*; @@ -618,39 +677,59 @@ mod tests { #[test] fn test_validate_esp_success() { - // Generate mock OS image - let mock_image = MockOsImage { - source: Url::parse(OSIMAGE_DUMMY_SOURCE).unwrap(), - os_arch: SystemArchitecture::Amd64, - os_release: OsRelease::default(), - images: vec![ - MockImage { - mount_point: PathBuf::from(ESP_MOUNT_POINT_PATH), - fs_type: OsImageFileSystemType::Vfat, - fs_uuid: OsUuid::Uuid(Uuid::new_v4()), - part_type: DiscoverablePartitionType::Esp, - verity: None, - }, - MockImage { - mount_point: PathBuf::from(ROOT_MOUNT_POINT_PATH), - fs_type: OsImageFileSystemType::Ext4, - fs_uuid: OsUuid::Uuid(Uuid::new_v4()), - part_type: DiscoverablePartitionType::Root, - verity: Some(MockVerity { - roothash: "mock-roothash".to_string(), - }), + let ctx = EngineContext::default() + .with_image(MockOsImage { + source: Url::parse(OSIMAGE_DUMMY_SOURCE).unwrap(), + os_arch: SystemArchitecture::Amd64, + os_release: OsRelease::default(), + images: vec![ + MockImage { + mount_point: PathBuf::from(ESP_MOUNT_POINT_PATH), + fs_type: OsImageFileSystemType::Vfat, + fs_uuid: OsUuid::Uuid(Uuid::new_v4()), + part_type: DiscoverablePartitionType::Esp, + verity: None, + }, + MockImage { + mount_point: PathBuf::from(ROOT_MOUNT_POINT_PATH), + fs_type: OsImageFileSystemType::Ext4, + fs_uuid: OsUuid::Uuid(Uuid::new_v4()), + part_type: DiscoverablePartitionType::Root, + verity: Some(MockVerity { + roothash: "mock-roothash".to_string(), + }), + }, + ], + }) + .with_spec(HostConfiguration { + storage: Storage { + disks: vec![Disk { + id: "disk1".to_owned(), + device: PathBuf::from("/dev/sda"), + partitions: vec![Partition { + id: "part1".to_owned(), + size: 4096.into(), + partition_type: Default::default(), + }], + ..Default::default() + }], + filesystems: vec![FileSystem { + device_id: Some("part1".to_owned()), + mount_point: Some("/data".into()), + source: FileSystemSource::Image, + }], + ..Default::default() }, - ], - }; + ..Default::default() + }); // Expect validation to succeed - validate_esp(&OsImage::mock(mock_image)).unwrap(); + validate_esp(ctx.image.as_ref().unwrap(), &ctx).unwrap(); } #[test] fn test_validate_esp_failure() { - // Generate mock OS image - let mock_image = MockOsImage { + let ctx = EngineContext::default().with_image(MockOsImage { source: Url::parse(OSIMAGE_DUMMY_SOURCE).unwrap(), os_arch: SystemArchitecture::Amd64, os_release: OsRelease::default(), @@ -674,10 +753,10 @@ mod tests { }), }, ], - }; + }); // Expect validation to fail - let err = validate_esp(&OsImage::mock(mock_image)).unwrap_err(); + let err = validate_esp(ctx.image.as_ref().unwrap(), &ctx).unwrap_err(); assert_eq!( err.kind(), &ErrorKind::InvalidInput(InvalidInputError::UnexpectedVerityOnEsp) @@ -1036,6 +1115,76 @@ mod tests { "Expected UnusedOsImageFilesystem error" ); } + + #[test] + fn test_validate_hash_filesystem_blkdev_fit() { + let required_size_gb = 1; + let required_partition_size = + PartitionSize::from_str(format!("{required_size_gb}G").as_str()).unwrap(); + let too_big_size_gb = 2; + let too_big_partition_size = + PartitionSize::from_str(format!("{too_big_size_gb}G").as_str()).unwrap(); + let mount_point = "/mnt/path/verity"; + let fs_mount_point = PathBuf::from(mount_point); + let graph = Storage { + disks: vec![Disk { + device: "/dev/sda".into(), + partitions: vec![ + Partition { + id: "data".into(), + partition_type: Default::default(), + size: required_partition_size, + }, + Partition { + id: "hash".into(), + partition_type: Default::default(), + size: required_partition_size, + }, + ], + ..Default::default() + }], + verity: vec![VerityDevice { + id: "verity".into(), + name: "verity".into(), + data_device_id: "data".into(), + hash_device_id: "hash".into(), + ..Default::default() + }], + filesystems: vec![FileSystem { + device_id: Some("verity".into()), + source: FileSystemSource::Image, + mount_point: Some(MountPoint::from_str(mount_point).unwrap()), + }], + ..Default::default() + } + .build_graph() + .unwrap(); + + // Test with exact matching block device size + validate_hash_filesystem_blkdev_fit( + required_partition_size.to_bytes().unwrap(), + fs_mount_point.clone(), + &graph, + ) + .unwrap(); + + // Test with too small block device size + let err = validate_hash_filesystem_blkdev_fit( + too_big_partition_size.to_bytes().unwrap(), + fs_mount_point.clone(), + &graph, + ) + .unwrap_err(); + assert_eq!( + err.kind(), + &ErrorKind::InvalidInput(InvalidInputError::FilesystemSizeExceedsBlockDevice { + mount_point: mount_point.to_string(), + device_id: "hash".to_string(), + fs_size: ByteCount::from(too_big_partition_size.to_bytes().unwrap()), + device_size: ByteCount::from(required_partition_size.to_bytes().unwrap()), + }) + ); + } } #[cfg(feature = "functional-test")] diff --git a/storm/cmd/storm-helloworld/main.go b/storm/cmd/storm-helloworld/main.go index f167d3298..8e3219d72 100644 --- a/storm/cmd/storm-helloworld/main.go +++ b/storm/cmd/storm-helloworld/main.go @@ -1,8 +1,9 @@ package main import ( - "storm/pkg/storm" - "storm/suites/helloworld" + "storm" + + "storm/helloworld" ) func main() { diff --git a/storm/cmd/storm-trident/main.go b/storm/cmd/storm-trident/main.go deleted file mode 100644 index 0446cd5ca..000000000 --- a/storm/cmd/storm-trident/main.go +++ /dev/null @@ -1,24 +0,0 @@ -package main - -import ( - "storm/pkg/storm" - trident "storm/suites/trident/e2e" - "storm/suites/trident/helpers" -) - -func main() { - storm := storm.CreateSuite("trident") - - // Add Trident E2E scenarios - scenarios := trident.DiscoverTridentScenarios(storm.Log) - for _, scenario := range scenarios { - storm.AddScenario(&scenario) - } - - // Register Trident helpers - for _, helper := range helpers.TRIDENT_HELPERS { - storm.AddHelper(helper) - } - - storm.Run() -} diff --git a/storm/go.mod b/storm/go.mod index 829ca37d2..478db0af7 100644 --- a/storm/go.mod +++ b/storm/go.mod @@ -5,19 +5,13 @@ go 1.23.5 require ( github.com/alecthomas/kong v1.8.1 github.com/fatih/color v1.18.0 - github.com/pkg/sftp v1.13.9 github.com/sirupsen/logrus v1.9.3 - golang.org/x/crypto v0.37.0 golang.org/x/term v0.31.0 - gopkg.in/yaml.v3 v3.0.1 ) require ( - github.com/kr/fs v0.1.0 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/stretchr/testify v1.8.0 // indirect golang.org/x/sys v0.32.0 // indirect ) - -// Deal with CVE-2024-45338, CVE-2025-22870, CVE-2025-22872 -replace golang.org/x/net => golang.org/x/net v0.39.0 diff --git a/storm/go.sum b/storm/go.sum index 5c0d49497..a42c16ddf 100644 --- a/storm/go.sum +++ b/storm/go.sum @@ -9,18 +9,13 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= -github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8= -github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/pkg/sftp v1.13.9 h1:4NGkvGudBL7GteO3m6qnaQ4pC0Kvf0onSVc9gR3EWBw= -github.com/pkg/sftp v1.13.9/go.mod h1:OBN7bVXdstkFFN/gdnHPUb5TE8eb8G1Rp9wCItqjkkA= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= @@ -31,51 +26,13 @@ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= -golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= -golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= -golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= -golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= -golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E= -golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= -golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= -golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= -golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o= golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw= -golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= -golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= -golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= -golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= -golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= -golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/storm/suites/helloworld/helper.go b/storm/helloworld/helper.go similarity index 86% rename from storm/suites/helloworld/helper.go rename to storm/helloworld/helper.go index f4e44b2ff..dff236200 100644 --- a/storm/suites/helloworld/helper.go +++ b/storm/helloworld/helper.go @@ -2,7 +2,9 @@ package helloworld import ( "fmt" - "storm/pkg/storm" + "storm" + + "github.com/sirupsen/logrus" ) // This is a simple implementation of the storm.Helper interface. It is @@ -32,7 +34,12 @@ func (h *HelloWorldHelper) RegisterTestCases(r storm.TestRegistrar) error { } func (h *HelloWorldHelper) myPasssingTestCase(tc storm.TestCase) error { - tc.Logger().Info("This message will be logged in the test case!") + // It is recommended to use the logrus logger for logging in your test cases. + // This will be captured by storm and stored in the test case. + logrus.Info("This message will be captured by storm and stored in the test case!") + + // If desired, you can also use the standard fmt package to print messages. + fmt.Println("This message will also be captured!") // Do something here! // ... @@ -50,7 +57,7 @@ func (h *HelloWorldHelper) mySkippedTestCase(tc storm.TestCase) error { } func (h *HelloWorldHelper) myFailingTestCase(tc storm.TestCase) error { - tc.Logger().Info("This message will be shown in the failure report!") + logrus.Info("This message will be shown in the failure report!") // A failure will stop execution of this test case here, mark it as failed, // and stop execution of the entire test suite. // time.Sleep(time.Second * 10) @@ -66,7 +73,7 @@ func (h *HelloWorldHelper) myFailingTestCase(tc storm.TestCase) error { } func (h *HelloWorldHelper) myErrorTestCase(tc storm.TestCase) error { - tc.Logger().Info("This test case will never run because we fail before," + + logrus.Info("This test case will never run because we fail before," + "but we'll use it to demonstrate error handling.") // Storm treats failures an errors differently. Both generally imply that a diff --git a/storm/helloworld/scenario.go b/storm/helloworld/scenario.go new file mode 100644 index 000000000..6b2219b2e --- /dev/null +++ b/storm/helloworld/scenario.go @@ -0,0 +1,65 @@ +// Package helloworld implements a simple hello world scenario and helper. +package helloworld + +import ( + "storm" + "storm/pkg/storm/core" + + "github.com/sirupsen/logrus" +) + +type HelloWorldScenario struct { + // You can embed storm.BaseScenario to get the default implementation of the Scenario interface. + // This is useful if you are not using most methods. + // storm.BaseScenario +} + +// Args implements core.Scenario. +func (s *HelloWorldScenario) Args() any { + return nil +} + +// Setup implements core.Scenario. +func (s *HelloWorldScenario) Setup(core.SetupCleanupContext) error { + logrus.Info("Setup called for HelloWorldScenario") + return nil +} + +// Cleanup implements core.Scenario. +func (s *HelloWorldScenario) Cleanup(core.SetupCleanupContext) error { + logrus.Info("Cleanup called for HelloWorldScenario") + return nil +} + +// RequiredFiles implements core.Scenario. +func (s *HelloWorldScenario) RequiredFiles() []string { + return nil +} + +// StagePaths implements core.Scenario. +func (s *HelloWorldScenario) StagePaths() []string { + return nil +} + +// Tags implements core.Scenario. +func (s *HelloWorldScenario) Tags() []string { + return nil +} + +// Type implements core.Scenario. +func (s *HelloWorldScenario) Name() string { + return "hello-world" +} + +// Description implements core.Scenario. +func (h *HelloWorldScenario) RegisterTestCases(r storm.TestRegistrar) error { + r.RegisterTestCase("myPassingTestCase", func(tc storm.TestCase) error { + logrus.Info("This message will be logged in the test case!") + + // Do something here! + // ... + + return nil + }) + return nil +} diff --git a/storm/internal/cli/run/helper.go b/storm/internal/cli/run/helper.go index d1a9edc2b..abb31ae49 100644 --- a/storm/internal/cli/run/helper.go +++ b/storm/internal/cli/run/helper.go @@ -7,6 +7,7 @@ import ( type HelperCmd struct { Helper string `arg:"" name:"helper" help:"Name of the helper to run"` + Watch bool `short:"w" help:"Watch the output of the helper live"` HelperArgs []string `arg:"" passthrough:"all" help:"Arguments to pass to the helper, you may use '--' to force passthrough." optional:""` } @@ -16,5 +17,5 @@ func (cmd *HelperCmd) Run(suite core.SuiteContext) error { helper := suite.Helper(cmd.Helper) - return runner.RegisterAndRunTests(suite, helper, cmd.HelperArgs) + return runner.RegisterAndRunTests(suite, helper, cmd.HelperArgs, cmd.Watch) } diff --git a/storm/internal/cli/run/scenario.go b/storm/internal/cli/run/scenario.go index 2807fd92b..f9e9189aa 100644 --- a/storm/internal/cli/run/scenario.go +++ b/storm/internal/cli/run/scenario.go @@ -7,6 +7,7 @@ import ( type ScenarioCmd struct { Scenario string `arg:"" name:"scenario" help:"Name of the scenario to run"` + Watch bool `short:"w" help:"Watch the output of the scenario live"` ScenarioArgs []string `arg:"" passthrough:"all" help:"Arguments to pass to the scenario, you may use '--' to force passthrough." optional:""` } @@ -16,5 +17,5 @@ func (cmd *ScenarioCmd) Run(suite core.SuiteContext) error { scenario := suite.Scenario(cmd.Scenario) - return runner.RegisterAndRunTests(suite, scenario, cmd.ScenarioArgs) + return runner.RegisterAndRunTests(suite, scenario, cmd.ScenarioArgs, cmd.Watch) } diff --git a/storm/internal/reporter/reporter.go b/storm/internal/reporter/reporter.go index 44444646e..f7923e9d0 100644 --- a/storm/internal/reporter/reporter.go +++ b/storm/internal/reporter/reporter.go @@ -1,7 +1,6 @@ package reporter import ( - "bytes" "fmt" "storm/internal/devops" "storm/internal/stormerror" @@ -168,7 +167,7 @@ func (tr *TestReporter) printFailureReport() { fmt.Printf("Stack trace:\n%s\n", err.Stack) } - logLines := getLogLinesFromTestCase(testCase) + logLines := testCase.CollectedOutput() // Check if there are any log lines if len(logLines) == 0 { @@ -203,16 +202,3 @@ func (tr *TestReporter) printFailureReport() { } } } - -func getLogLinesFromTestCase(testCase *testmgr.TestCase) []string { - lines := make([]string, 0) - rawLines := testCase.Buffer().Bytes() - for _, line := range bytes.Split(rawLines, []byte("\n")) { - if len(line) == 0 { - continue - } - lines = append(lines, string(line)) - } - - return lines -} diff --git a/storm/internal/runner/runner.go b/storm/internal/runner/runner.go index bbf1aa50b..895c3486a 100644 --- a/storm/internal/runner/runner.go +++ b/storm/internal/runner/runner.go @@ -1,7 +1,10 @@ package runner import ( + "bufio" "fmt" + "io" + "os" "runtime/debug" "slices" "storm/internal/reporter" @@ -9,6 +12,8 @@ import ( "storm/internal/testmgr" "storm/pkg/storm/core" "sync" + + "github.com/sirupsen/logrus" ) func RegisterAndRunTests(suite core.SuiteContext, @@ -17,6 +22,7 @@ func RegisterAndRunTests(suite core.SuiteContext, core.TestRegistrant }, args []string, + watch bool, ) error { // Create a new runnable instance registrantInstance := &runnableInstance{ @@ -37,7 +43,7 @@ func RegisterAndRunTests(suite core.SuiteContext, } // Actually run the thing - err = executeTestCases(suite, registrantInstance, testMgr) + err = executeTestCases(suite, registrantInstance, testMgr, watch) if err != nil { switch err.(type) { @@ -62,11 +68,9 @@ func RegisterAndRunTests(suite core.SuiteContext, } func executeTestCases(suite core.SuiteContext, - runnable interface { - core.TestRegistrantMetadata - core.TestRegistrant - }, + runnable *runnableInstance, testManager *testmgr.StormTestManager, + watch bool, ) error { ctx := &runnableContext{ @@ -76,7 +80,7 @@ func executeTestCases(suite core.SuiteContext, // If the runnable implements the SetupCleanup interface, we call // the setup method before running the tests. - if r, ok := runnable.(core.SetupCleanup); ok { + if r, ok := runnable.TestRegistrant.(core.SetupCleanup); ok { err := runCatchPanic(func() error { return r.Setup(ctx) }) if err != nil { return newSetupError(runnable, err) @@ -90,8 +94,24 @@ func executeTestCases(suite core.SuiteContext, for _, testCase := range testManager.TestCases() { if !bail { suite.Logger().Infof("%s (started)", testCase.Name()) + // Run the test case. - executeTestCase(testCase) + captured, err := captureOutput(func() { + executeTestCase(testCase) + }, func(w io.Writer, s string) { + if suite.AzureDevops() || watch { + fmt.Fprintf(w, " ├ %s\n", s) + } + }) + + // Store the captured output in the test case. + testCase.SetCollectedOutput(captured) + + // If we failed to collect the output, return an error. This means + // that we didn't even run. + if err != nil { + return fmt.Errorf("failed to capture output for '%s': %w", testCase.Name(), err) + } // Grab and store the cleanup functions for this test case. cleanupFuncs = append(cleanupFuncs, testCase.SuiteCleanupList()...) @@ -116,7 +136,7 @@ func executeTestCases(suite core.SuiteContext, // If the runnable implements the SetupCleanup interface, we call // the Cleanup method after running the tests. - if r, ok := runnable.(core.SetupCleanup); ok { + if r, ok := runnable.TestRegistrant.(core.SetupCleanup); ok { err := runCatchPanic(func() error { return r.Cleanup(ctx) }) if err != nil { return newCleanupError(runnable, err) @@ -135,6 +155,7 @@ func executeTestCase(testCase *testmgr.TestCase) { wg.Add(1) go func() { defer wg.Done() + err = runCatchPanic(func() error { return testCase.Execute() }) @@ -160,3 +181,76 @@ func runCatchPanic(f func() error) (err error) { return f() } + +func captureOutput(f func(), forward func(io.Writer, string)) ([]string, error) { + oldStdout := os.Stdout + oldStderr := os.Stderr + + rOut, wOut, err := os.Pipe() + if err != nil { + return nil, fmt.Errorf("failed to create stdout capture pipe: %w", err) + } + + rErr, wErr, err := os.Pipe() + if err != nil { + return nil, fmt.Errorf("failed to create stderr capture pipe: %w", err) + } + + os.Stdout = wOut + os.Stderr = wErr + + logrusOutput := logrus.StandardLogger().Out + logrusFormatter := logrus.StandardLogger().Formatter + logrusLevel := logrus.StandardLogger().Level + + // Logrust's standard logger is created on startup and stores a reference to + // the real stderr then, so our clever redirection does not work. To enable it, we + // need to set the output of the logger to our pipe as well. + logrus.SetOutput(os.Stderr) + + // Trick logrus into treating our pipe as the real stderr and force it to TRACE level. + logrus.SetFormatter(&logrus.TextFormatter{ + ForceColors: true, + }) + logrus.SetLevel(logrus.TraceLevel) + + defer func() { + os.Stdout = oldStdout + os.Stderr = oldStderr + + // Restore the original logrus configuration + logrus.SetOutput(logrusOutput) + logrus.SetFormatter(logrusFormatter) + logrus.SetLevel(logrusLevel) + }() + + var combinedOutput []string + var outMutex sync.Mutex + var wg sync.WaitGroup + + var streamReader = func(r io.Reader, w io.Writer) { + defer wg.Done() + scanner := bufio.NewScanner(r) + for scanner.Scan() { + line := scanner.Text() + outMutex.Lock() + combinedOutput = append(combinedOutput, line) + outMutex.Unlock() + forward(w, line) + } + } + + wg.Add(2) + + go streamReader(rOut, oldStdout) + go streamReader(rErr, oldStderr) + + f() + + wOut.Close() + wErr.Close() + + wg.Wait() + + return combinedOutput, nil +} diff --git a/storm/internal/suite/context.go b/storm/internal/suite/context.go deleted file mode 100644 index 2a081c4e2..000000000 --- a/storm/internal/suite/context.go +++ /dev/null @@ -1,26 +0,0 @@ -package suite - -import ( - "storm/pkg/storm" - "storm/pkg/storm/core" -) - -type SuiteContext interface { - core.Named - - core.LoggerProvider - - // Returns a list of all scenarios - Scenarios() []storm.Scenario - - // Returns a scenario by name, will exit with an error if the scenario is - // not found. - Scenario(name string) storm.Scenario - - // Returns a list of all helpers - Helpers() []storm.Helper - - // Returns a helper by name, will exit with an error if the helper is - // not found. - Helper(name string) storm.Helper -} diff --git a/storm/internal/testmgr/status.go b/storm/internal/testmgr/status.go index 9d14dba1b..e470bc85e 100644 --- a/storm/internal/testmgr/status.go +++ b/storm/internal/testmgr/status.go @@ -45,6 +45,7 @@ func (tcs TestCaseStatus) String() string { } func (tcs TestCaseStatus) ColorString() string { + color.NoColor = false // Force colors switch tcs { case TestCaseStatusPassed: return color.GreenString(tcs.String()) diff --git a/storm/internal/testmgr/testcase.go b/storm/internal/testmgr/testcase.go index 109fe0b53..ef782ce63 100644 --- a/storm/internal/testmgr/testcase.go +++ b/storm/internal/testmgr/testcase.go @@ -1,27 +1,23 @@ package testmgr import ( - "bytes" "fmt" "runtime" "storm/pkg/storm/core" "time" - - "github.com/sirupsen/logrus" ) type TestCase struct { - registrant core.TestRegistrantMetadata - name string - startTime time.Time - endTime time.Time - status TestCaseStatus - reason string - err error - log *logrus.Logger - logBuffer bytes.Buffer - f core.TestCaseFunction - suiteCleanup []func() + registrant core.TestRegistrantMetadata + name string + startTime time.Time + endTime time.Time + status TestCaseStatus + reason string + err error + collectedOutput []string + f core.TestCaseFunction + suiteCleanup []func() } func newTestCase(name string, f core.TestCaseFunction) *TestCase { @@ -29,16 +25,8 @@ func newTestCase(name string, f core.TestCaseFunction) *TestCase { name: name, f: f, status: TestCaseStatusPending, - log: logrus.New(), } - tc.log.SetLevel(logrus.TraceLevel) - tc.log.SetOutput(&tc.logBuffer) - tc.log.SetFormatter(&logrus.TextFormatter{ - ForceColors: true, - DisableTimestamp: false, - }) - return tc } @@ -93,9 +81,9 @@ func (t *TestCase) IsBailCondition() bool { return t.status.IsBad() } -// Returns the log buffer of the test case. -func (t *TestCase) Buffer() *bytes.Buffer { - return &t.logBuffer +// Returns the collected output of the test case. +func (t *TestCase) CollectedOutput() []string { + return t.collectedOutput } // Returns the reason for the test case closure. @@ -114,6 +102,10 @@ func (t *TestCase) MarkNotRun(reason string) { t.close(TestCaseStatusNotRun, reason, nil) } +func (t *TestCase) SetCollectedOutput(val []string) { + t.collectedOutput = val +} + // Mark a test as errored. This is used when the test case panics or returns an // error. func (t *TestCase) MarkError(err error) { @@ -171,11 +163,6 @@ func (t *TestCase) Skip(reason string) { runtime.Goexit() } -// Logger implements core.TestCase. -func (t *TestCase) Logger() *logrus.Logger { - return t.log -} - // Name implements core.TestCase. func (t *TestCase) Name() string { return t.name diff --git a/storm/pkg/storm/core/testcases.go b/storm/pkg/storm/core/testcases.go index af0c29f2f..bd7b83b48 100644 --- a/storm/pkg/storm/core/testcases.go +++ b/storm/pkg/storm/core/testcases.go @@ -5,8 +5,6 @@ import "time" type TestCase interface { Named - LoggerProvider - // Returns information about the registrant that created this test case. Registrant() TestRegistrantMetadata diff --git a/storm/pkg/storm/suite/suite.go b/storm/pkg/storm/suite/suite.go index d447900cc..d70cbb188 100644 --- a/storm/pkg/storm/suite/suite.go +++ b/storm/pkg/storm/suite/suite.go @@ -2,6 +2,7 @@ package suite import ( "fmt" + "os" "slices" "storm/internal/cli" @@ -24,12 +25,19 @@ type StormSuite struct { func CreateSuite(name string) StormSuite { name = fmt.Sprintf("storm-%s", name) ctx, global := cli.ParseCommandLine(name) + logger := logrus.New() logger.SetLevel(global.Verbosity) logger.SetFormatter(&logrus.TextFormatter{ ForceColors: true, }) + // Create a copy of stdErr and set it as the output for the logger. This + // means that regardless of any changes to os.Stderr we will still log + // correctly. + stdErrCopy := os.Stderr + logger.SetOutput(stdErrCopy) + logger.Infof("Creating suite '%s'", name) return StormSuite{ diff --git a/storm/pkg/storm/init.go b/storm/storm.go similarity index 100% rename from storm/pkg/storm/init.go rename to storm/storm.go diff --git a/storm/suites/helloworld/helloworld.go b/storm/suites/helloworld/helloworld.go deleted file mode 100644 index 152022b58..000000000 --- a/storm/suites/helloworld/helloworld.go +++ /dev/null @@ -1,27 +0,0 @@ -// Package helloworld implements a simple hello world scenario and helper. -package helloworld - -import ( - "storm/pkg/storm" - "storm/pkg/storm/core" -) - -type HelloWorldScenario struct { - storm.BaseScenario -} - -func (s *HelloWorldScenario) Name() string { - return "hello-world" -} - -func (h *HelloWorldScenario) RegisterTestCases(r storm.TestRegistrar) error { - r.RegisterTestCase("myPassingTestCase", func(tc core.TestCase) error { - tc.Logger().Info("This message will be logged in the test case!") - - // Do something here! - // ... - - return nil - }) - return nil -} diff --git a/sysdefs/src/arch.rs b/sysdefs/src/arch.rs index b5579891f..4cfc1131b 100644 --- a/sysdefs/src/arch.rs +++ b/sysdefs/src/arch.rs @@ -44,3 +44,15 @@ impl<'de> Deserialize<'de> for SystemArchitecture { }) } } + +/// System architecture +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +pub enum PackageArchitecture { + /// NoArch + #[serde(alias = "noarch")] + #[serde(alias = "(none)")] + NoArch, + + #[serde(untagged)] + Specific(SystemArchitecture), +} diff --git a/sysdefs/src/tpm2.rs b/sysdefs/src/tpm2.rs index feea2786c..220f3c8f5 100644 --- a/sysdefs/src/tpm2.rs +++ b/sysdefs/src/tpm2.rs @@ -1,6 +1,13 @@ +use anyhow::{bail, Error}; use enumflags2::bitflags; +use serde::{self, Deserialize}; -/// Represents the Platform Configuration Registers (PCRs) in the TPM. +/// Represents the Platform Configuration Registers (PCRs) in the TPM. Each PCR is associated with +/// a digit number and a string name. +/// +/// Currently, the PCRs modified by `systemd`` are represented, e.g. as shown in the +/// `systemd-cryptenroll` documentation, but more might be added in the future: +/// https://www.man7.org/linux/man-pages/man1/systemd-cryptenroll.1.html. #[bitflags] #[repr(u32)] #[derive(Copy, Clone, Debug, PartialEq, Eq)] @@ -40,10 +47,44 @@ pub enum Pcr { } impl Pcr { - /// Returns the digit value of the PCR. - pub fn to_value(&self) -> u32 { + /// Returns the digit representation of the PCR number. + pub fn to_num(&self) -> u32 { (*self as u32).trailing_zeros() } + + /// Returns the PCR for the given digit number. Needed for deserialization. + pub fn from_num(num: u32) -> Result { + match num { + 0 => Ok(Pcr::Pcr0), + 1 => Ok(Pcr::Pcr1), + 2 => Ok(Pcr::Pcr2), + 3 => Ok(Pcr::Pcr3), + 4 => Ok(Pcr::Pcr4), + 5 => Ok(Pcr::Pcr5), + 7 => Ok(Pcr::Pcr7), + 9 => Ok(Pcr::Pcr9), + 10 => Ok(Pcr::Pcr10), + 11 => Ok(Pcr::Pcr11), + 12 => Ok(Pcr::Pcr12), + 13 => Ok(Pcr::Pcr13), + 14 => Ok(Pcr::Pcr14), + 15 => Ok(Pcr::Pcr15), + 16 => Ok(Pcr::Pcr16), + 23 => Ok(Pcr::Pcr23), + _ => bail!("Failed to convert an invalid PCR number '{}' to a Pcr", num), + } + } +} + +impl<'de> Deserialize<'de> for Pcr { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let val = u32::deserialize(deserializer)?; + Pcr::from_num(val) + .map_err(|_| serde::de::Error::custom(format!("Failed to deserialize PCR: {}", val))) + } } #[cfg(test)] @@ -51,22 +92,37 @@ mod tests { use super::*; #[test] - fn test_to_value() { - assert_eq!(Pcr::Pcr0.to_value(), 0); - assert_eq!(Pcr::Pcr1.to_value(), 1); - assert_eq!(Pcr::Pcr2.to_value(), 2); - assert_eq!(Pcr::Pcr3.to_value(), 3); - assert_eq!(Pcr::Pcr4.to_value(), 4); - assert_eq!(Pcr::Pcr5.to_value(), 5); - assert_eq!(Pcr::Pcr7.to_value(), 7); - assert_eq!(Pcr::Pcr9.to_value(), 9); - assert_eq!(Pcr::Pcr10.to_value(), 10); - assert_eq!(Pcr::Pcr11.to_value(), 11); - assert_eq!(Pcr::Pcr12.to_value(), 12); - assert_eq!(Pcr::Pcr13.to_value(), 13); - assert_eq!(Pcr::Pcr14.to_value(), 14); - assert_eq!(Pcr::Pcr15.to_value(), 15); - assert_eq!(Pcr::Pcr16.to_value(), 16); - assert_eq!(Pcr::Pcr23.to_value(), 23); + fn test_to_num() { + assert_eq!(Pcr::Pcr0.to_num(), 0); + assert_eq!(Pcr::Pcr1.to_num(), 1); + assert_eq!(Pcr::Pcr2.to_num(), 2); + assert_eq!(Pcr::Pcr3.to_num(), 3); + assert_eq!(Pcr::Pcr4.to_num(), 4); + assert_eq!(Pcr::Pcr5.to_num(), 5); + assert_eq!(Pcr::Pcr7.to_num(), 7); + assert_eq!(Pcr::Pcr9.to_num(), 9); + assert_eq!(Pcr::Pcr10.to_num(), 10); + assert_eq!(Pcr::Pcr11.to_num(), 11); + assert_eq!(Pcr::Pcr12.to_num(), 12); + assert_eq!(Pcr::Pcr13.to_num(), 13); + assert_eq!(Pcr::Pcr14.to_num(), 14); + assert_eq!(Pcr::Pcr15.to_num(), 15); + assert_eq!(Pcr::Pcr16.to_num(), 16); + assert_eq!(Pcr::Pcr23.to_num(), 23); + } + + #[test] + fn test_from_num() { + // Test case #0: Convert a valid value to a PCR. + assert_eq!(Pcr::from_num(0).unwrap(), Pcr::Pcr0); + assert_eq!(Pcr::from_num(1).unwrap(), Pcr::Pcr1); + assert_eq!(Pcr::from_num(2).unwrap(), Pcr::Pcr2); + assert_eq!(Pcr::from_num(23).unwrap(), Pcr::Pcr23); + + // Test case #1: Convert an invalid value to a PCR. + assert_eq!( + Pcr::from_num(31).unwrap_err().root_cause().to_string(), + "Failed to convert an invalid PCR number '31' to a Pcr" + ); } } diff --git a/systemd/trident.service b/systemd/trident.service index c4f1216d4..7797523ae 100644 --- a/systemd/trident.service +++ b/systemd/trident.service @@ -7,4 +7,4 @@ ExecStart=trident commit Type=oneshot [Install] -WantedBy=multi-user.target \ No newline at end of file +WantedBy=multi-user.target diff --git a/tools/cmd/miniproxy/main.go b/tools/cmd/miniproxy/main.go index 1ffa7ab1a..1adf106e4 100644 --- a/tools/cmd/miniproxy/main.go +++ b/tools/cmd/miniproxy/main.go @@ -8,7 +8,9 @@ import ( "net" "os" "strconv" + "sync" + "github.com/dustin/go-humanize" log "github.com/sirupsen/logrus" "github.com/spf13/cobra" ) @@ -29,8 +31,8 @@ func init() { rootCmd.PersistentFlags().Uint16VarP(&listen_port, "listen-port", "l", 0, "Port to listen on.") rootCmd.PersistentFlags().Uint16VarP(&dest_port, "forward-port", "f", 0, "Port to forward to.") - rootCmd.MarkFlagRequired("src-port") - rootCmd.MarkFlagRequired("dst-port") + rootCmd.MarkFlagRequired("listen-port") + rootCmd.MarkFlagRequired("forward-port") log.SetLevel(log.DebugLevel) } @@ -60,35 +62,36 @@ func run(cmd *cobra.Command, args []string) { } } -func handleConnection(source net.Conn, id uint64) { - log.WithField("id", id).WithField("address", source.RemoteAddr().String()).Info("Accepted connection") - defer source.Close() - dest, err := net.Dial("tcp", "localhost:"+strconv.Itoa(int(dest_port))) +func handleConnection(client net.Conn, id uint64) { + log.WithField("id", id).WithField("address", client.RemoteAddr().String()).Info("Accepted connection") + defer client.Close() + server, err := net.Dial("tcp", "localhost:"+strconv.Itoa(int(dest_port))) if err != nil { log.WithField("id", id).WithError(err).Fatalf("Failed to connect to destination") } - defer dest.Close() + defer server.Close() - log.WithField("id", id).WithField("address", dest.RemoteAddr().String()).Info("Connected to destination") + log.WithField("id", id).WithField("address", server.RemoteAddr().String()).Info("Connected to destination") - done := make(chan bool) + var wg sync.WaitGroup + + wg.Add(2) go func() { - defer source.Close() - defer dest.Close() - copied, _ := io.Copy(dest, source) - log.WithField("id", id).WithField("bytes", copied).Info("Done copying from src to dst") - done <- true + defer wg.Done() + defer client.Close() + defer server.Close() + copied, _ := io.Copy(server, client) + log.WithField("id", id).Infof("Done copying %s from client to server", humanize.IBytes(uint64(copied))) }() go func() { - defer source.Close() - defer dest.Close() - copied, _ := io.Copy(source, dest) - log.WithField("id", id).WithField("bytes", copied).Info("Done copying from dst to src") - done <- true + defer wg.Done() + defer client.Close() + defer server.Close() + copied, _ := io.Copy(client, server) + log.WithField("id", id).Infof("Done copying %s from server to client", humanize.IBytes(uint64(copied))) }() - <-done - <-done + wg.Wait() log.WithField("id", id).Info("Connection closed") } diff --git a/tools/cmd/mkcosi/builder/builder.go b/tools/cmd/mkcosi/builder/builder.go index 9263c7b04..f51d2a4db 100644 --- a/tools/cmd/mkcosi/builder/builder.go +++ b/tools/cmd/mkcosi/builder/builder.go @@ -2,13 +2,14 @@ package builder import ( "archive/tar" - "argus_toolkit/cmd/mkcosi/metadata" "bytes" "encoding/json" "fmt" "io" "os" + "tridenttools/cmd/mkcosi/metadata" + log "github.com/sirupsen/logrus" ) diff --git a/tools/cmd/mkcosi/builder/common.go b/tools/cmd/mkcosi/builder/common.go index 35b342842..846e87be5 100644 --- a/tools/cmd/mkcosi/builder/common.go +++ b/tools/cmd/mkcosi/builder/common.go @@ -1,8 +1,6 @@ package builder import ( - "argus_toolkit/cmd/mkcosi/metadata" - "argus_toolkit/pkg/ref" "crypto/sha512" _ "embed" "encoding/json" @@ -13,6 +11,8 @@ import ( "os/exec" "path" "strings" + "tridenttools/cmd/mkcosi/metadata" + "tridenttools/pkg/ref" "github.com/google/uuid" "github.com/klauspost/compress/zstd" diff --git a/tools/cmd/mkcosi/builder/regular.go b/tools/cmd/mkcosi/builder/regular.go index 295b22c56..a9980c634 100644 --- a/tools/cmd/mkcosi/builder/regular.go +++ b/tools/cmd/mkcosi/builder/regular.go @@ -1,8 +1,8 @@ package builder import ( - "argus_toolkit/cmd/mkcosi/metadata" - "argus_toolkit/pkg/ref" + "tridenttools/cmd/mkcosi/metadata" + "tridenttools/pkg/ref" ) type BuildRegular struct { diff --git a/tools/cmd/mkcosi/builder/verity.go b/tools/cmd/mkcosi/builder/verity.go index 190b8d4ac..9f8f5d6c2 100644 --- a/tools/cmd/mkcosi/builder/verity.go +++ b/tools/cmd/mkcosi/builder/verity.go @@ -1,8 +1,8 @@ package builder import ( - "argus_toolkit/cmd/mkcosi/metadata" - "argus_toolkit/pkg/ref" + "tridenttools/cmd/mkcosi/metadata" + "tridenttools/pkg/ref" ) type BuildVerity struct { diff --git a/tools/cmd/mkcosi/cmd/random_uuid.go b/tools/cmd/mkcosi/cmd/random_uuid.go index 89fff4371..aa78cab96 100644 --- a/tools/cmd/mkcosi/cmd/random_uuid.go +++ b/tools/cmd/mkcosi/cmd/random_uuid.go @@ -1,15 +1,15 @@ package cmd import ( - "argus_toolkit/cmd/mkcosi/builder" - "argus_toolkit/cmd/mkcosi/cosi" - "argus_toolkit/cmd/mkcosi/metadata" "fmt" "io" "os" "os/exec" "slices" "strings" + "tridenttools/cmd/mkcosi/builder" + "tridenttools/cmd/mkcosi/cosi" + "tridenttools/cmd/mkcosi/metadata" "github.com/google/uuid" "github.com/klauspost/compress/zstd" diff --git a/tools/cmd/mkcosi/cmd/read_metadata.go b/tools/cmd/mkcosi/cmd/read_metadata.go index 77e545057..90051bb84 100644 --- a/tools/cmd/mkcosi/cmd/read_metadata.go +++ b/tools/cmd/mkcosi/cmd/read_metadata.go @@ -1,9 +1,9 @@ package cmd import ( - "argus_toolkit/cmd/mkcosi/cosi" "encoding/json" "fmt" + "tridenttools/cmd/mkcosi/cosi" log "github.com/sirupsen/logrus" ) diff --git a/tools/cmd/mkcosi/cosi/cosi.go b/tools/cmd/mkcosi/cosi/cosi.go index c8a6b69f9..f36860eca 100644 --- a/tools/cmd/mkcosi/cosi/cosi.go +++ b/tools/cmd/mkcosi/cosi/cosi.go @@ -1,11 +1,11 @@ package cosi import ( - "argus_toolkit/cmd/mkcosi/metadata" "fmt" "io" "os" "path/filepath" + "tridenttools/cmd/mkcosi/metadata" log "github.com/sirupsen/logrus" ) diff --git a/tools/cmd/mkcosi/cosi/scan.go b/tools/cmd/mkcosi/cosi/scan.go index 3d80245f4..a998f4ef0 100644 --- a/tools/cmd/mkcosi/cosi/scan.go +++ b/tools/cmd/mkcosi/cosi/scan.go @@ -2,11 +2,11 @@ package cosi import ( "archive/tar" - "argus_toolkit/cmd/mkcosi/metadata" "encoding/json" "fmt" "io" "os" + "tridenttools/cmd/mkcosi/metadata" log "github.com/sirupsen/logrus" "golang.org/x/exp/slices" diff --git a/tools/cmd/mkcosi/main.go b/tools/cmd/mkcosi/main.go index 59d5c8a73..d9845e44c 100644 --- a/tools/cmd/mkcosi/main.go +++ b/tools/cmd/mkcosi/main.go @@ -1,8 +1,8 @@ package main import ( - "argus_toolkit/cmd/mkcosi/builder" - "argus_toolkit/cmd/mkcosi/cmd" + "tridenttools/cmd/mkcosi/builder" + "tridenttools/cmd/mkcosi/cmd" "github.com/alecthomas/kong" log "github.com/sirupsen/logrus" diff --git a/tools/cmd/netlaunch/main.go b/tools/cmd/netlaunch/main.go index 9b2c10ee5..178d61365 100644 --- a/tools/cmd/netlaunch/main.go +++ b/tools/cmd/netlaunch/main.go @@ -4,10 +4,11 @@ Copyright Š 2023 Microsoft Corporation package main import ( - "argus_toolkit/pkg/netfinder" - "argus_toolkit/pkg/phonehome" - "argus_toolkit/pkg/serial" "sync" + "tridenttools/pkg/netfinder" + "tridenttools/pkg/phonehome" + "tridenttools/pkg/serial" + "tridenttools/storm/utils" "bytes" "context" @@ -27,6 +28,7 @@ import ( "gopkg.in/yaml.v2" bmclib "github.com/bmc-toolbox/bmclib/v2" + "github.com/google/uuid" ) // `MagicString` is used to locate placeholder files in the initrd. Each placeholder file will be @@ -41,7 +43,7 @@ type NetLaunchConfig struct { Netlaunch struct { AnnounceIp *string AnnouncePort *uint16 - Bmc struct { + Bmc *struct { Ip string Port *string Username string @@ -52,6 +54,7 @@ type NetLaunchConfig struct { Output string } } + LocalVmUuid *string } Iso struct { PreTridentScript *string @@ -288,65 +291,69 @@ var rootCmd = &cobra.Command{ // Start the HTTP server go server.Serve(listen) log.WithField("address", listen.Addr().String()).Info("Listening...") + iso_location := fmt.Sprintf("http://%s/provision.iso", announceAddress) - if config.Netlaunch.Bmc.SerialOverSsh != nil { - serial, err := serial.NewSerialOverSshSession(serial.SerialOverSSHSettings{ - Host: config.Netlaunch.Bmc.Ip, - Port: config.Netlaunch.Bmc.SerialOverSsh.SshPort, - Username: config.Netlaunch.Bmc.Username, - Password: config.Netlaunch.Bmc.Password, - ComPort: config.Netlaunch.Bmc.SerialOverSsh.ComPort, - Output: config.Netlaunch.Bmc.SerialOverSsh.Output, - }) - if err != nil { - log.WithError(err).Fatalf("Failed to open serial over SSH session") + if config.Netlaunch.LocalVmUuid != nil { + startLocalVm(*config.Netlaunch.LocalVmUuid, iso_location) + } else { + if config.Netlaunch.Bmc.SerialOverSsh != nil { + serial, err := serial.NewSerialOverSshSession(serial.SerialOverSSHSettings{ + Host: config.Netlaunch.Bmc.Ip, + Port: config.Netlaunch.Bmc.SerialOverSsh.SshPort, + Username: config.Netlaunch.Bmc.Username, + Password: config.Netlaunch.Bmc.Password, + ComPort: config.Netlaunch.Bmc.SerialOverSsh.ComPort, + Output: config.Netlaunch.Bmc.SerialOverSsh.Output, + }) + if err != nil { + log.WithError(err).Fatalf("Failed to open serial over SSH session") + } + defer serial.Close() } - defer serial.Close() - } - // Deploy ISO to BMC + // Deploy ISO to BMC - // Default to port 443 - port := "443" - if config.Netlaunch.Bmc.Port != nil { - port = *config.Netlaunch.Bmc.Port - } + // Default to port 443 + port := "443" + if config.Netlaunch.Bmc.Port != nil { + port = *config.Netlaunch.Bmc.Port + } - client := bmclib.NewClient( - config.Netlaunch.Bmc.Ip, - config.Netlaunch.Bmc.Username, - config.Netlaunch.Bmc.Password, - bmclib.WithRedfishPort(port), - ) + client := bmclib.NewClient( + config.Netlaunch.Bmc.Ip, + config.Netlaunch.Bmc.Username, + config.Netlaunch.Bmc.Password, + bmclib.WithRedfishPort(port), + ) - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) - defer cancel() + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() - log.Info("Connecting to BMC") - client.Registry.Drivers = client.Registry.For("gofish") - if err := client.Open(context.Background()); err != nil { - log.WithError(err).Fatalf("failed to open connection to BMC") - } + log.Info("Connecting to BMC") + client.Registry.Drivers = client.Registry.For("gofish") + if err := client.Open(context.Background()); err != nil { + log.WithError(err).Fatalf("failed to open connection to BMC") + } - log.Info("Shutting down machine") - if _, err = client.SetPowerState(ctx, "off"); err != nil { - log.WithError(err).Fatalf("failed to turn off machine") - } + log.Info("Shutting down machine") + if _, err = client.SetPowerState(ctx, "off"); err != nil { + log.WithError(err).Fatalf("failed to turn off machine") + } - iso_location := fmt.Sprintf("http://%s/provision.iso", announceAddress) - log.WithField("url", iso_location).Info("Setting virtual media to ISO") - if _, err = client.SetVirtualMedia(ctx, string(redfish.CDMediaType), iso_location); err != nil { - log.WithError(err).Fatalf("failed to set virtual media") - } + log.WithField("url", iso_location).Info("Setting virtual media to ISO") + if _, err = client.SetVirtualMedia(ctx, string(redfish.CDMediaType), iso_location); err != nil { + log.WithError(err).Fatalf("failed to set virtual media") + } - log.Info("Setting boot media") - if _, err = client.SetBootDevice(ctx, "cdrom", false, true); err != nil { - log.WithError(err).Fatalf("failed to set boot media") - } + log.Info("Setting boot media") + if _, err = client.SetBootDevice(ctx, "cdrom", false, true); err != nil { + log.WithError(err).Fatalf("failed to set boot media") + } - log.Info("Turning on machine") - if _, err = client.SetPowerState(ctx, "on"); err != nil { - log.WithError(err).Fatalf("failed to turn on machine") + log.Info("Turning on machine") + if _, err = client.SetPowerState(ctx, "on"); err != nil { + log.WithError(err).Fatalf("failed to turn on machine") + } } log.Info("ISO deployed!") @@ -367,6 +374,30 @@ var rootCmd = &cobra.Command{ }, } +func startLocalVm(localVmUuidStr string, isoLocation string) { + log.Info("Using local VM") + + // TODO: Parse the UUID directly when reading the config file + vmUuid, err := uuid.Parse(localVmUuidStr) + if err != nil { + log.WithError(err).Fatalf("failed to parse LocalVmUuid as UUID") + } + + vm, err := utils.InitializeVm(vmUuid) + if err != nil { + log.WithError(err).Fatalf("failed to initialize VM") + } + defer vm.Disconnect() + + if err = vm.SetVmHttpBootUri(isoLocation); err != nil { + log.WithError(err).Fatalf("failed to set VM HTTP boot URI") + } + + if err = vm.Start(); err != nil { + log.WithError(err).Fatalf("failed to start VM") + } +} + func init() { rootCmd.PersistentFlags().StringVarP(&netlaunchConfigFile, "config", "c", "netlaunch.yaml", "Netlaunch config file") rootCmd.PersistentFlags().StringVarP(&tridentConfigFile, "trident", "t", "", "Trident local config file") diff --git a/tools/cmd/netlisten/main.go b/tools/cmd/netlisten/main.go index c32e6c07a..ff358570f 100644 --- a/tools/cmd/netlisten/main.go +++ b/tools/cmd/netlisten/main.go @@ -24,11 +24,11 @@ Then start the provisioning using the patched Trident config file. package main import ( - "argus_toolkit/pkg/phonehome" "fmt" "net" "os/signal" "syscall" + "tridenttools/pkg/phonehome" "context" "net/http" diff --git a/tools/cmd/storm-trident/main.go b/tools/cmd/storm-trident/main.go new file mode 100644 index 000000000..5c8d4774a --- /dev/null +++ b/tools/cmd/storm-trident/main.go @@ -0,0 +1,27 @@ +package main + +import ( + "storm" + "tridenttools/storm/helpers" + "tridenttools/storm/servicing" +) + +func main() { + storm := storm.CreateSuite("trident") + + // Add Trident E2E scenarios (disabled for now) + // scenarios := trident.DiscoverTridentScenarios(storm.Log) + // for _, scenario := range scenarios { + // storm.AddScenario(&scenario) + // } + + // Add Trident servicing scenario + storm.AddScenario(&servicing.TridentServicingScenario{}) + + // Register Trident helpers + for _, helper := range helpers.TRIDENT_HELPERS { + storm.AddHelper(helper) + } + + storm.Run() +} diff --git a/tools/go.mod b/tools/go.mod index c40ae630a..0de70f2ef 100644 --- a/tools/go.mod +++ b/tools/go.mod @@ -1,26 +1,40 @@ -module argus_toolkit +module tridenttools -go 1.23.0 +go 1.23.5 toolchain go1.24.2 +replace storm => ../storm + +// Deal with CVE-2024-45338, CVE-2025-22870, CVE-2025-22872 +replace golang.org/x/net => golang.org/x/net v0.39.0 + require ( + github.com/dustin/go-humanize v1.0.1 github.com/bmc-toolbox/bmclib/v2 v2.0.1-0.20230530141715-da28e42c453f - github.com/fatih/color v1.17.0 + github.com/fatih/color v1.18.0 github.com/google/uuid v1.6.0 github.com/pkg/errors v0.9.1 + github.com/pkg/sftp v1.13.9 github.com/sirupsen/logrus v1.9.3 github.com/spf13/cobra v1.8.1 github.com/spf13/viper v1.19.0 gopkg.in/yaml.v2 v2.4.0 + storm v1.0.0 ) -require github.com/vishvananda/netns v0.0.4 // indirect +require ( + github.com/digitalocean/go-libvirt v0.0.0-20250512231903-57024326652b // indirect + github.com/kr/fs v0.1.0 // indirect + github.com/vishvananda/netns v0.0.4 // indirect + golang.org/x/term v0.32.0 // indirect + libvirt.org/libvirt-go-xml v7.4.0+incompatible // indirect +) require ( github.com/VictorLowther/simplexml v0.0.0-20180716164440-0bff93621230 // indirect github.com/VictorLowther/soap v0.0.0-20150314151524-8e36fca84b22 // indirect - github.com/alecthomas/kong v1.2.1 + github.com/alecthomas/kong v1.8.1 github.com/bmc-toolbox/common v0.0.0-20240806132831-ba8adc6a35e3 // indirect github.com/fsnotify/fsnotify v1.7.0 // indirect github.com/go-logr/logr v1.4.2 // indirect @@ -47,11 +61,11 @@ require ( github.com/subosito/gotenv v1.6.0 // indirect github.com/vishvananda/netlink v1.3.0 go.uber.org/multierr v1.11.0 // indirect - golang.org/x/crypto v0.37.0 + golang.org/x/crypto v0.38.0 golang.org/x/exp v0.0.0-20240823005443-9b4947da3948 golang.org/x/net v0.39.0 // indirect - golang.org/x/sys v0.32.0 // indirect - golang.org/x/text v0.24.0 // indirect + golang.org/x/sys v0.33.0 // indirect + golang.org/x/text v0.25.0 // indirect gopkg.in/ini.v1 v1.67.0 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect + gopkg.in/yaml.v3 v3.0.1 ) diff --git a/tools/go.sum b/tools/go.sum index 1b9a25fb3..bad425d31 100644 --- a/tools/go.sum +++ b/tools/go.sum @@ -2,10 +2,10 @@ github.com/VictorLowther/simplexml v0.0.0-20180716164440-0bff93621230 h1:t95Grn2 github.com/VictorLowther/simplexml v0.0.0-20180716164440-0bff93621230/go.mod h1:t2EzW1qybnPDQ3LR/GgeF0GOzHUXT5IVMLP2gkW1cmc= github.com/VictorLowther/soap v0.0.0-20150314151524-8e36fca84b22 h1:a0MBqYm44o0NcthLKCljZHe1mxlN6oahCQHHThnSwB4= github.com/VictorLowther/soap v0.0.0-20150314151524-8e36fca84b22/go.mod h1:/B7V22rcz4860iDqstGvia/2+IYWXf3/JdQCVd/1D2A= -github.com/alecthomas/assert/v2 v2.10.0 h1:jjRCHsj6hBJhkmhznrCzoNpbA3zqy0fYiUcYZP/GkPY= -github.com/alecthomas/assert/v2 v2.10.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= -github.com/alecthomas/kong v1.2.1 h1:E8jH4Tsgv6wCRX2nGrdPyHDUCSG83WH2qE4XLACD33Q= -github.com/alecthomas/kong v1.2.1/go.mod h1:rKTSFhbdp3Ryefn8x5MOEprnRFQ7nlmMC01GKhehhBM= +github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= +github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= +github.com/alecthomas/kong v1.8.1 h1:6aamvWBE/REnR/BCq10EcozmcpUPc5aGI1lPAWdB0EE= +github.com/alecthomas/kong v1.8.1/go.mod h1:p2vqieVMeTAnaC83txKtXe8FLke2X07aruPWXyMPQrU= github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= github.com/bmc-toolbox/bmclib/v2 v2.0.1-0.20230530141715-da28e42c453f h1:5xXPluhAvpNCmrgVhQesx+GcpPX1pXRyDFtj3JmxT+g= @@ -19,8 +19,12 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/fatih/color v1.17.0 h1:GlRw1BRJxkpqUCBKzKOw098ed57fEsKeNjpTe3cSjK4= -github.com/fatih/color v1.17.0/go.mod h1:YZ7TlrGPkiz6ku9fK3TLD/pl3CpsiFyu8N92HLgmosI= +github.com/digitalocean/go-libvirt v0.0.0-20250512231903-57024326652b h1:o/RoLbHmKtibc3lMpuPcYGUjnboEORpLFnqtC89tfqY= +github.com/digitalocean/go-libvirt v0.0.0-20250512231903-57024326652b/go.mod h1:B2R8mtJc0BNx0NvvfOajL5no+MaFDumyD5sHsxll62g= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= +github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= @@ -48,6 +52,8 @@ github.com/jacobweinstock/registrar v0.4.7 h1:s4dOExccgD+Pc7rJC+f3Mc3D+NXHcXUaOi github.com/jacobweinstock/registrar v0.4.7/go.mod h1:PWmkdGFG5/ZdCqgMo7pvB3pXABOLHc5l8oQ0sgmBNDU= github.com/klauspost/compress v1.17.10 h1:oXAz+Vh0PMUvJczoi+flxpnBEPxoER1IaAnU/NMPtT0= github.com/klauspost/compress v1.17.10/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0= +github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8= +github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= @@ -65,6 +71,8 @@ github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNH github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/sftp v1.13.9 h1:4NGkvGudBL7GteO3m6qnaQ4pC0Kvf0onSVc9gR3EWBw= +github.com/pkg/sftp v1.13.9/go.mod h1:OBN7bVXdstkFFN/gdnHPUb5TE8eb8G1Rp9wCItqjkkA= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -94,7 +102,10 @@ github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+ github.com/stmcginnis/gofish v0.19.0 h1:fmxdRZ5WHfs+4ExArMYoeRfoh+SAxLELKtmoVplBkU4= github.com/stmcginnis/gofish v0.19.0/go.mod h1:lq2jHj2t8Krg0Gx02ABk8MbK7Dz9jvWpO/TGnVksn00= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= @@ -103,27 +114,68 @@ github.com/vishvananda/netlink v1.3.0 h1:X7l42GfcV4S6E4vHTsw48qbrV+9PVojNfIhZcwQ github.com/vishvananda/netlink v1.3.0/go.mod h1:i6NetklAujEcC6fK0JPjT8qSwWyO0HLn4UKG+hGqeJs= github.com/vishvananda/netns v0.0.4 h1:Oeaw1EM2JMxD51g9uhtC0D7erkIjgmj8+JZc26m1YX8= github.com/vishvananda/netns v0.0.4/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= go.uber.org/goleak v1.2.1 h1:NBol2c7O1ZokfZ0LEU9K6Whx/KnwvepVetCUhtKja4A= go.uber.org/goleak v1.2.1/go.mod h1:qlT2yGI9QafXHhZZLxlSuNsMw3FFLxBr+tBRlmO1xH4= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= +golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8= +golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw= golang.org/x/exp v0.0.0-20240823005443-9b4947da3948 h1:kx6Ds3MlpiUHKj7syVnbp57++8WpuKPcR5yjLBjvLEA= golang.org/x/exp v0.0.0-20240823005443-9b4947da3948/go.mod h1:akd2r19cwCdwSwWeIdzYQGa/EZZyqcOdwWiwj5L5eKQ= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY= golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= +golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o= golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw= +golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg= +golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= +golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= +golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= @@ -136,3 +188,5 @@ gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +libvirt.org/libvirt-go-xml v7.4.0+incompatible h1:NaCRjbtz//xuTZOp1nDHbe0eu5BQlhIy5PPuc09EWtU= +libvirt.org/libvirt-go-xml v7.4.0+incompatible/go.mod h1:FL+H1+hKNWDdkKQGGS4sGCZJ3pGWcjt6VbxZvPlQJkY= diff --git a/storm/suites/trident/e2e/configurations/.gitignore b/tools/storm/e2e/configurations/.gitignore similarity index 100% rename from storm/suites/trident/e2e/configurations/.gitignore rename to tools/storm/e2e/configurations/.gitignore diff --git a/storm/suites/trident/e2e/discover.go b/tools/storm/e2e/discover.go similarity index 98% rename from storm/suites/trident/e2e/discover.go rename to tools/storm/e2e/discover.go index 60bb6967c..78bbe927e 100644 --- a/storm/suites/trident/e2e/discover.go +++ b/tools/storm/e2e/discover.go @@ -7,7 +7,7 @@ import ( "gopkg.in/yaml.v3" ) -//go:generate cp -r ../../../../e2e_tests/trident_configurations configurations +//go:generate cp -r ../../../e2e_tests/trident_configurations configurations //go:embed configurations/* var content embed.FS diff --git a/storm/suites/trident/e2e/stages.go b/tools/storm/e2e/stages.go similarity index 100% rename from storm/suites/trident/e2e/stages.go rename to tools/storm/e2e/stages.go diff --git a/storm/suites/trident/e2e/trident.go b/tools/storm/e2e/trident.go similarity index 90% rename from storm/suites/trident/e2e/trident.go rename to tools/storm/e2e/trident.go index 1abaf7988..be4832596 100644 --- a/storm/suites/trident/e2e/trident.go +++ b/tools/storm/e2e/trident.go @@ -2,7 +2,10 @@ package trident import ( "fmt" - "storm/pkg/storm" + + "storm" + + "github.com/sirupsen/logrus" ) type TridentE2EScenario struct { @@ -56,8 +59,8 @@ func (s *TridentE2EScenario) RegisterTestCases(r storm.TestRegistrar) error { } func (s TridentE2EScenario) Run(tc storm.TestCase) error { - tc.Logger().Infof("Hello from '%s'!", s.Name()) - tc.Logger().Infof("Running stage '%s'", s.args.StagePath) + logrus.Infof("Hello from '%s'!", s.Name()) + logrus.Infof("Running stage '%s'", s.args.StagePath) fmt.Println(s.config) diff --git a/storm/suites/trident/helpers/ab_update.go b/tools/storm/helpers/ab_update.go similarity index 71% rename from storm/suites/trident/helpers/ab_update.go rename to tools/storm/helpers/ab_update.go index 044303573..c4f7ddd17 100644 --- a/storm/suites/trident/helpers/ab_update.go +++ b/tools/storm/helpers/ab_update.go @@ -2,22 +2,25 @@ package helpers import ( "fmt" + "net/http" "path" "regexp" - "storm/pkg/storm" - "storm/suites/trident/utils" "strings" "time" + "storm" + + "github.com/sirupsen/logrus" "golang.org/x/crypto/ssh" "gopkg.in/yaml.v3" + + "tridenttools/storm/utils" ) type AbUpdateHelper struct { args struct { utils.SshCliSettings `embed:""` utils.EnvCliSettings `embed:""` - DestinationDirectory string `short:"d" required:"" help:"Read-write directory on the host that contains the runtime OS images for the A/B update."` TridentConfig string `short:"c" required:"" help:"File name of the custom read-write Trident config on the host to point Trident to."` Version string `short:"v" required:"" help:"Version of the Trident image to use for the A/B update."` StageAbUpdate bool `short:"s" help:"Controls whether A/B update should be staged."` @@ -70,14 +73,14 @@ func (h *AbUpdateHelper) getHostConfig(tc storm.TestCase) error { return fmt.Errorf("failed to run trident to get host config: %w", err) } - tc.Logger().Debugf("Trident stdout:\n%s", out.Stdout) - tc.Logger().Debugf("Trident stderr:\n%s", out.Stderr) + logrus.Debugf("Trident stdout:\n%s", out.Stdout) + logrus.Debugf("Trident stderr:\n%s", out.Stderr) err = yaml.Unmarshal([]byte(out.Stdout), &h.config) if err != nil { return fmt.Errorf("failed to unmarshal YAML: %w", err) } - tc.Logger().Infof("Trident configuration: %v", h.config) + logrus.Infof("Trident configuration: %v", h.config) return nil } @@ -93,9 +96,21 @@ func (h *AbUpdateHelper) updateHostConfig(tc storm.TestCase) error { return fmt.Errorf("failed to get old image URL from configuration") } - tc.Logger().Infof("Old image URL: %s", oldUrl) + logrus.Infof("Old image URL: %s", oldUrl) + // Extract the base name of the image URL base := path.Base(oldUrl) + if base == "" { + return fmt.Errorf("failed to get base name from URL: %s", oldUrl) + } + + // Then extract everything but the base by removing it as a suffix + urlPath, ok := strings.CutSuffix(oldUrl, base) + if !ok { + return fmt.Errorf("failed to remove suffix '%s' from URL '%s'", base, oldUrl) + } + + logrus.Debugf("Base name: %s", base) matches := regexp.MustCompile(`^(.*?)(_v\d+)?\.(.+)$`).FindStringSubmatch(base) @@ -107,13 +122,20 @@ func (h *AbUpdateHelper) updateHostConfig(tc storm.TestCase) error { ext := matches[3] newCosiName := fmt.Sprintf("%s_v%s.%s", name, h.args.Version, ext) - newCosiPath := path.Join(h.args.DestinationDirectory, newCosiName) - tridentCosiPath := path.Join(h.args.Env.HostPath(), newCosiPath) - newUrl := fmt.Sprintf("file://%s", tridentCosiPath) - tc.Logger().Infof("New image URL: %s", newUrl) + newUrl := fmt.Sprintf("%s%s", urlPath, newCosiName) + logrus.Infof("New image URL: %s", newUrl) + + logrus.Infof("Checking if new image URL is accessible...") + err := checkUrlIsAccessible(newUrl) + if err != nil { + logrus.WithError(err).Errorf("New image URL is not accessible: %s (continuing)", newUrl) + } else { + logrus.Infof("New image URL is accessible") + } // Update the image URL in the configuration h.config["image"].(map[string]any)["url"] = newUrl + h.config["image"].(map[string]any)["sha384"] = "ignored" // Set the config to NOT self-upgrade trident, ok := h.config["trident"].(map[string]any) @@ -139,13 +161,6 @@ func (h *AbUpdateHelper) updateHostConfig(tc storm.TestCase) error { defer sftpClient.Close() // Ensure the cosi file exists - tc.Logger().Infof("Checking if new COSI file exists at %s", newCosiPath) - _, err = sftpClient.Stat(newCosiPath) - if err != nil { - fmt.Println("Yielding to the error") - return fmt.Errorf("failed to stat new COSI file at %s: %w", newCosiPath, err) - } - err = sftpClient.MkdirAll(path.Dir(h.args.TridentConfig)) if err != nil { return fmt.Errorf("failed to create directory for new Host Config file: %w", err) @@ -179,12 +194,12 @@ func (h *AbUpdateHelper) triggerTridentUpdate(tc storm.TestCase) error { allowedOperations := make([]string, 0) if h.args.StageAbUpdate { - tc.Logger().Infof("Allowed operations: stage") + logrus.Infof("Allowed operations: stage") allowedOperations = append(allowedOperations, "stage") } if h.args.FinalizeAbUpdate { - tc.Logger().Infof("Allowed operations: finalize") + logrus.Infof("Allowed operations: finalize") allowedOperations = append(allowedOperations, "finalize") } @@ -194,41 +209,41 @@ func (h *AbUpdateHelper) triggerTridentUpdate(tc storm.TestCase) error { strings.Join(allowedOperations, ","), ) - file, err := utils.CommandOutput(h.client, tc.Logger(), fmt.Sprintf("sudo cat %s", h.args.TridentConfig)) + file, err := utils.CommandOutput(h.client, fmt.Sprintf("sudo cat %s", h.args.TridentConfig)) if err != nil { return fmt.Errorf("failed to read new Host Config file: %w", err) } - tc.Logger().Debugf("Trident config file:\n%s", file) + logrus.Debugf("Trident config file:\n%s", file) for i := 1; ; i++ { - tc.Logger().Infof("Invoking Trident attempt #%d with args: %s", i, args) + logrus.Infof("Invoking Trident attempt #%d with args: %s", i, args) out, err := utils.InvokeTrident(h.args.Env, h.client, args) if err != nil { if err, ok := err.(*ssh.ExitMissingError); ok && strings.Contains(out.Stderr, "Rebooting system") { // The connection closed without an exit code, and the output contains "Rebooting system". // This indicates that the host has rebooted. - tc.Logger().Infof("Host rebooted successfully") + logrus.Infof("Host rebooted successfully") break } else { // Some unknown error occurred. - tc.Logger().Errorf("Failed to invoke Trident: %s; %s", err, out.Report()) + logrus.Errorf("Failed to invoke Trident: %s; %s", err, out.Report()) return fmt.Errorf("failed to invoke Trident: %w", err) } } if out.Status == 0 && strings.Contains(out.Stderr, "Staging of update 'AbUpdate' succeeded") { - tc.Logger().Infof("Staging of update 'AbUpdate' succeeded") + logrus.Infof("Staging of update 'AbUpdate' succeeded") break } if out.Status == 2 && strings.Contains(out.Stderr, "Failed to run post-configure script 'fail-on-the-first-run'") { - tc.Logger().Infof("Detected intentional failure. Re-running...") + logrus.Infof("Detected intentional failure. Re-running...") continue } - tc.Logger().Errorf("Trident update failed %s", out.Report()) + logrus.Errorf("Trident update failed %s", out.Report()) tc.Fail(fmt.Sprintf("Trident update failed with status %d", out.Status)) } @@ -245,7 +260,7 @@ func (h *AbUpdateHelper) checkTridentService(tc storm.TestCase) error { tc.Skip("No Trident environment specified") } - tc.Logger().Infof("Waiting for the host to reboot and come back online...") + logrus.Infof("Waiting for the host to reboot and come back online...") time.Sleep(time.Second * 10) // Reconnect via SSH to the updated OS @@ -253,23 +268,23 @@ func (h *AbUpdateHelper) checkTridentService(tc storm.TestCase) error { h.args.TimeoutDuration(), time.Second*5, func(attempt int) (*bool, error) { - tc.Logger().Infof("SSH dial to '%s' (attempt %d)", h.args.SshCliSettings.FullHost(), attempt) + logrus.Infof("SSH dial to '%s' (attempt %d)", h.args.SshCliSettings.FullHost(), attempt) client, err := utils.OpenSshClient(h.args.SshCliSettings) if err != nil { - tc.Logger().Warnf("Failed to dial SSH server '%s': %s", h.args.SshCliSettings.FullHost(), err) + logrus.Warnf("Failed to dial SSH server '%s': %s", h.args.SshCliSettings.FullHost(), err) return nil, err } defer client.Close() - tc.Logger().Infof("SSH dial to '%s' succeeded", h.args.SshCliSettings.FullHost()) + logrus.Infof("SSH dial to '%s' succeeded", h.args.SshCliSettings.FullHost()) - err = utils.CheckTridentService(client, tc.Logger(), h.args.Env, h.args.TimeoutDuration()) + err = utils.CheckTridentService(client, h.args.Env, h.args.TimeoutDuration()) if err != nil { - tc.Logger().Warnf("Trident service is not in expected state: %s", err) + logrus.Warnf("Trident service is not in expected state: %s", err) return nil, err } - tc.Logger().Infof("Trident service is in expected state") + logrus.Infof("Trident service is in expected state") return nil, nil }, ) @@ -280,3 +295,15 @@ func (h *AbUpdateHelper) checkTridentService(tc storm.TestCase) error { return nil } + +func checkUrlIsAccessible(url string) error { + resp, err := http.Head(url) + if err != nil { + return fmt.Errorf("failed to check new image URL: %w", err) + } + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("new image URL is not accessible: %s, got HTTP code: %d", url, resp.StatusCode) + } + + return nil +} diff --git a/tools/storm/helpers/boot_metrics.go b/tools/storm/helpers/boot_metrics.go new file mode 100644 index 000000000..abb214368 --- /dev/null +++ b/tools/storm/helpers/boot_metrics.go @@ -0,0 +1,224 @@ +package helpers + +import ( + "bufio" + "encoding/json" + "fmt" + "os" + "regexp" + "storm" + "strconv" + "strings" + "time" + "tridenttools/storm/utils" + + "github.com/sirupsen/logrus" +) + +type BootMetricsHelper struct { + args struct { + utils.SshCliSettings `embed:""` + utils.EnvCliSettings `embed:""` + MetricsFile string `required:"" help:"Metrics file." type:"path"` + MetricsOperation string `required:"" help:"Metrics operation."` + } +} + +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"` +} + +type BootMetrics struct { + Timestamp string `json:"timestamp"` + MetricName string `json:"metric_name"` + Value BootMetric `json:"value"` + AdditionalFields map[string]interface{} `json:"additional_fields"` + PlatformInfo map[string]interface{} `json:"platform_info"` +} + +func (h BootMetricsHelper) Name() string { + return "boot-metrics" +} + +func (h *BootMetricsHelper) Args() any { + return &h.args +} + +func (h *BootMetricsHelper) RegisterTestCases(r storm.TestRegistrar) error { + r.RegisterTestCase("collect-boot-metrics", h.collectBootMetrics) + return nil +} + +func (h *BootMetricsHelper) collectBootMetrics(tc storm.TestCase) error { + if h.args.Env == utils.TridentEnvironmentNone { + tc.Skip("No Trident environment specified") + } + logrus.Infof("Waiting for the host to reboot and come back online...") + + result, err := h.initializeBootMetrics(tc, h.args.MetricsFile) + if err != nil { + tc.FailFromError(err) + } + + value, err := utils.Retry( + time.Second*time.Duration(h.args.Timeout), + time.Second*5, + func(attempt int) (*BootMetric, error) { + var err error = nil + result := BootMetric{} + client, err := utils.OpenSshClient(h.args.SshCliSettings) + if err != nil { + return &result, err + } + + // Expect output in the form of: + // Startup finished in [13.022s (firmware) + 2.552s (loader) + ]? 4.740s (kernel) + 1.267s (initrd) + 15.249s (userspace) = 35.565s + // graphical.target reached after 13.272s in userspace + systemdAnalzeBootResult, err := utils.RunCommand(client, "systemd-analyze | head -n 1") + if err != nil { + return &result, err + } + systemdAnalzeBootOutput := systemdAnalzeBootResult.Stdout + + result.Operation = h.args.MetricsOperation + + if firmwareBoot, units, firmwareBootExists := h.findWordBeforeMatch(tc, systemdAnalzeBootOutput, "(firmware)"); firmwareBootExists { + result.FirmwareMs, err = h.ensureMilliseconds(tc, firmwareBoot, units) + if err != nil { + return &result, err + } + } + if loaderBoot, units, loaderBootExists := h.findWordBeforeMatch(tc, systemdAnalzeBootOutput, "(loader)"); loaderBootExists { + result.LoaderMs, err = h.ensureMilliseconds(tc, loaderBoot, units) + if err != nil { + return &result, err + } + } + if kernelBoot, units, kernelBootExists := h.findWordBeforeMatch(tc, systemdAnalzeBootOutput, "(kernel)"); kernelBootExists { + result.KernelMs, err = h.ensureMilliseconds(tc, kernelBoot, units) + if err != nil { + return &result, err + } + } + if initrdBoot, units, initrdBootExists := h.findWordBeforeMatch(tc, systemdAnalzeBootOutput, "(initrd)"); initrdBootExists { + result.InitrdMs, err = h.ensureMilliseconds(tc, initrdBoot, units) + if err != nil { + return &result, err + } + } + if userspaceBoot, units, userspaceBootExists := h.findWordBeforeMatch(tc, systemdAnalzeBootOutput, "(userspace)"); userspaceBootExists { + result.UserspaceMs, err = h.ensureMilliseconds(tc, userspaceBoot, units) + if err != nil { + return &result, err + } + } + return &result, err + }, + ) + if err != nil { + // Log this as a test failure + tc.FailFromError(err) + } + + result.Value = *value + + jsonBytes, err := json.Marshal(result) + if err != nil { + // Log this as a test failure + tc.FailFromError(err) + } + + file, err := os.OpenFile(h.args.MetricsFile, os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0600) + if err != nil { + return err + } + defer file.Close() + + _, err = file.WriteString(string(jsonBytes) + "\n") + if err != nil { + return err + } + + return nil +} + +func (h *BootMetricsHelper) ensureMilliseconds(tc storm.TestCase, value string, unit string) (float64, error) { + valueAsDouble, err := strconv.ParseFloat(value, 64) + if err != nil { + logrus.Infof("Failed to parse value: %s", value) + return 0, fmt.Errorf("failed to parse value: %s", value) + } + + switch unit { + case "s": + return valueAsDouble * 1000, nil + case "ms": + return valueAsDouble, nil + case "m": + return valueAsDouble * 60 * 1000, nil + case "ns": + return valueAsDouble / 1000000, nil + } + + return 0, fmt.Errorf("unknown time unit: %s", unit) +} + +func (h *BootMetricsHelper) initializeBootMetrics(tc storm.TestCase, metricsFile string) (BootMetrics, error) { + result := BootMetrics{} + + // Open metrics file + file, err := os.OpenFile(h.args.MetricsFile, os.O_RDONLY, os.ModeAppend) + if err != nil { + return result, err + } + defer file.Close() + + // Read only the first line of the file and parse it as JSON + // This is to ensure that the file is not empty and contains valid JSON + // Read the first line + scanner := bufio.NewScanner(file) + if !scanner.Scan() { + return result, fmt.Errorf("failed to read the first line of the file") + } + + // Decode the first line as JSON + decoder := json.NewDecoder(strings.NewReader(scanner.Text())) + var firstRecord map[string]interface{} + err = decoder.Decode(&firstRecord) + if err != nil { + return result, err + } + + result.Timestamp = time.Now().Format(time.RFC3339) + result.MetricName = "boot_info" + + // Get additional fields from first record + if firstRecord["additional_fields"] != nil { + additionalFields, ok := firstRecord["additional_fields"].(map[string]interface{}) + if ok { + result.AdditionalFields = additionalFields + } + } + // Get platform info from first record + if firstRecord["platform_info"] != nil { + platformInfo, ok := firstRecord["platform_info"].(map[string]interface{}) + if ok { + result.PlatformInfo = platformInfo + } + } + return result, nil +} + +func (h *BootMetricsHelper) findWordBeforeMatch(tc storm.TestCase, 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 +} diff --git a/storm/suites/trident/helpers/check_ssh.go b/tools/storm/helpers/check_ssh.go similarity index 77% rename from storm/suites/trident/helpers/check_ssh.go rename to tools/storm/helpers/check_ssh.go index 32ec0990d..62ee23676 100644 --- a/storm/suites/trident/helpers/check_ssh.go +++ b/tools/storm/helpers/check_ssh.go @@ -2,12 +2,15 @@ package helpers import ( "fmt" - "storm/pkg/storm" - "storm/suites/trident/utils" "time" + "storm" + + "github.com/sirupsen/logrus" "golang.org/x/crypto/ssh" "gopkg.in/yaml.v3" + + "tridenttools/storm/utils" ) type CheckSshHelper struct { @@ -35,14 +38,14 @@ func (h *CheckSshHelper) RegisterTestCases(r storm.TestRegistrar) error { } func (h *CheckSshHelper) sshDial(tc storm.TestCase) error { - tc.Logger().Infof("Checking SSH connection to '%s' as user '%s'", h.args.Host, h.args.User) + logrus.Infof("Checking SSH connection to '%s' as user '%s'", h.args.Host, h.args.User) var err error h.client, err = utils.Retry( time.Second*time.Duration(h.args.Timeout), time.Second*5, func(attempt int) (*ssh.Client, error) { - tc.Logger().Infof("SSH dial to '%s' (attempt %d)", h.args.SshCliSettings.FullHost(), attempt) + logrus.Infof("SSH dial to '%s' (attempt %d)", h.args.SshCliSettings.FullHost(), attempt) return utils.OpenSshClient(h.args.SshCliSettings) }, ) @@ -66,7 +69,7 @@ func (h *CheckSshHelper) checkTridentService(tc storm.TestCase) error { tc.Skip("No Trident environment specified") } - err := utils.CheckTridentService(h.client, tc.Logger(), h.args.Env, h.args.TimeoutDuration()) + err := utils.CheckTridentService(h.client, h.args.Env, h.args.TimeoutDuration()) if err != nil { // Log this as a test failure tc.FailFromError(err) @@ -84,8 +87,8 @@ func (h *CheckSshHelper) checkActiveVolume(tc storm.TestCase) error { time.Second*5, time.Second, func(attempt int) (*ssh.Client, error) { - tc.Logger().Infof("Checking active volume (attempt %d)", attempt) - return nil, checkActiveVolumeInner(tc, h.client, h.args.CheckActiveVolume) + logrus.Infof("Checking active volume (attempt %d)", attempt) + return nil, checkActiveVolumeInner(h.client, h.args.CheckActiveVolume) }, ) @@ -97,7 +100,7 @@ func (h *CheckSshHelper) checkActiveVolume(tc storm.TestCase) error { return nil } -func checkActiveVolumeInner(lp storm.LoggerProvider, client *ssh.Client, expectedActiveVolume string) error { +func checkActiveVolumeInner(client *ssh.Client, expectedActiveVolume string) error { session, err := client.NewSession() if err != nil { return fmt.Errorf("failed to create SSH session: %w", err) @@ -111,7 +114,7 @@ func checkActiveVolumeInner(lp storm.LoggerProvider, client *ssh.Client, expecte outputStr := string(output) - lp.Logger().Debugf("Host Status:\n%s", outputStr) + logrus.Debugf("Host Status:\n%s", outputStr) hostStatus := make(map[string]interface{}) if err = yaml.Unmarshal([]byte(outputStr), &hostStatus); err != nil { @@ -121,13 +124,15 @@ func checkActiveVolumeInner(lp storm.LoggerProvider, client *ssh.Client, expecte if hostStatus["servicingState"] != "provisioned" { return fmt.Errorf("trident state is not 'provisioned'") } - lp.Logger().Info("Host is in provisioned state") + + logrus.Info("Host is in provisioned state") hsActiveVol := hostStatus["abActiveVolume"] if hsActiveVol != expectedActiveVolume { return fmt.Errorf("expected active volume '%s', got '%s'", expectedActiveVolume, hsActiveVol) } - lp.Logger().Infof("Active volume is '%s'", hsActiveVol) + + logrus.Infof("Active volume is '%s'", hsActiveVol) return nil } diff --git a/storm/suites/trident/helpers/init.go b/tools/storm/helpers/init.go similarity index 63% rename from storm/suites/trident/helpers/init.go rename to tools/storm/helpers/init.go index b265bf28d..95ac7b424 100644 --- a/storm/suites/trident/helpers/init.go +++ b/tools/storm/helpers/init.go @@ -1,8 +1,10 @@ package helpers -import "storm/pkg/storm" +import "storm" var TRIDENT_HELPERS = []storm.Helper{ &CheckSshHelper{}, &AbUpdateHelper{}, + &PrepareImages{}, + &BootMetricsHelper{}, } diff --git a/tools/storm/helpers/prepare_images.go b/tools/storm/helpers/prepare_images.go new file mode 100644 index 000000000..346e41c47 --- /dev/null +++ b/tools/storm/helpers/prepare_images.go @@ -0,0 +1,184 @@ +package helpers + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + + "storm" + + "github.com/sirupsen/logrus" +) + +const ( + COSI_EXTENSION = "cosi" + OUTPUT_REGULAR_IMAGE_NAME = "regular" + OUTPUT_VERITY_IMAGE_NAME = "verity" + OUTPUT_USRVERITY_IMAGE_NAME = "usrverity" +) + +type PrepareImages struct { + args struct { + RegularTestImageDir string `arg:"" help:"Directory containing the regular test images" type:"path"` + VerityTestImageDir string `arg:"" help:"Directory containing the verity test images" type:"path"` + UsrVerityTestImageDir string `arg:"" help:"Directory containing the verity test images" type:"path"` + RegularImageName string `arg:"" help:"Name of the regular test image"` + VerityImageName string `arg:"" help:"Name of the verity test image"` + UsrVerityImageName string `arg:"" help:"Name of the verity test image"` + OutputDir string `arg:"" help:"Directory in which to place the prepared images" type:"path"` + Versions uint `short:"v" help:"Number of versions to create of each image type" default:"1"` + } +} + +func (h PrepareImages) Name() string { + return "prepare-images" +} + +func (h *PrepareImages) Args() any { + return &h.args +} + +func (h *PrepareImages) RegisterTestCases(r storm.TestRegistrar) error { + r.RegisterTestCase("copy-regular", h.copyRegularImages) + r.RegisterTestCase("copy-verity", h.copyVerityImages) + r.RegisterTestCase("copy-usrverity", h.copyUsrVerityImages) + return nil +} + +func (h *PrepareImages) copyRegularImages(tc storm.TestCase) error { + // Skip test if the path doesn't exist + if _, err := os.Stat(h.args.RegularTestImageDir); os.IsNotExist(err) { + tc.Skip(fmt.Sprintf("Directory %s does not exist", h.args.RegularTestImageDir)) + } + + return copyImages( + h.args.RegularTestImageDir, + h.args.OutputDir, + h.args.RegularImageName, + COSI_EXTENSION, + OUTPUT_REGULAR_IMAGE_NAME, + h.args.Versions, + ) +} + +func (h *PrepareImages) copyVerityImages(tc storm.TestCase) error { + // Skip test if the path doesn't exist + if _, err := os.Stat(h.args.VerityTestImageDir); os.IsNotExist(err) { + tc.Skip(fmt.Sprintf("Directory %s does not exist", h.args.VerityTestImageDir)) + } + + return copyImages( + h.args.VerityTestImageDir, + h.args.OutputDir, + h.args.VerityImageName, + COSI_EXTENSION, + OUTPUT_VERITY_IMAGE_NAME, + h.args.Versions, + ) +} + +func (h *PrepareImages) copyUsrVerityImages(tc storm.TestCase) error { + // Skip test if the path doesn't exist + if _, err := os.Stat(h.args.UsrVerityTestImageDir); os.IsNotExist(err) { + tc.Skip(fmt.Sprintf("Directory %s does not exist", h.args.UsrVerityTestImageDir)) + } + + return copyImages( + h.args.UsrVerityTestImageDir, + h.args.OutputDir, + h.args.UsrVerityImageName, + COSI_EXTENSION, + OUTPUT_USRVERITY_IMAGE_NAME, + h.args.Versions, + ) +} + +func copyImages(srcDir, destDir string, imageName string, ext string, outputFilename string, versions uint) error { + srcDir, err := filepath.Abs(srcDir) + if err != nil { + return fmt.Errorf("failed to get absolute path of source directory %s: %v", srcDir, err) + } + destDir, err = filepath.Abs(destDir) + if err != nil { + return fmt.Errorf("failed to get absolute path of destination directory %s: %v", destDir, err) + } + + glob := fmt.Sprintf("%s/%s*.%s", srcDir, imageName, ext) + files, err := filepath.Glob(glob) + if err != nil { + return fmt.Errorf("failed to list files in directory %s: %v", srcDir, err) + } + + if len(files) == 0 { + return fmt.Errorf("no '%s' files found in directory %s", glob, srcDir) + } + + logrus.Infof("Found %d files in %s matching glob %s", len(files), srcDir, glob) + + singleFilePattern := fmt.Sprintf("%s.%s", imageName, ext) + multipleFilePattern := fmt.Sprintf(`%s_(\d+).%s`, regexp.QuoteMeta(imageName), regexp.QuoteMeta(ext)) + + if len(files) == 1 && filepath.Base(files[0]) != singleFilePattern { + // Single file, must be names exactly as the image name + extension + return fmt.Errorf("file '%s' does not match the expected pattern '%s'", filepath.Base(files[0]), singleFilePattern) + } else if len(files) > 1 { + compiled, err := regexp.Compile(multipleFilePattern) + if err != nil { + return fmt.Errorf("failed to compile regex %s: %v", multipleFilePattern, err) + } + + // Multiple files, must match the pattern imageName_0.ext, imageName_1.ext, etc. + for _, file := range files { + if !compiled.MatchString(filepath.Base(file)) { + return fmt.Errorf("file %s does not match the expected pattern %s", file, multipleFilePattern) + } + } + } + + // Create output directory if it doesn't exist + if _, err := os.Stat(destDir); os.IsNotExist(err) { + logrus.Debugf("Creating directory %s", destDir) + err := os.MkdirAll(destDir, 0755) + if err != nil { + return fmt.Errorf("failed to create directory %s: %v", destDir, err) + } + } + + outputFiles := make([]string, 0) + + for i, file := range files { + var newFileName string + if i == 0 { + newFileName = fmt.Sprintf("%s.%s", outputFilename, ext) + } else { + // Add 1 because we expect the first update to consume v2 + newFileName = fmt.Sprintf("%s_v%d.%s", outputFilename, i+1, ext) + } + + logrus.Infof("Moving file '%s' to '%s'", file, newFileName) + + newFilePath := filepath.Join(destDir, newFileName) + err := os.Rename(file, newFilePath) + if err != nil { + return fmt.Errorf("failed to rename file %s to %s: %v", file, newFilePath, err) + } + + outputFiles = append(outputFiles, newFilePath) + } + + for v := len(outputFiles); v < int(versions); v++ { + // Add 1 because we expect the first update to consume v2 + newFileName := fmt.Sprintf("%s_v%d.%s", outputFilename, v+1, ext) + baseFile := outputFiles[v%len(outputFiles)] + // Create a hard link to the base file + newFilePath := filepath.Join(destDir, newFileName) + logrus.Infof("Linking file '%s' to '%s'", baseFile, newFilePath) + err := os.Link(baseFile, newFilePath) + if err != nil { + return fmt.Errorf("failed to link file %s to %s: %v", baseFile, newFilePath, err) + } + } + + return nil +} diff --git a/tools/storm/servicing/tests/azure.go b/tools/storm/servicing/tests/azure.go new file mode 100644 index 000000000..d7b822c83 --- /dev/null +++ b/tools/storm/servicing/tests/azure.go @@ -0,0 +1,13 @@ +package tests + +import ( + "fmt" + "tridenttools/storm/servicing/utils/config" +) + +func PublishSigImage(cfg config.ServicingConfig) error { + if err := cfg.AzureConfig.PublishSigImage(cfg.TestConfig.ArtifactsDir, cfg.TestConfig.BuildId); err != nil { + return fmt.Errorf("failed to publish Azure Shared Image Gallery image: %w", err) + } + return nil +} diff --git a/tools/storm/servicing/tests/logs.go b/tools/storm/servicing/tests/logs.go new file mode 100644 index 000000000..1285559db --- /dev/null +++ b/tools/storm/servicing/tests/logs.go @@ -0,0 +1,34 @@ +package tests + +import ( + "tridenttools/storm/servicing/utils/config" + "tridenttools/storm/servicing/utils/ssh" + "tridenttools/storm/utils" + + "github.com/sirupsen/logrus" +) + +func FetchLogs(cfg config.ServicingConfig) error { + vmIP, err := utils.GetVmIP(cfg) + if err != nil { + return err + } + // Best effort: download journal log + logrus.Tracef("Make journal log available for download") + _, err = ssh.SshCommand(cfg.VMConfig, vmIP, "sudo journalctl --no-pager > /tmp/journal.log && sudo chmod 644 /tmp/journal.log") + if err == nil { + // Download file via scp if creating journal.log succeeded + logrus.Tracef("Downloading journal log from VM '%s' to local machine", cfg.VMConfig.Name) + ssh.ScpDownloadFile(cfg.VMConfig, vmIP, "/tmp/journal.log", cfg.TestConfig.OutputPath+"/journal.log") + } + // Download crashdumps (simplified) + logrus.Tracef("Check for crash dumps on VM") + crashDumpOutput, err := ssh.SshCommand(cfg.VMConfig, vmIP, "ls /var/crash/*") + if err == nil { + logrus.Debugf("Crash files found on host: %s", crashDumpOutput) + logrus.Error("Crash files found on host") + ssh.SshCommand(cfg.VMConfig, vmIP, "sudo mv /var/crash/* /tmp/crash && sudo chmod -R 644 /tmp/crash && sudo chmod +x /tmp/crash") + ssh.ScpDownloadFile(cfg.VMConfig, vmIP, "/tmp/crash/*", cfg.TestConfig.OutputPath+"/") + } + return nil +} diff --git a/tools/storm/servicing/tests/update.go b/tools/storm/servicing/tests/update.go new file mode 100644 index 000000000..666d85e46 --- /dev/null +++ b/tools/storm/servicing/tests/update.go @@ -0,0 +1,455 @@ +package tests + +import ( + "bytes" + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" + "time" + "tridenttools/storm/servicing/utils/config" + "tridenttools/storm/servicing/utils/file" + "tridenttools/storm/servicing/utils/ssh" + "tridenttools/storm/utils" + + "github.com/sirupsen/logrus" + "gopkg.in/yaml.v2" +) + +func UpdateLoop(cfg config.ServicingConfig) error { + return innerUpdateLoop(cfg, false) +} + +func Rollback(cfg config.ServicingConfig) error { + return innerUpdateLoop(cfg, true) +} + +func innerUpdateLoop(cfg config.ServicingConfig, rollback bool) error { + // Create context to ensure goroutines exit cleanly + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + logrus.Tracef("Stop existing update servers if any") + // Kill any running update servers + killUpdateServer(cfg.TestConfig.UpdatePortA) + killUpdateServer(cfg.TestConfig.UpdatePortB) + + lsaCmd := exec.Command("ls", "-l", cfg.TestConfig.ArtifactsDir+"/update-a") + lsaOut, err := lsaCmd.Output() + if err != nil { + return fmt.Errorf("failed to list update-a directory: %w", err) + } + logrus.Tracef("Contents of update-a directory:\n%s", lsaOut) + + lsbCmd := exec.Command("ls", "-l", cfg.TestConfig.ArtifactsDir+"/update-b") + lsbOut, err := lsbCmd.Output() + if err != nil { + return fmt.Errorf("failed to list update-b directory: %w", err) + } + logrus.Tracef("Contents of update-b directory:\n%s", lsbOut) + + // Check for COSI files + cosiFile, err := file.FindFile(cfg.TestConfig.ArtifactsDir+"/update-a", ".*\\.cosi$") + if err != nil { + return fmt.Errorf("failed to find COSI file: %w", err) + } + logrus.Tracef("Found COSI file: %s", cosiFile) + cosiFileBase := cosiFile[strings.LastIndex(cosiFile, "/")+1:] + + logrus.Tracef("Start update servers (netlisten)") + // Start update servers (netlisten) + aStartedChannel := make(chan bool) + go startNetListenAndWait(ctx, cfg.TestConfig.UpdatePortA, "a", cfg.TestConfig.ArtifactsDir, aStartedChannel) + bStartedChannel := make(chan bool) + go startNetListenAndWait(ctx, cfg.TestConfig.UpdatePortB, "b", cfg.TestConfig.ArtifactsDir, bStartedChannel) + // Wait for both udpate servers to start + <-aStartedChannel + <-bStartedChannel + expectedVolume := "volume-b" + logrus.Tracef("Current expected volume: %s", expectedVolume) + + updateConfig := "/var/lib/trident/update-config.yaml" + if expectedVolume == "volume-a" && !rollback { + updateConfig = "/var/lib/trident/update-config2.yaml" + } else if expectedVolume == "volume-b" && rollback { + updateConfig = "/var/lib/trident/update-config2.yaml" + } + logrus.Tracef("Using update config file: %s", updateConfig) + + vmIP, err := utils.GetVmIP(cfg) + if err != nil { + return fmt.Errorf("failed to get VM IP: %w", err) + } + + // Run several commands to update/specialize update config files on VM + logrus.Tracef("Updating config files") + configChanges := + // use COSI file found in update-a and update-b directories + fmt.Sprintf("sudo sed -i 's!verity.cosi!files/%s!' /var/lib/trident/update-config.yaml && ", cosiFileBase) + + // use localhost as update server address + "sudo sed -i 's/192.168.122.1/localhost/' /var/lib/trident/update-config.yaml &&" + + // create second config file for b update + "sudo cp /var/lib/trident/update-config.yaml /var/lib/trident/update-config2.yaml && " + + // use update port b for second config + fmt.Sprintf("sudo sed -i 's/8000/%d/' /var/lib/trident/update-config2.yaml && ", cfg.TestConfig.UpdatePortB) + + // use udpate port a for first config + fmt.Sprintf("sudo sed -i 's/8000/%d/' /var/lib/trident/update-config.yaml", cfg.TestConfig.UpdatePortA) + configChangesOutput, err := ssh.SshCommand(cfg.VMConfig, vmIP, configChanges) + if err != nil { + logrus.Tracef("Failed to update config files:\n%s", configChangesOutput) + return fmt.Errorf("failed to create config for b updates") + } + + if cfg.TestConfig.Verbose { + configaOut, err := ssh.SshCommand(cfg.VMConfig, vmIP, "sudo cat /var/lib/trident/update-config.yaml") + if err != nil { + return fmt.Errorf("failed to get config a contents") + } + logrus.Tracef("Trident config-a contents:\n%s", configaOut) + configbOut, err := ssh.SshCommand(cfg.VMConfig, vmIP, "sudo cat /var/lib/trident/update-config2.yaml") + if err != nil { + return fmt.Errorf("failed to get config b contents") + } + logrus.Tracef("Trident config-b contents:\n%s", configbOut) + } + + // Main update loop (simplified) + loopCount := cfg.TestConfig.RetryCount + if rollback { + loopCount = cfg.TestConfig.RollbackRetryCount + } + for i := 1; i <= loopCount; i++ { + logrus.Infof("Update attempt #%d for VM '%s' (%s)", i, cfg.VMConfig.Name, cfg.VMConfig.Platform) + + if cfg.VMConfig.Platform == config.PlatformQEMU && i%10 == 0 { + // For every 10th update, reboot the VM (QEMU only) + if err := cfg.QemuConfig.RebootQemuVm(cfg.VMConfig.Name, i, cfg.TestConfig.OutputPath, cfg.TestConfig.Verbose); err != nil { + return fmt.Errorf("failed to reboot QEMU VM before update attempt #%d: %w", i, err) + } + if err := cfg.QemuConfig.TruncateLog(cfg.VMConfig.Name); err != nil { + return fmt.Errorf("failed to truncate log file before update attempt #%d: %w", i, err) + } + } + + logrus.Tracef("Setting up SSH proxy ports for update servers") + aStartedChannel := make(chan bool) + go ssh.StartSshProxyPortAndWait(ctx, cfg.TestConfig.UpdatePortA, vmIP, cfg.VMConfig.User, cfg.VMConfig.SshPrivateKeyPath, aStartedChannel) + bStartedChannel := make(chan bool) + go ssh.StartSshProxyPortAndWait(ctx, cfg.TestConfig.UpdatePortB, vmIP, cfg.VMConfig.User, cfg.VMConfig.SshPrivateKeyPath, bStartedChannel) + // Wait for both SSH proxy ports to be ready + <-aStartedChannel + <-bStartedChannel + + logrus.Tracef("Checking for crash dumps on host") + crashDumpOutput, err := ssh.SshCommand(cfg.VMConfig, vmIP, "ls /var/crash/*") + if err == nil { + logrus.Debugf("Crash files found on host during iteration %d: %s", i, crashDumpOutput) + logrus.Error("Crash files found on host") + return fmt.Errorf("crash files found on host during iteration %d", i) + } + + if rollback && i == 1 { + if err := prepareRollback(cfg, vmIP, updateConfig, expectedVolume, i); err != nil { + return fmt.Errorf("failed to prepare rollback for iteration %d: %w", i, err) + } + } + + if cfg.TestConfig.Verbose { + configContents, err := ssh.SshCommand(cfg.VMConfig, vmIP, fmt.Sprintf("sudo cat %s", updateConfig)) + if err != nil { + return fmt.Errorf("failed to read update config file after modification: %w", err) + } + logrus.Infof("Update Config Contents:\n%s", configContents) + } + + tridentLoggingArg := "-v WARN" + if cfg.TestConfig.Verbose { + tridentLoggingArg = "-v DEBUG" + } + + logrus.Tracef("Running Trident update staging command on VM") + combinedStagingOutput, stageErr := ssh.SshCommandCombinedOutput(cfg.VMConfig, vmIP, fmt.Sprintf("sudo trident update %s %s --allowed-operations stage", tridentLoggingArg, updateConfig)) + if cfg.TestConfig.Verbose { + logrus.Tracef("Staging output for iteration %d:\n%s", i, combinedStagingOutput) + } + + if cfg.TestConfig.OutputPath != "" { + logrus.Tracef("Download staging trident logs for iteration %d", i) + localPath := filepath.Join(cfg.TestConfig.OutputPath, fmt.Sprintf("%s-staged-trident-full.log", fmt.Sprintf("%03d", i))) + err = ssh.ScpDownloadFile(cfg.VMConfig, vmIP, "/var/log/trident-full.log", localPath) + if err != nil { + return fmt.Errorf("failed to download staged trident log: %w", err) + } + } + + if stageErr != nil { + logrus.Errorf("Failed to stage update for iteration %d: %v", i, stageErr) + return fmt.Errorf("failed to stage update for iteration %d: %w", i, stageErr) + } + + logrus.Tracef("Running Trident update finalize command on VM") + combinedFinalizeOutput, finalizeErr := ssh.SshCommandCombinedOutput(cfg.VMConfig, vmIP, fmt.Sprintf("sudo trident update %s %s --allowed-operations finalize", tridentLoggingArg, updateConfig)) + if cfg.TestConfig.Verbose { + logrus.Tracef("Finalize output for iteration %d:\n%s\n%v", i, combinedFinalizeOutput, finalizeErr) + } + + logrus.Tracef("Wait for VM to come back up after finalize reboot") + if cfg.VMConfig.Platform == config.PlatformQEMU { + err := cfg.QemuConfig.WaitForLogin(cfg.VMConfig.Name, cfg.TestConfig.OutputPath, cfg.TestConfig.Verbose, i) + if err != nil { + return fmt.Errorf("VM did not come back up after update for iteration %d: %w", i, err) + } + } else if cfg.VMConfig.Platform == config.PlatformAzure { + time.Sleep(15 * time.Second) + + success := false + for j := 0; j < 10; j++ { + if _, err = ssh.SshCommand(cfg.VMConfig, vmIP, "hostname"); err == nil { + success = true + break + } + time.Sleep(5 * time.Second) // Wait for the VM to stabilize + } + + if !success { + logrus.Info("VM did not come back up after update") + logrus.Errorf("VM did not come back up after update for iteration %d", i) + return fmt.Errorf("VM did not come back up after update for iteration %d", i) + } + } + + logrus.Tracef("Check if VM IP has changed after update") + newVmIP, err := utils.GetVmIP(cfg) + if err != nil { + return fmt.Errorf("failed to get new VM IP after update: %w", err) + } + if newVmIP != vmIP { + logrus.Infof("VM IP changed from %s to %s", vmIP, newVmIP) + return fmt.Errorf("VM IP changed during update from %s to %s", vmIP, newVmIP) + } + logrus.Infof("VM IP remains the same after update: %s", vmIP) + + logrus.Tracef("Validate active volume after update") + checkActiveVolumeErr := checkActiveVolume(cfg.VMConfig, vmIP, expectedVolume) + logrus.Tracef("Get journal logs after post-update reboot %d", i) + if _, postUpdateJournalLogErr := ssh.SshCommand(cfg.VMConfig, vmIP, "sudo journalctl --no-pager > /tmp/post-reboot-update-journal.log && sudo chmod 644 /tmp/post-reboot-update-journal.log"); postUpdateJournalLogErr == nil { + // Download file via scp if creating post-reboot-update-journal.log succeeded + padIteration := fmt.Sprintf("%03d", i) + logrus.Tracef("Downloading post-reboot-update-journal.log from VM '%s' to local machine", cfg.VMConfig.Name) + ssh.ScpDownloadFile(cfg.VMConfig, vmIP, "/tmp/post-reboot-update-journal.log", fmt.Sprintf("%s/%s-%s", cfg.TestConfig.OutputPath, padIteration, "post-reboot-update-journal.log")) + } + if checkActiveVolumeErr != nil { + return fmt.Errorf("failed to verify active volume after update: %w", checkActiveVolumeErr) + } + + if rollback && i == 1 { + logrus.Tracef("Validate rollback after first update") + validateRollback(cfg.VMConfig, vmIP) + } + + if cfg.TestConfig.Verbose { + hostStatusStr, err := ssh.SshCommand(cfg.VMConfig, vmIP, "sudo trident get") + if err != nil { + return fmt.Errorf("failed to get host status: %w", err) + } + logrus.Infof("Host Status after update:\n%s", hostStatusStr) + } + + if expectedVolume == "volume-a" { + expectedVolume = "volume-b" + if !rollback || i != 1 { + updateConfig = "/var/lib/trident/update-config.yaml" + } + } else { + expectedVolume = "volume-a" + if !rollback || i != 1 { + updateConfig = "/var/lib/trident/update-config2.yaml" + } + } + logrus.Tracef("Updated expected volume for next update to be: %s", expectedVolume) + logrus.Tracef("Updated config file for next update to be: %s", updateConfig) + } + return nil +} + +func prepareRollback(cfg config.ServicingConfig, vmIP string, updateConfig string, expectedVolume string, iteration int) error { + logrus.Tracef("Testing Rollback for iteration %d", iteration) + + triggerRollbackScript := ".pipelines/templates/stages/testing_common/scripts/trigger-rollback.sh" + scriptHostCopy := "/var/lib/trident/trigger-rollback.sh" + + logrus.Tracef("Copying rollback script to VM") + if err := ssh.ScpUploadFileWithSudo(cfg.VMConfig, vmIP, triggerRollbackScript, scriptHostCopy); err != nil { + return fmt.Errorf("failed to upload rollback script: %w", err) + } + logrus.Tracef("Make rollback script executable") + if _, err := ssh.SshCommand(cfg.VMConfig, vmIP, fmt.Sprintf("sudo chmod +x %s", scriptHostCopy)); err != nil { + return fmt.Errorf("failed to make rollback script executable: %w", err) + } + + localConfig := "./config.yaml" + logrus.Tracef("Downloading %s from VM to local machine: %s", updateConfig, updateConfig) + if err := ssh.ScpDownloadFile(cfg.VMConfig, vmIP, updateConfig, localConfig); err != nil { + return fmt.Errorf("failed to download update config file: %w", err) + } + + logrus.Tracef("Add postProvision step to local config file: %s", localConfig) + postProvisionCmd := exec.Command( + "yq", "eval", + ".scripts.postProvision += [{\"name\": \"mount-var\", \"runOn\": [\"ab-update\"], \"content\": \"mkdir -p $TARGET_ROOT/tmp/var && mount --bind /var $TARGET_ROOT/tmp/var\"}]", + "-i", localConfig) + if err := postProvisionCmd.Run(); err != nil { + return fmt.Errorf("failed to update postProvision scripts in config: %w", err) + } + + logrus.Tracef("Add postConfigure step to invoke rollback script to local config file: %s", localConfig) + postConfigureCmd := exec.Command( + "yq", "eval", + ".scripts.postConfigure += [{\"name\": \"trigger-rollback\", \"runOn\": [\"ab-update\"], \"path\": \""+scriptHostCopy+"\"}]", + "-i", localConfig) + if err := postConfigureCmd.Run(); err != nil { + return fmt.Errorf("failed to update postConfigure scripts in config: %w", err) + } + + // Set writableEtcOverlayHooks flag under internalParams to true, so that the script + // can create a new systemd service + logrus.Tracef("Set writableEtcOverlayHooks in local config file: %s", localConfig) + internalParamsCmd := exec.Command( + "yq", "eval", + ".internalParams.writableEtcOverlayHooks = true", + "-i", localConfig) + if err := internalParamsCmd.Run(); err != nil { + return fmt.Errorf("failed to set writableEtcOverlayHooks in config: %w", err) + } + + logrus.Tracef("Upload modified config file to VM: %s", updateConfig) + if err := ssh.ScpUploadFileWithSudo(cfg.VMConfig, vmIP, localConfig, updateConfig); err != nil { + return fmt.Errorf("failed to upload rollback script: %w", err) + } + return nil +} + +func killUpdateServer(port int) error { + logrus.Tracef("Kill process found using port %d", port) + cmd := exec.Command("lsof", "-ti", fmt.Sprintf("tcp:%d", port)) + var out bytes.Buffer + cmd.Stdout = &out + if err := cmd.Run(); err != nil { + // No process found is not an error for our use case + logrus.Tracef("No process found on port %d", port) + return nil + } + pids := strings.Fields(out.String()) + for _, pid := range pids { + logrus.Tracef("Kill process %v", pid) + killCmd := exec.Command("kill", "-9", pid) + _ = killCmd.Run() // Ignore errors for robustness + } + return nil +} + +func startNetListenAndWait(ctx context.Context, port int, partition string, artifactsDir string, startedChannel chan bool) error { + cmdPath := "bin/netlisten" + if _, err := os.Stat(cmdPath); os.IsNotExist(err) { + logrus.Error("bin/netlisten not found") + return fmt.Errorf("netlisten not found at %s: %w", cmdPath, err) + } + + cmdArgs := []string{ + "-p", fmt.Sprint(port), + "-s", fmt.Sprintf("%s/update-%s", artifactsDir, partition), + "--force-color", + "--full-logstream", fmt.Sprintf("logstream-full-update-%s.log", partition), + } + cmd := exec.CommandContext(ctx, cmdPath, cmdArgs...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + if err := cmd.Start(); err != nil { + return fmt.Errorf("failed to start netlisten for port %d: %w", port, err) + } + + // Signal that netlisten has started + startedChannel <- true + + // Wait for the command to finish + if err := cmd.Wait(); err != nil { + return fmt.Errorf("netlisten for port %d failed: %w", port, err) + } + logrus.Tracef("netlisten for port %d exited", port) + + return nil +} + +func validateRollback(cfg config.VMConfig, vmIP string) error { + // Get host status, but ensure this is done **after** trident.service runs + hostStatusStr, err := ssh.SshCommand(cfg, vmIP, "set -o pipefail; sudo systemd-run --pipe --property=After=trident.service trident get") + if err != nil { + return fmt.Errorf("failed to get host status: %w", err) + } + + // Parse the host status yaml + hostStatus := make(map[string]interface{}) + if err = yaml.Unmarshal([]byte(hostStatusStr), &hostStatus); err != nil { + return fmt.Errorf("failed to unmarshal YAML output: %w", err) + } + + // Validate that lastError.category is set to "servicing" + category, ok := hostStatus["lastError"].(map[interface{}]interface{})["category"].(string) + if ok && category != "servicing" { + logrus.Tracef("Host status: %s", hostStatusStr) + logrus.Errorf("Category of last error is not 'servicing', but '%s'", category) + return fmt.Errorf("category of last error is not 'servicing', but '%s'", category) + } + + // Validate that lastError.error contains the expected content + error, ok := hostStatus["lastError"].(map[interface{}]interface{})["error"].(string) + if ok && !strings.Contains(error, "!ab-update-reboot-check") { + logrus.Errorf("Type of last error is not '!ab-update-reboot-check', but '%s'", error) + return fmt.Errorf("type of last error is not '!ab-update-reboot-check', but '%s'", error) + } + + // Validate that lastError.message matches the expected format + message, ok := hostStatus["lastError"].(map[interface{}]interface{})["message"].(string) + if ok && !regexp.MustCompile(`^A/B update failed as host booted from .+ instead of the expected device .+$`).MatchString(message) { + logrus.Errorf("Message of last error does not match the expected format: '%s'", message) + return fmt.Errorf("message of last error does not match the expected format: '%s'", message) + } + + logrus.Info("Rollback validation succeeded") + return nil +} + +func checkActiveVolume(cfg config.VMConfig, vmIP string, expectedVolume string) error { + _, err := utils.Retry( + time.Second*600, + time.Second, + func(attempt int) (*bool, error) { + logrus.Tracef("Checking active volume (attempt %d)", attempt) + hostStatusStr, err := ssh.SshCommandWithRetries(cfg, vmIP, "sudo trident get", 5, 5) + if err != nil { + return nil, fmt.Errorf("failed to get host status: %w", err) + } + logrus.Tracef("Retrieved host status") + hostStatus := make(map[string]interface{}) + if err = yaml.Unmarshal([]byte(hostStatusStr), &hostStatus); err != nil { + return nil, fmt.Errorf("failed to unmarshal YAML output: %w", err) + } + logrus.Tracef("Parsed host status") + if hostStatus["servicingState"] != "provisioned" { + return nil, fmt.Errorf("trident state is not 'provisioned'") + } + logrus.Tracef("Host satus servicingState is 'provisioned'") + hsActiveVol := hostStatus["abActiveVolume"] + if hsActiveVol != expectedVolume { + return nil, fmt.Errorf("expected active volume '%s', got '%s'", expectedVolume, hsActiveVol) + } + logrus.Infof("Active volume '%s' matches expected volume '%s'", hsActiveVol, expectedVolume) + return nil, nil + }, + ) + return err +} diff --git a/tools/storm/servicing/tests/vm.go b/tools/storm/servicing/tests/vm.go new file mode 100644 index 000000000..9f9d05d60 --- /dev/null +++ b/tools/storm/servicing/tests/vm.go @@ -0,0 +1,68 @@ +package tests + +import ( + "fmt" + "tridenttools/storm/servicing/utils/config" + "tridenttools/storm/utils" + + "github.com/sirupsen/logrus" +) + +func CheckDeployment(cfg config.ServicingConfig) error { + logrus.Tracef("Get VM IP address(es)") + vmIPs, err := utils.GetAllVmIPAddresses(cfg) + if err != nil { + return fmt.Errorf("failed to get VM IP addresses: %w", err) + } + if len(vmIPs) == 0 { + return fmt.Errorf("no VM IP addresses found") + } + logrus.Infof("Found VM IP address(es): %v", vmIPs) + + // Help diagnose https://dev.azure.com/mariner-org/ECF/_workitems/edit/11273 and + // fail explicitly if multiple IPs are found + if cfg.VMConfig.Platform == config.PlatformQEMU { + if len(vmIPs) > 1 { + logrus.Errorf("Multiple IPs found: %v", vmIPs) + logrus.Error("Multiple IPs found, expected only one IP address") + return fmt.Errorf("multiple IPs found, expected only one IP address") + } + } + + logrus.Tracef("Check if VM is reachable and has expected active volume") + if err := checkActiveVolume(cfg.VMConfig, vmIPs[0], cfg.TestConfig.ExpectedVolume); err != nil { + return fmt.Errorf("failed to check active volume '%s': %w", cfg.TestConfig.ExpectedVolume, err) + } + + return nil +} + +func DeployVM(cfg config.ServicingConfig) error { + if cfg.VMConfig.Platform == config.PlatformQEMU { + logrus.Tracef("Deploying VM on QEMU platform with name '%s'", cfg.VMConfig.Name) + if err := cfg.QemuConfig.DeployQemuVM(cfg.VMConfig.Name, cfg.TestConfig.ArtifactsDir, cfg.TestConfig.OutputPath, cfg.TestConfig.Verbose); err != nil { + return fmt.Errorf("failed to deploy qemu vm: %w", err) + } + } else if cfg.VMConfig.Platform == config.PlatformAzure { + logrus.Tracef("Deploying VM on Azure platform with name '%s'", cfg.VMConfig.Name) + if err := cfg.AzureConfig.DeployAzureVM(cfg.VMConfig.Name, cfg.VMConfig.User, cfg.TestConfig.BuildId); err != nil { + return fmt.Errorf("failed to deploy azure vm: %w", err) + } + } + return nil +} + +func CleanupVM(cfg config.ServicingConfig) error { + if cfg.VMConfig.Platform == config.PlatformAzure { + if err := cfg.AzureConfig.CleanupAzureVM(); err != nil { + return fmt.Errorf("failed to cleanup Azure VM: %w", err) + } + } else if cfg.VMConfig.Platform == config.PlatformQEMU { + if err := cfg.QemuConfig.CleanupQemuVM(cfg.VMConfig.Name); err != nil { + return fmt.Errorf("failed to cleanup QEMU VM: %w", err) + } + } + killUpdateServer(cfg.TestConfig.UpdatePortA) + killUpdateServer(cfg.TestConfig.UpdatePortB) + return nil +} diff --git a/tools/storm/servicing/trident.go b/tools/storm/servicing/trident.go new file mode 100644 index 000000000..51fb1c8db --- /dev/null +++ b/tools/storm/servicing/trident.go @@ -0,0 +1,138 @@ +package servicing + +import ( + "fmt" + "os" + "path/filepath" + "tridenttools/storm/servicing/tests" + "tridenttools/storm/servicing/utils/azure" + "tridenttools/storm/servicing/utils/config" + "tridenttools/storm/servicing/utils/qemu" + + "storm" + + "github.com/sirupsen/logrus" +) + +type TridentServicingScenario struct { + args TridentServicingScenarioArgs +} + +type TridentServicingScenarioArgs struct { + config.TestConfig `embed:""` + config.VMConfig `embed:""` + qemu.QemuConfig `embed:""` + azure.AzureConfig `embed:""` + TestCaseToRun string `help:"Name of the test case to run. If not specified, all test cases will be run." default:"all"` +} + +func (s *TridentServicingScenario) Name() string { + return "servicing" +} + +func (s *TridentServicingScenario) Args() any { + return &s.args +} + +func (s *TridentServicingScenario) Tags() []string { + return []string{} +} + +func (s *TridentServicingScenario) StagePaths() []string { + return []string{} +} + +func (s *TridentServicingScenario) RegisterTestCases(r storm.TestRegistrar) error { + r.RegisterTestCase("publish-sig-image", s.publishSigImage) + r.RegisterTestCase("deploy-vm", s.deployVm) + r.RegisterTestCase("check-deployment", s.checkDeployment) + r.RegisterTestCase("update-loop", s.updateLoop) + r.RegisterTestCase("rollback", s.rollback) + r.RegisterTestCase("collect-logs", s.collectLogs) + r.RegisterTestCase("cleanup-vm", s.cleanupVm) + return nil +} + +func (s *TridentServicingScenario) RequiredFiles() []string { + return nil +} + +func (s TridentServicingScenario) Setup(ctx storm.SetupCleanupContext) error { + return nil +} + +func (h *TridentServicingScenario) Cleanup(ctx storm.SetupCleanupContext) error { + if h.args.TestConfig.ForceCleanup { + // Best effort to clean up azure resources in case there was a failure + tests.CleanupVM(config.ServicingConfig{ + TestConfig: h.args.TestConfig, + VMConfig: h.args.VMConfig, + QemuConfig: h.args.QemuConfig, + AzureConfig: h.args.AzureConfig, + }) + } + return nil +} + +func (h *TridentServicingScenario) runTestCase(tc storm.TestCase, testFunc func(config.ServicingConfig) error) error { + if tc.Name() != h.args.TestCaseToRun && h.args.TestCaseToRun != "all" { + tc.Skip(fmt.Sprintf("Test case '%s' does not align to TestCaseToRun '%s'", tc.Name(), h.args.TestCaseToRun)) + } else { + logrus.Infof("Running test case '%s'", tc.Name()) + // create test-specific output directory + testCaseSpecificConfig := h.args.TestConfig + testCaseSpecificConfig.OutputPath = h.args.TestConfig.OutputPath + if testCaseSpecificConfig.OutputPath != "" { + testCaseSpecificConfig.OutputPath = filepath.Join(testCaseSpecificConfig.OutputPath, tc.Name()) + if err := os.MkdirAll(testCaseSpecificConfig.OutputPath, 0755); err != nil { + tc.FailFromError(err) + } + } + err := testFunc(config.ServicingConfig{ + TestConfig: testCaseSpecificConfig, + VMConfig: h.args.VMConfig, + QemuConfig: h.args.QemuConfig, + AzureConfig: h.args.AzureConfig, + }) + if err != nil { + logrus.Infof("test case '%s' failed", tc.Name()) + tc.FailFromError(err) + } + logrus.Infof("test case '%s' passed", tc.Name()) + } + return nil + +} + +func (h *TridentServicingScenario) deployVm(tc storm.TestCase) error { + return h.runTestCase(tc, tests.DeployVM) +} + +func (h *TridentServicingScenario) checkDeployment(tc storm.TestCase) error { + return h.runTestCase(tc, tests.CheckDeployment) +} + +func (h *TridentServicingScenario) updateLoop(tc storm.TestCase) error { + return h.runTestCase(tc, tests.UpdateLoop) +} + +func (h *TridentServicingScenario) rollback(tc storm.TestCase) error { + return h.runTestCase(tc, tests.Rollback) +} + +func (h *TridentServicingScenario) collectLogs(tc storm.TestCase) error { + return h.runTestCase(tc, tests.FetchLogs) +} + +func (h *TridentServicingScenario) cleanupVm(tc storm.TestCase) error { + return h.runTestCase(tc, tests.CleanupVM) +} + +func (h *TridentServicingScenario) publishSigImage(tc storm.TestCase) error { + if h.args.Platform != config.PlatformAzure { + tc.Skip("Test case 'publish-sig-image' is only applicable for Azure platform") + return nil // No action needed for non-Azure platforms + } + + return h.runTestCase(tc, tests.PublishSigImage) +} diff --git a/tools/storm/servicing/utils/azure/azure.go b/tools/storm/servicing/utils/azure/azure.go new file mode 100644 index 000000000..61ba3ddd5 --- /dev/null +++ b/tools/storm/servicing/utils/azure/azure.go @@ -0,0 +1,707 @@ +// Package storm provides helpers for Trident loop-update Storm tests. +// This file contains helpers converted from Bash scripts in scripts/loop-update. +package azure + +import ( + "bytes" + "fmt" + "os" + "os/exec" + "path/filepath" + "regexp" + "sort" + "strconv" + "strings" + "time" + + "github.com/sirupsen/logrus" +) + +type AzureConfig struct { + Subscription string `help:"Azure subscription" default:"b8a0db63-c5fa-4198-8e2a-f9d6ff52465e"` + StorageAccountResourceGroup string `help:"Azure resource group" default:"azlinux_bmp_dev"` + StorageAccount string `help:"Azure storage account for VM artifacts" default:"azlinuxbmpdev"` + StorageContainerName string `help:"Azure storage continer for VM artifacts" default:""` + WhoAmI string `help:"User who is running the tests, used for tagging resources" default:""` + Region string `help:"Azure region" default:"eastus2"` + SubnetId string `help:"Azure subnet ID" default:"/subscriptions/04cdc145-a4f9-42d4-9868-c46d23d0c63f/resourceGroups/trident-vm_servicing-azure-vnet/providers/Microsoft.Network/virtualNetworks/poolpeeringvnet/subnets/default"` + SshPublicKeyPath string `help:"Path to SSH public key" default:"~/.ssh/id_rsa.pub"` + GalleryName string `help:"Azure Shared Image Gallery name" default:""` + GalleryResourceGroup string `help:"Azure Shared Image Gallery resource group" default:""` + ImageDefinition string `help:"Azure Shared Image Gallery image definition" default:"trident-vm-grub-verity-azure-testimage"` + Offer string `help:"Azure offer for the VM" default:"trident-vm-grub-verity-azure-offer"` + Size string `help:"Azure VM size" default:"Standard_D2ds_v5"` + TestResourceGroup string `help:"Azure resource group for the VM" default:""` +} + +func (cfg AzureConfig) GetStorageAccountUrl() string { + return fmt.Sprintf("https://%s.blob.core.windows.net", cfg.StorageAccount) +} + +func (cfg AzureConfig) GetStorageAccountId() string { + return fmt.Sprintf("/subscriptions/%s/resourceGroups/%s/providers/Microsoft.Storage/storageAccounts/%s", cfg.Subscription, cfg.StorageAccountResourceGroup, cfg.StorageAccount) +} + +func (cfg AzureConfig) ImageVersionExists(imageVersion string) bool { + _, err := cfg.CallAzCli( + []string{ + "sig", "image-version", "show", + "--resource-group", cfg.GetGalleryResourceGroup(), + "--gallery-name", cfg.GetGalleryName(), + "--gallery-image-definition", cfg.ImageDefinition, + "--gallery-image-version", imageVersion, + }, + false, + ) + return err == nil +} + +func (cfg AzureConfig) CreateImageVersion(imageVersion string, storageAccountResourceId string, storageBlobEndpoint string) error { + logrus.Tracef("Create image version in Azure Shared Image Gallery") + output, err := cfg.CallAzCli( + []string{ + "sig", "image-version", "create", + "--resource-group", cfg.GetGalleryResourceGroup(), + "--gallery-name", cfg.GetGalleryName(), + "--gallery-image-definition", cfg.ImageDefinition, + "--gallery-image-version", imageVersion, + "--target-regions", cfg.Region, + "--location", cfg.Region, + "--replication-mode", "Shallow", + "--os-vhd-storage-account", storageAccountResourceId, + "--os-vhd-uri", storageBlobEndpoint, + }, + true, + ) + if err != nil { + logrus.Tracef("Failed to create image version in Azure SIG (%v): %s", err, output) + return fmt.Errorf("failed to create image version in Azure SIG: %w", err) + } + + return nil +} + +func (cfg AzureConfig) DeployAzureVM(vmName string, user string, buildId string) error { + if err := cfg.SetSubscription(); err != nil { + return fmt.Errorf("failed to set Azure subscription: %w", err) + } + + err := cfg.EnsureGroupExists(cfg.GetTestResourceGroup(), true) + if err != nil { + return fmt.Errorf("failed to create Azure resource group: %w", err) + } + + imageVersion := cfg.GetImageVersion(buildId, false) + + // Create the VM + vmCreateArgs := []string{ + "vm", "create", + "--resource-group", cfg.GetTestResourceGroup(), + "--name", vmName, + "--size", cfg.Size, + "--os-disk-size-gb", "60", + "--admin-username", user, + "--ssh-key-values", cfg.SshPublicKeyPath, + "--image", fmt.Sprintf("/subscriptions/%s/resourceGroups/%s/providers/Microsoft.Compute/galleries/%s/images/%s/versions/%s", + cfg.Subscription, cfg.GetGalleryResourceGroup(), cfg.GetGalleryName(), cfg.ImageDefinition, imageVersion), + "--location", cfg.Region, + "--security-type", "TrustedLaunch", + "--enable-secure-boot", "true", + "--enable-vtpm", "true", + "--no-wait", + } + if cfg.SubnetId != "" { + vmCreateArgs = append(vmCreateArgs, "--subnet", cfg.SubnetId) + } + logrus.Tracef("Creating Azure VM with args: %v", vmCreateArgs) + + createVmOutput, err := cfg.CallAzCli(vmCreateArgs, true) + logrus.Tracef("Azure VM creation (%v): %s", err, createVmOutput) + if err != nil { + return fmt.Errorf("failed to create Azure VM (%w):\n%s", err, createVmOutput) + } + + for { + out, err := cfg.CallAzCli( + []string{"vm", "boot-diagnostics", "enable", "--name", vmName, "--resource-group", cfg.GetTestResourceGroup()}, + true, + ) + if err == nil { + break + } + logrus.Tracef("Failed to enable boot diagnostics for VM '%s': %s", vmName, out) + time.Sleep(1 * time.Second) // Retry after a short delay + } + + for { + out, err := cfg.CallAzCli( + []string{"vm", "boot-diagnostics", "get-boot-log", "--name", vmName, "--resource-group", cfg.GetTestResourceGroup()}, + true, + ) + if err == nil { + if strings.Contains(string(out), "BlobNotFound") { + time.Sleep(5 * time.Second) // Wait before retrying + continue // Retry until the boot log is available + } + break + } + logrus.Tracef("Failed to get boot diagnostics log for VM '%s': %s", vmName, out) + time.Sleep(5 * time.Second) // Retry after a short delay + } + + for { + out, err := cfg.CallAzCli( + []string{"vm", "show", "-d", "-g", cfg.GetTestResourceGroup(), "-n", vmName, "--query", "provisioningState", "-o", "tsv"}, + false, + ) + if err != nil || strings.TrimSpace(string(out)) != "Succeeded" { + logrus.Tracef("VM '%s' provisioning state is not 'Succeeded': %s", vmName, out) + time.Sleep(1 * time.Second) // Wait before retrying + continue // Retry until the VM is successfully provisioned + } + break + } + return nil +} + +func (cfg AzureConfig) CleanupAzureVM() error { + if err := cfg.SetSubscription(); err != nil { + return fmt.Errorf("failed to set Azure subscription: %w", err) + } + if err := cfg.DeleteGroup(cfg.GetTestResourceGroup()); err != nil { + return fmt.Errorf("failed to delete Azure resource group: %w", err) + } + return nil +} + +func (cfg AzureConfig) PublishSigImage(artifactsDir string, buildId string) error { + if err := cfg.SetSubscription(); err != nil { + return fmt.Errorf("failed to set Azure subscription: %w", err) + } + + now := time.Now() + currentDate := now.Format("20060102") + currentTime := now.Format("150405") + + storageAccountUrl := cfg.GetStorageAccountUrl() + storageAccountResourceId := cfg.GetStorageAccountId() + storageContainerName := cfg.GetStorageContainerName() + imagePath := filepath.Join(artifactsDir, "trident-vm-grub-verity-azure-testimage.vhd") + + imageVersion := cfg.GetImageVersion(buildId, true) + logrus.Infof("Using image version %s", imageVersion) + + logrus.Tracef("Check if image version %s already exists", imageVersion) + _, err := cfg.CallAzCli( + []string{ + "sig", "image-version", "show", + "--resource-group", cfg.GetGalleryResourceGroup(), + "--gallery-name", cfg.GetGalleryName(), + "--gallery-image-definition", cfg.ImageDefinition, + "--gallery-image-version", imageVersion}, + false, + ) + if err == nil { + logrus.Infof("Image version %s already exists. Exiting...", imageVersion) + return nil // Image version already exists, no need to proceed + } + logrus.Tracef("Image version %s does not exist", imageVersion) + + logrus.Tracef("Prepare image for Azure Shared Image Gallery") + err = cfg.PrepareSigImage() + if err != nil { + return fmt.Errorf("failed to prepare image for Azure: %w", err) + } + + storageBlobName := fmt.Sprintf("%s.%s-%s.vhd", currentDate, currentTime, imageVersion) + storageBlobEndpoint := fmt.Sprintf("%s/%s/%s", storageAccountUrl, storageContainerName, storageBlobName) + + // Get the path to the VHD file + logrus.Tracef("Resize VHD image for upload") + if err := cfg.ResizeImage(artifactsDir, imagePath); err != nil { + return fmt.Errorf("failed to resize image: %w", err) + } + + logrus.Tracef("Ensure azcopy is installed") + if err := cfg.EnsureAzcopyExists(); err != nil { + return fmt.Errorf("failed to ensure azcopy exists: %w", err) + } + + // Upload the image artifact to Steamboat Storage Account + logrus.Tracef("Upload image to Azure Storage Blob: %s", storageBlobEndpoint) + if azcopyOutput, err := exec.Command("azcopy", "copy", imagePath, storageBlobEndpoint).CombinedOutput(); err != nil { + logrus.Tracef("Failed to upload image to Azure Storage Blob (%v): %s", err, azcopyOutput) + return fmt.Errorf("failed to upload image to Azure Storage: %w", err) + } + + logrus.Tracef("Create image version in Azure Shared Image Gallery") + createImageVersionOutput, err := cfg.CallAzCli( + []string{ + "sig", "image-version", "create", + "--resource-group", cfg.GetGalleryResourceGroup(), + "--gallery-name", cfg.GetGalleryName(), + "--gallery-image-definition", cfg.ImageDefinition, + "--gallery-image-version", imageVersion, + "--target-regions", cfg.Region, + "--location", cfg.Region, + "--replication-mode", "Shallow", + "--os-vhd-storage-account", storageAccountResourceId, + "--os-vhd-uri", storageBlobEndpoint, + }, + true, + ) + if err != nil { + logrus.Tracef("Failed to create image version in Azure SIG (%v): %s", err, createImageVersionOutput) + return fmt.Errorf("failed to create image version in Azure SIG: %w", err) + } + + return nil +} + +func (cfg AzureConfig) EnsureAzcopyExists() error { + // if ! which azcopy; then + if _, err := exec.LookPath("azcopy"); err != nil { + logrus.Info("azcopy not found, installing...") + // Install az-copy dependency + pipelineAgentOs, err := os.ReadFile("/etc/os-release") + if err != nil { + return fmt.Errorf("failed to read /etc/os-release: %w", err) + } + pipelineAgentOsId := "" + for _, lines := range strings.Split(string(pipelineAgentOs), "\n") { + if strings.HasPrefix(lines, "ID=") { + pipelineAgentOsId = strings.Trim(strings.Split(lines, "=")[1], "\"") + break + } + } + if pipelineAgentOsId == "" { + return fmt.Errorf("failed to determine OS ID from /etc/os-release") + } + + pipelineAgentOsVersion := "" + for _, lines := range strings.Split(string(pipelineAgentOs), "\n") { + if strings.HasPrefix(lines, "VERSION_ID=") { + pipelineAgentOsVersion = strings.Trim(strings.Split(lines, "=")[1], "\"") + break + } + } + if pipelineAgentOsVersion == "" { + return fmt.Errorf("failed to determine OS version from /etc/os-release") + } + + azcopyDownloadUrl := fmt.Sprintf("https://packages.microsoft.com/config/%s/%s/packages-microsoft-prod.deb", pipelineAgentOsId, pipelineAgentOsVersion) + if err := exec.Command("curl", "-sSL", "-O", azcopyDownloadUrl).Run(); err != nil { + logrus.Errorf("Failed to download the debian package repo while attempting to install azcopy: %v", err) + logrus.Error("Suggestion: Are you using a new, non-ubuntu, pipeline agent? If yes, add azcopy installation logic for the new build agent.") + return fmt.Errorf("failed to download the debian package repo while attempting to install azcopy: %w", err) + } + + if err := exec.Command("sudo", "dpkg", "-i", "packages-microsoft-prod.deb").Run(); err != nil { + return fmt.Errorf("failed to install debian package while attempting to install azcopy: %w", err) + } + if err := os.Remove("packages-microsoft-prod.deb"); err != nil { + return fmt.Errorf("failed to remove debian package file: %w", err) + } + if err := exec.Command("sudo", "apt-get", "update", "-y").Run(); err != nil { + return fmt.Errorf("failed to update package list while attempting to install azcopy: %w", err) + } + if err := exec.Command("sudo", "apt-get", "install", "azcopy", "-y").Run(); err != nil { + return fmt.Errorf("failed to install azcopy: %w", err) + } + out, err := exec.Command("azcopy", "--version").Output() + if err != nil { + return fmt.Errorf("failed to check azcopy version: %w", err) + } + logrus.Infof("azcopy version: %s", strings.TrimSpace(string(out))) + } + return nil +} + +func (cfg AzureConfig) ResizeImage(artifactsDir string, imagePath string) error { + // VHD images on Azure must have a virtual size aligned to 1MB. https://learn.microsoft.com/en-us/azure/virtual-machines/linux/create-upload-generic#resize-vhds + MB := 1024 * 1024 + + rawFile := filepath.Join(artifactsDir, "resize.raw") + // Convert to raw format + if out, err := exec.Command("sudo", "qemu-img", "convert", "-f", "vpc", "-O", "raw", imagePath, rawFile).CombinedOutput(); err != nil { + logrus.Tracef("Failed to convert VHD to raw: %v\n%s", err, out) + return fmt.Errorf("failed to convert VHD to raw: %w", err) + } + + // Get the size of the raw image + out, err := exec.Command("qemu-img", "info", "-f", "raw", "--output", "json", rawFile).Output() + if err != nil { + return fmt.Errorf("failed to get raw image size: %w", err) + } + re := regexp.MustCompile(`"virtual-size":\s*([0-9]+)`) + matches := re.FindSubmatch(out) + if len(matches) < 2 { + return fmt.Errorf("failed to parse raw image size from output: %s", out) + } + size, err := strconv.Atoi(string(matches[1])) + if err != nil { + return fmt.Errorf("failed to convert raw image size to integer: %w", err) + } + + roundedSize := ((size + MB - 1) / MB) * MB + logrus.Infof("Rounded Size = %d", roundedSize) + + // Resize the raw image to the rounded size + if out, err := exec.Command("sudo", "qemu-img", "resize", rawFile, fmt.Sprintf("%d", roundedSize)).CombinedOutput(); err != nil { + logrus.Tracef("Failed to resize raw image: %v\n%s", err, out) + return fmt.Errorf("failed to resize raw image: %w", err) + } + // Convert back to original format + if out, err := exec.Command("sudo", "qemu-img", "convert", "-f", "raw", "-o", "subformat=fixed,force_size", "-O", "vpc", rawFile, imagePath).CombinedOutput(); err != nil { + logrus.Tracef("Failed to convert raw back to VHD: %v\n%s", err, out) + return fmt.Errorf("failed to convert raw back to VHD: %w", err) + } + // Remove the temporary raw file + if err := exec.Command("rm", rawFile).Run(); err != nil { + return fmt.Errorf("failed to remove temporary raw file: %w", err) + } + return nil +} + +func (cfg AzureConfig) PrepareSigImage() error { + logrus.Tracef("Set Azure subscription") + if err := cfg.SetSubscription(); err != nil { + return fmt.Errorf("failed to set Azure subscription: %w", err) + } + logrus.Tracef("Ensure Azure resource group '%s' exists", cfg.GetTestResourceGroup()) + if err := cfg.EnsureGroupExists(cfg.GetTestResourceGroup(), false); err != nil { + return fmt.Errorf("failed to ensure Azure resource group (%s) exists: %w", cfg.GetTestResourceGroup(), err) + } + logrus.Tracef("Ensure Azure gallery resource group '%s' exists", cfg.GetGalleryResourceGroup()) + if err := cfg.EnsureGroupExists(cfg.GetGalleryResourceGroup(), false); err != nil { + return fmt.Errorf("failed to ensure Azure gallery resource group (%s) exists: %w", cfg.GetGalleryResourceGroup(), err) + } + // Ensure storage account exists + if err := cfg.EnsureStorageAccountExists(); err != nil { + return fmt.Errorf("failed to ensure Azure storage account exists: %w", err) + } + // Ensure storage container exists + if err := cfg.EnsureStorageContainerExists(); err != nil { + return fmt.Errorf("failed to ensure Azure storage container exists: %w", err) + } + // Ensure gallery exists + if err := cfg.EnsureGalleryExists(); err != nil { + return fmt.Errorf("failed to ensure Azure image gallery exists: %w", err) + } + + // Ensure the image-definition exists + if err := cfg.EnsureImageDefinitionExists(); err != nil { + return fmt.Errorf("failed to ensure Azure image definition exists: %w", err) + } + + return nil +} + +func (cfg AzureConfig) GetWhoAmI() string { + if cfg.WhoAmI != "" { + return cfg.WhoAmI + } + whoami, err := exec.Command("whoami").Output() + if err != nil { + panic(fmt.Sprintf("Failed to get current user: %v", err)) + } + return strings.TrimSpace(string(whoami)) +} + +func (cfg AzureConfig) GetTestResourceGroup() string { + if cfg.TestResourceGroup != "" { + return cfg.TestResourceGroup + } + return fmt.Sprintf("%s-test", cfg.GetGalleryResourceGroup()) +} + +func (cfg AzureConfig) GetGalleryName() string { + if cfg.GalleryName != "" { + return cfg.GalleryName + } + return fmt.Sprintf("%s_trident_gallery", cfg.GetWhoAmI()) +} + +func (cfg AzureConfig) GetGalleryResourceGroup() string { + if cfg.GalleryResourceGroup != "" { + return cfg.GalleryResourceGroup + } + return fmt.Sprintf("%s-trident-rg", cfg.GetWhoAmI()) +} + +func (cfg AzureConfig) GetStorageContainerName() string { + if cfg.StorageContainerName != "" { + return cfg.StorageContainerName + } + return fmt.Sprintf("%s-test", cfg.GetWhoAmI()) +} + +func (cfg AzureConfig) GetAllVmIPAddresses(vmName string, buildId string) ([]string, error) { + ipType := "publicIps" + if buildId != "" { + ipType = "privateIps" // Use private IPs for build tests + } + logrus.Tracef("Fetching Azure VM IP addresses for type '%s'", ipType) + cmd := exec.Command("az", "vm", "show", "-d", "-g", cfg.GetTestResourceGroup(), "-n", vmName, "--query", ipType, "-o", "tsv") + out, err := cmd.Output() + if err != nil { + fullShowCmd := exec.Command("az", "vm", "show", "-d", "-g", cfg.GetTestResourceGroup(), "-n", vmName) + fullShowCmdOut, fullShowCmdErr := fullShowCmd.CombinedOutput() + logrus.Tracef("Failed to get Azure VM IP addresses, show vm: %v\n%s", fullShowCmdErr, fullShowCmdOut) + return nil, fmt.Errorf("failed to get Azure VM IP (%w): %s", err, out) + } + return strings.Split(strings.TrimSpace(string(out)), "\n"), nil +} + +func (cfg AzureConfig) GetLatestVersion() string { + // Get existing image versions from Azure Shared Image Gallery + out, err := exec.Command("az", "sig", "image-version", "list", + "--resource-group", cfg.GetGalleryResourceGroup(), + "--gallery-name", cfg.GetGalleryName(), + "--gallery-image-definition", cfg.ImageDefinition, + "--query", "[].name", + "-o", "tsv").Output() + if err != nil { + logrus.Errorf("Failed to get latest image version: %v", err) + return "" + } + versions := strings.Split(strings.TrimSpace(string(out)), "\n") + if len(versions) == 0 { + logrus.Info("No image versions found") + return "" + } + // Sort versions by semver + sort.Slice(versions, func(i, j int) bool { + v1 := strings.Split(versions[i], ".") + v2 := strings.Split(versions[j], ".") + if len(v1) != 3 || len(v2) != 3 { + return false // Invalid version format + } + for k := 0; k < 3; k++ { + if v1[k] != v2[k] { + return v1[k] < v2[k] + } + } + return false // Versions are equal + }) + return versions[len(versions)-1] // Return the latest version +} + +func (cfg AzureConfig) GetImageVersion(buildId string, increment bool) string { + imageVersion := "0.0.1" + if buildId == "" { + // If no build ID is provided, get the latest version + imageVersion = cfg.GetLatestVersion() + if imageVersion == "" { + // If no versions found, use a default version + imageVersion = "0.0.1" + } else if increment { + // If version was found and increment is true, + // increment the patch version + parts := strings.Split(imageVersion, ".") + if len(parts) != 3 { + logrus.Errorf("Invalid image version format: %s", imageVersion) + return "" + } + major, _ := strconv.Atoi(parts[0]) + minor, _ := strconv.Atoi(parts[1]) + patch, _ := strconv.Atoi(parts[2]) + patch++ // Increment the patch version + imageVersion = fmt.Sprintf("%d.%d.%d", major, minor, patch) + } + } else { + // Use the build ID as the patch version + imageVersion = fmt.Sprintf("0.0.%s", buildId) + } + + logrus.Infof("Image version: %s", imageVersion) + return imageVersion +} + +func (cfg AzureConfig) SetSubscription() error { + if _, err := cfg.CallAzCli([]string{"account", "set", "--subscription", cfg.Subscription}, false); err != nil { + return fmt.Errorf("failed to set Azure subscription: %w", err) + } + return nil +} + +func (cfg AzureConfig) EnsureGroupExists(groupName string, deleteExisting bool) error { + findGroupCmdOutput, err := cfg.CallAzCli([]string{"group", "exists", "-n", groupName}, false) + if err != nil { + return fmt.Errorf("failed to check if group exists: %w", err) + } + + if strings.TrimSpace(string(findGroupCmdOutput)) == "true" { + if !deleteExisting { + return nil + } + + // Resource group exists, delete it + if deleteOutput, err := cfg.CallAzCli([]string{"group", "delete", "-n", groupName, "-y"}, true); err != nil { + return fmt.Errorf("failed to delete Azure resource group (%w):\n%s", err, deleteOutput) + } + } + + if groupCreateOutput, err := cfg.CallAzCli([]string{"group", "create", "-n", groupName, "-l", cfg.Region, "--tags", fmt.Sprintf("creationTime=%d", time.Now().Unix())}, true); err != nil { + return fmt.Errorf("failed to create Azure resource group (%w):\n%s", err, groupCreateOutput) + } + return nil +} + +func (cfg AzureConfig) DeleteGroup(groupName string) error { + findGroupCmdOutput, err := cfg.CallAzCli([]string{"group", "exists", "-n", groupName}, false) + if err != nil { + return fmt.Errorf("failed to check if group exists: %w", err) + } + if strings.TrimSpace(string(findGroupCmdOutput)) != "true" { + return nil + } + if deleteOutput, err := cfg.CallAzCli([]string{"group", "delete", "-n", groupName, "-y"}, true); err != nil { + return fmt.Errorf("failed to delete Azure resource group (%w):\n%s", err, deleteOutput) + } + return nil +} + +func (cfg AzureConfig) EnsureStorageAccountExists() error { + // Ensure storage account exists + storageAccountResourceId := fmt.Sprintf("/subscriptions/%s/resourceGroups/%s/providers/Microsoft.Storage/storageAccounts/%s", + cfg.Subscription, cfg.StorageAccountResourceGroup, cfg.StorageAccount) + + logrus.Tracef("Ensure Azure storage account '%s' exists in resource group '%s'", cfg.StorageAccount, cfg.StorageAccountResourceGroup) + storageAccountOutput, err := cfg.CallAzCli( + []string{ + "storage", "account", "show", + "--ids", storageAccountResourceId, + }, + false, + ) + if err != nil || strings.TrimSpace(string(storageAccountOutput)) == "" { + logrus.Infof("Could not find storage account '%s' in the expected location. Creating the storage account.", cfg.StorageAccount) + checkNameOut, err := cfg.CallAzCli( + []string{"storage", "account", "check-name", "--name", cfg.StorageAccount, "--query", "nameAvailable"}, + false, + ) + if err != nil || strings.TrimSpace(string(checkNameOut)) != "false" { + return fmt.Errorf("storage account name %s is not available", cfg.StorageAccount) + } + createStorageOutput, err := cfg.CallAzCli( + []string{ + "storage", "account", "create", + "-g", cfg.StorageAccountResourceGroup, + "-n", cfg.StorageAccount, + "-l", cfg.Region, + "--allow-shared-key-access", "false", + }, + true, + ) + if err != nil { + logrus.Tracef("Failed to create Azure storage account '%s': %s", cfg.StorageAccount, createStorageOutput) + return fmt.Errorf("failed to create Azure storage account: %w", err) + } + } + return nil +} + +func (cfg AzureConfig) EnsureStorageContainerExists() error { + logrus.Tracef("Ensure Azure storage container '%s' exists in storage account '%s'", cfg.GetStorageContainerName(), cfg.StorageAccount) + containerExistsOutput, err := cfg.CallAzCli( + []string{ + "storage", "container", "exists", + "--account-name", cfg.StorageAccount, + "--name", cfg.GetStorageContainerName(), + "--auth-mode", "login", + }, + false, + ) + if err != nil || !strings.Contains(string(containerExistsOutput), `"exists": true`) { + logrus.Tracef("Container '%s' not found, creating in storage account '%s'...", cfg.GetStorageContainerName(), cfg.StorageAccount) + createContainerOutput, err := cfg.CallAzCli( + []string{ + "storage", "container", "create", + "--account-name", cfg.StorageAccount, + "--name", cfg.GetStorageContainerName(), + "--auth-mode", "login", + }, + true, + ) + if err != nil { + logrus.Tracef("Failed to create Azure storage container '%s': %s", cfg.GetStorageContainerName(), createContainerOutput) + return fmt.Errorf("failed to create Azure storage container: %w", err) + } + } + return nil +} + +func (cfg AzureConfig) EnsureGalleryExists() error { + logrus.Tracef("Ensure Azure image gallery '%s' exists in resource group '%s'", cfg.GetGalleryName(), cfg.GetGalleryResourceGroup()) + galleryExistsOutput, err := cfg.CallAzCli( + []string{ + "sig", "show", + "-r", cfg.GetGalleryName(), + "-g", cfg.GetGalleryResourceGroup(), + }, + false, + ) + if err != nil || strings.TrimSpace(string(galleryExistsOutput)) == "" { + logrus.Infof("Could not find image gallery '%s' in resource group '%s'. Creating the gallery.", cfg.GetGalleryName(), cfg.GetGalleryResourceGroup()) + createGalleryOutput, err := cfg.CallAzCli( + []string{ + "sig", "create", + "-g", cfg.GetGalleryResourceGroup(), + "-r", cfg.GetGalleryName(), + "-l", cfg.Region, + }, + true) + if err != nil { + logrus.Tracef("Failed to create Azure image gallery '%s': %s", cfg.GetGalleryName(), createGalleryOutput) + return fmt.Errorf("failed to create Azure image gallery: %w", err) + } + } + return nil +} + +func (cfg AzureConfig) EnsureImageDefinitionExists() error { + logrus.Tracef("Ensure Azure image definition '%s' exists in gallery '%s'", cfg.ImageDefinition, cfg.GetGalleryName()) + imageDefinitionExistsOutput, err := cfg.CallAzCli( + []string{ + "sig", "image-definition", "list", + "-r", cfg.GetGalleryName(), + "-g", cfg.GetGalleryResourceGroup(), + "--query", fmt.Sprintf("[?name=='%s'].name", cfg.ImageDefinition), + "-o", "tsv", + }, + false, + ) + if err != nil || strings.TrimSpace(string(imageDefinitionExistsOutput)) == "" { + logrus.Infof("Could not find image-definition '%s'. Creating definition '%s' in gallery '%s'...", cfg.ImageDefinition, cfg.ImageDefinition, cfg.GetGalleryName()) + createImageDefOutput, err := cfg.CallAzCli( + []string{ + "sig", "image-definition", "create", + "-i", cfg.ImageDefinition, + "--publisher", cfg.GetWhoAmI(), + "--offer", cfg.Offer, + "--sku", cfg.ImageDefinition, + "-r", cfg.GetGalleryName(), + "-g", cfg.GetGalleryResourceGroup(), + "--os-type", "Linux", + }, + true, + ) + if err != nil { + logrus.Tracef("Failed to create Azure image definition '%s': %s", cfg.ImageDefinition, createImageDefOutput) + return fmt.Errorf("failed to create Azure image definition: %w", err) + } + } + return nil +} + +func (cfg AzureConfig) CallAzCli(azArgs []string, combined bool) (string, error) { + cmd := exec.Command("az", azArgs...) + var b bytes.Buffer + cmd.Stdout = &b + if combined { + cmd.Stderr = &b + } + err := cmd.Run() + return b.String(), err +} diff --git a/tools/storm/servicing/utils/config/config.go b/tools/storm/servicing/utils/config/config.go new file mode 100644 index 000000000..f7ad1a736 --- /dev/null +++ b/tools/storm/servicing/utils/config/config.go @@ -0,0 +1,41 @@ +package config + +import ( + "tridenttools/storm/servicing/utils/azure" + "tridenttools/storm/servicing/utils/qemu" +) + +// VMPlatformType represents the test platform (qemu or azure) +type VMPlatformType string + +const ( + PlatformQEMU VMPlatformType = "qemu" + PlatformAzure VMPlatformType = "azure" +) + +type VMConfig struct { + Name string `help:"Name of the VM" default:"trident-vm-verity-test"` + Platform VMPlatformType `help:"Platform for the VM (qemu or azure)" default:"qemu"` + User string `help:"User to use for SSH connection" default:"testuser"` + SshPrivateKeyPath string `help:"Path to the SSH private key file" default:"~/.ssh/id_rsa"` +} + +type TestConfig struct { + ArtifactsDir string `help:"Directory containing artifacts for the VM" default:"/tmp"` + OutputPath string `help:"Path to the output directory for logs and artifacts" default:"./output"` + Verbose bool `help:"Enable verbose logging" default:"false"` + RetryCount int `help:"Number of retry attempts for updates" default:"3"` + RollbackRetryCount int `help:"Number of retry attempts for updates" default:"3"` + UpdatePortA int `help:"Port for the first update server" default:"8000"` + UpdatePortB int `help:"Port for the second update server" default:"8001"` + BuildId string `help:"Build ID for the VM" default:""` + ExpectedVolume string `help:"Expected active volume after update" default:"volume-a"` + ForceCleanup bool `help:"Force cleanup of VM when test finishes" default:"false"` +} + +type ServicingConfig struct { + VMConfig VMConfig + TestConfig TestConfig + QemuConfig qemu.QemuConfig + AzureConfig azure.AzureConfig +} diff --git a/tools/storm/servicing/utils/file/file.go b/tools/storm/servicing/utils/file/file.go new file mode 100644 index 000000000..d6985d792 --- /dev/null +++ b/tools/storm/servicing/utils/file/file.go @@ -0,0 +1,35 @@ +// Package storm provides helpers for Trident loop-update Storm tests. +// This file contains helpers converted from Bash scripts in scripts/loop-update. +package file + +import ( + "fmt" + "os" + "path/filepath" + "regexp" +) + +func FindFile(dir, pattern string) (string, error) { + // Find image file + regexPattern, e := regexp.Compile(pattern) + if e != nil { + return "", fmt.Errorf("failed to match pattern: %w", e) + } + + matchingFiles := make([]string, 0) + e = filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { + if err == nil && !info.IsDir() && regexPattern.MatchString(info.Name()) { + matchingFiles = append(matchingFiles, path) + } + return nil + }) + if e != nil { + return "", fmt.Errorf("failed to find file: %w", e) + } + if len(matchingFiles) < 1 { + return "", fmt.Errorf("file not found") + } else if len(matchingFiles) > 1 { + return "", fmt.Errorf("multiple files found: %v", matchingFiles) + } + return matchingFiles[0], nil +} diff --git a/tools/storm/servicing/utils/qemu/qemu.go b/tools/storm/servicing/utils/qemu/qemu.go new file mode 100644 index 000000000..5877f941c --- /dev/null +++ b/tools/storm/servicing/utils/qemu/qemu.go @@ -0,0 +1,451 @@ +// Package storm provides helpers for Trident loop-update Storm tests. +// This file contains helpers converted from Bash scripts in scripts/loop-update. +package qemu + +import ( + "bufio" + "fmt" + "io" + "net/url" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" + "time" + "tridenttools/storm/servicing/utils/file" + + "github.com/digitalocean/go-libvirt" + "github.com/sirupsen/logrus" +) + +type QemuConfig struct { + SecureBoot bool `help:"Enable secure boot for the VM" default:"false"` + SerialLog string `help:"Path to the serial log file" default:"/tmp/trident-vm-verity-test.log"` +} + +func (cfg QemuConfig) DeployQemuVM(vmName string, artifactsDir string, outputPath string, verbose bool) error { + logrus.Tracef("Deploying VM on QEMU platform with name '%s'", vmName) + + // Destroy and undefine any existing VM + if err := cfg.deleteLibvirtDomain(vmName); err != nil { + return fmt.Errorf("failed to delete existing domain '%s': %w", vmName, err) + } + + // Find image file + imageFile, err := file.FindFile(artifactsDir, "^trident-vm-.*-testimage.qcow2$") + if err != nil { + return fmt.Errorf("failed to find image file: %w", err) + } + logrus.Tracef("Found image file: %s", imageFile) + + bootImage := artifactsDir + "/booted.qcow2" + if err := exec.Command("cp", imageFile, bootImage).Run(); err != nil { + return fmt.Errorf("failed to copy image: %w", err) + } + logrus.Tracef("Copied image to boot image: %s", bootImage) + + err = cfg.createQemuVM(vmName, bootImage, true) + if err != nil { + return fmt.Errorf("failed to create VM: %w", err) + } + + // Wait for serial log + for { + if _, err := os.Stat(cfg.SerialLog); err == nil { + break + } + } + + logrus.Tracef("Check if VM is ready for login") + err = cfg.WaitForLogin(vmName, outputPath, verbose, 0) + if err != nil { + return fmt.Errorf("failed to wait for login after reboot: %w", err) + } + + return nil +} + +func (cfg QemuConfig) CleanupQemuVM(vmName string) error { + err := cfg.deleteLibvirtDomain(vmName) + if err != nil { + return fmt.Errorf("failed to cleanup vm '%s': %w", vmName, err) + } + return nil +} + +func (cfg QemuConfig) RebootQemuVm(vmName string, iteration int, outputPath string, verbose bool) error { + logrus.Tracef("Truncate log files before reboot") + if err := cfg.TruncateLog(vmName); err != nil { + return fmt.Errorf("failed to truncate log file: %w", err) + } + + lv, domain, err := getLibvirtDomainByname(vmName) + if err != nil { + return fmt.Errorf("failed to lookup domain by name '%s': %w", vmName, err) + } + + logrus.Tracef("Rebooting VM '%s' before update attempt #%d", vmName, iteration) + if err := lv.DomainShutdown(domain); err != nil { + return fmt.Errorf("failed to shutdown domain '%s': %w", vmName, err) + } + logrus.Tracef("Waiting for VM '%s' to shut down", vmName) + for { + domainState, _, err := lv.DomainGetState(domain, 0) + if err != nil { + return fmt.Errorf("failed to get domain state: %w", err) + } + if domainState == int32(libvirt.DomainShutoff) { + break // Domain is shut off, exit loop + } + } + logrus.Tracef("Domain '%s' is shut down, starting it again", vmName) + err = lv.DomainCreate(domain) + if err != nil { + return fmt.Errorf("failed to start domain '%s': %w", vmName, err) + } + logrus.Tracef("Waiting for VM '%s' to come back up after reboot", vmName) + err = cfg.WaitForLogin(vmName, outputPath, verbose, iteration) + if err != nil { + return fmt.Errorf("failed to wait for login after reboot: %w", err) + } + return nil +} + +func getLibvirtDomainByname(vmName string) (lv *libvirt.Libvirt, domain libvirt.Domain, err error) { + uri, _ := url.Parse(string(libvirt.QEMUSession)) + lv, err = libvirt.ConnectToURI(uri) + if err != nil { + return lv, domain, fmt.Errorf("failed to connect: %v", err) + } + + domain, err = lv.DomainLookupByName(vmName) + if err != nil { + return lv, domain, fmt.Errorf("failed to lookup domain by name '%s': %w", vmName, err) + } + + return lv, domain, nil +} + +func (cfg QemuConfig) getQemuVmIpAddresses(vmName string) ([]string, error) { + lv, domain, err := getLibvirtDomainByname(vmName) + if err != nil { + return nil, fmt.Errorf("failed to lookup domain by name '%s': %w", vmName, err) + } + logrus.Tracef("Found libvirt domain '%s' with ID %v", vmName, domain) + + ifaces, err := lv.DomainInterfaceAddresses(domain, uint32(libvirt.DomainInterfaceAddressesSrcLease), 0) // VIR_DOMAIN_INTERFACE_ADDRESSES_SRC_AGENT retrieves info from the guest agent + if err != nil { + return nil, fmt.Errorf("failed to get domain interface addresses: %w", err) + } + logrus.Tracef("Found %d interfaces for domain '%s'", len(ifaces), vmName) + + ipAddressesFound := make([]string, 0) + + // Iterate through interfaces to find IP address + for _, val := range ifaces { + logrus.Tracef("Interface '%s' has %d addresses", val.Name, len(val.Addrs)) + if val.Addrs != nil { + logrus.Tracef("Interface '%s' has non-null addresses: %v", val.Name, val.Addrs) + for _, addr := range val.Addrs { + logrus.Tracef("Found address '%s' of type %d for interface '%s'", addr.Addr, addr.Type, val.Name) + if addr.Type == int32(libvirt.IPAddrTypeIpv4) { + logrus.Tracef("Found IPv4 address '%s' for interface '%s'", addr.Addr, val.Name) + ipAddressesFound = append(ipAddressesFound, addr.Addr) + } + } + } + } + return ipAddressesFound, nil +} + +func (cfg QemuConfig) GetAllVmIPAddresses(vmName string) ([]string, error) { + for { + ips, err := cfg.getQemuVmIpAddresses(vmName) + if err != nil || len(ips) == 0 { + logrus.Tracef("Failed to get QEMU VM IP addresses: %v", err) + + virshOutput, virshErr := exec.Command("sudo", "virsh", "domifaddr", vmName).CombinedOutput() + logrus.Tracef("virsh domifaddr output: %s\n%v", string(virshOutput), virshErr) + + time.Sleep(1 * time.Second) // Wait before retrying + continue // Retry until we get an IP address + } + + return ips, nil + } +} + +func (cfg QemuConfig) deleteLibvirtDomain(vmName string) error { + logrus.Tracef("Deleting libvirt domain '%s'", vmName) + lv, domain, err := getLibvirtDomainByname(vmName) + if err != nil { + logrus.Tracef("Failed to lookup domain by name '%s': %v", vmName, err) + return nil + } + + domainState, _, err := lv.DomainGetState(domain, 0) + if err != nil { + return fmt.Errorf("failed to get domain state: %w", err) + } + if domainState == int32(libvirt.DomainRunning) { + logrus.Tracef("Destroying libvirt domain '%s'", vmName) + err = lv.DomainDestroy(domain) // Stop the VM + if err != nil { + logrus.Tracef("failed to destroy domain '%s': %v", vmName, err) + return fmt.Errorf("failed to destroy domain '%s': %w", vmName, err) + } + } + + logrus.Tracef("Undefining libvirt domain '%s'", vmName) + err = lv.DomainUndefineFlags(domain, libvirt.DomainUndefineNvram) // Undefine the VM, including NVRAM + if err != nil { + logrus.Tracef("failed to undefine domain '%s': %v", vmName, err) + return fmt.Errorf("failed to undefine domain '%s': %w", vmName, err) + } + return nil +} + +func (cfg QemuConfig) createQemuVM(name string, bootImage string, useVirtInstall bool) error { + + // TODO: migrate to use virtdeploy + + if useVirtInstall { + logrus.Tracef("Using virt-install to create QEMU VM '%s'", name) + virtInstallArgs := []string{ + "virt-install", + "--name", name, + "--memory", "2048", + "--vcpus", "2", + "--os-variant", "generic", + "--import", + "--disk", fmt.Sprintf("%s,bus=sata", bootImage), + "--network", "default", + "--noautoconsole", + "--serial", fmt.Sprintf("file,path=%s", cfg.SerialLog), + } + if cfg.SecureBoot { + virtInstallArgs = append(virtInstallArgs, "--machine", "q35", "--boot", "uefi,loader_secure=yes") + } else { + virtInstallArgs = append(virtInstallArgs, "--boot", "uefi,loader_secure=no") + } + logrus.Tracef("Running virt-install command: %s", strings.Join(virtInstallArgs, " ")) + if err := exec.Command("sudo", virtInstallArgs...).Run(); err != nil { + return fmt.Errorf("failed to create QEMU VM '%s': %w", name, err) + } + } else { + logrus.Tracef("Using libvirt to create QEMU VM '%s'", name) + uri, _ := url.Parse(string(libvirt.QEMUSession)) + lv, err := libvirt.ConnectToURI(uri) + if err != nil { + return fmt.Errorf("failed to connect: %v", err) + } + + loaderPath := "/usr/share/OVMF/OVMF_CODE.secboot.fd" + if !cfg.SecureBoot { + loaderPath = "/usr/share/OVMF/OVMF_CODE.fd" + } + + domainXML := fmt.Sprintf(` + + %s + 2048 + 2 + + hvm + %s + + + + + + + + + + + + + + + + + + + + + + + + + +`, + name, loaderPath, bootImage, cfg.SerialLog, cfg.SerialLog) + + logrus.Tracef("Defining libvirt domain with XML: %s", domainXML) + domain, err := lv.DomainDefineXML(domainXML) + if err != nil { + return fmt.Errorf("failed to define domain: %w", err) + } + + logrus.Tracef("Starting libvirt domain '%s'", name) + if err := lv.DomainCreate(domain); err != nil { + return fmt.Errorf("failed to start domain: %w", err) + } + } + return nil +} + +func (cfg QemuConfig) TruncateLog(vmName string) error { + // If domain exists, truncate the serial log file + if _, _, err := getLibvirtDomainByname(vmName); err == nil { + if err := exec.Command("truncate", "-s", "0", cfg.SerialLog).Run(); err != nil { + return fmt.Errorf("failed to truncate log file: %w", err) + } + } + return nil +} + +func (cfg QemuConfig) WaitForLogin(vmName string, outputPath string, verbose bool, iteration int) error { + localSerialLog := "./serial.log" + // Wait for login prompt to appear in the serial log and save the log to localSerialLog + waitErr := innerWaitForLogin(cfg.SerialLog, verbose, iteration, localSerialLog) + // Copy serial log to output directory if specified + if outputPath != "" { + err := os.MkdirAll(outputPath, 0755) + if err != nil { + return fmt.Errorf("failed to create output directory '%s': %w", outputPath, err) + } + + outputFilename := fmt.Sprintf("%s-serial.log", fmt.Sprintf("%03d", iteration)) + if err := exec.Command("cp", localSerialLog, filepath.Join(outputPath, outputFilename)).Run(); err != nil { + return fmt.Errorf("failed to copy serial log to output directory: %w", err) + } + } + + if waitErr != nil { + // Create fairly generic error message + logrus.Errorf("Failed to reach login prompt for the VM for iteration %d: %v", iteration, waitErr) + // Attempt to create more meaningful error messages based on the serial log + analyzeSerialLog(cfg.SerialLog) + + // Output qemu domain info to try to help debug failure + dominfoOut, err := exec.Command("virsh", "dominfo", vmName).Output() + if err != nil { + logrus.Errorf("Failed to get domain info for VM '%s': %v", vmName, err) + } else { + logrus.Infof("Domain info for VM '%s': %s", vmName, dominfoOut) + } + + // Output disk usage to help debug failure + dfOut, err := exec.Command("df", "-h").Output() + if err != nil { + logrus.Errorf("Failed to run 'df -h': %v", err) + } else { + logrus.Infof("Disk usage:\n%s", dfOut) + } + } + return waitErr +} + +func printAndSave(line string, verbose bool, localSerialLog string, ansi_control_cleaner *regexp.Regexp, ansi_cleaner *regexp.Regexp) { + if line == "" { + return + } + + // Remove ANSI control codes + line = ansi_control_cleaner.ReplaceAllString(line, "") + if verbose { + logrus.Info(line) + } + if localSerialLog != "" { + // Remove all ANSI escape codes + line = ansi_cleaner.ReplaceAllString(line, "") + logFile, err := os.OpenFile(localSerialLog, os.O_APPEND|os.O_CREATE|os.O_RDWR, 0644) + if err != nil { + return + } + defer logFile.Close() + + _, err = logFile.WriteString(line + "\n") + if err != nil { + logrus.Errorf("Failed to append line to output file: %v", err) + } + } +} + +func analyzeSerialLog(serial string) error { + // Read the last line of the serial log + lastLine, err := exec.Command("tail", "-n", "1", serial).Output() + if err != nil { + return fmt.Errorf("failed to read last line of serial log: %w", err) + } + // Watch for specific failures and create error messages accordingly + if strings.Contains(string(lastLine), "tpm tpm0: Operation Timed out") { + logrus.Error("tpm tpm0: Operation Timed out") + } else { + // More generic error message based on last serial log line + logrus.Errorf("Last line of serial log: %s", lastLine) + } + return nil +} + +func innerWaitForLogin(vmSerialLog string, verbose bool, iteration int, localSerialLog string) error { + // ANSI escape code cleaner + ansi_cleaner := regexp.MustCompile(`(\x9B|\x1B\[)[0-?]*[ -\/]*[@-~]`) + // ANSI non-color escape code cleaner, matches only control codes + ansi_control_cleaner := regexp.MustCompile(`(\x9B|\x1B\[)[0-?]*[ -\/]*[@-ln-~]`) + + // Timeout for monitoring serial log for login prompt + timeout := time.Second * 120 + startTime := time.Now() + + // Create the file if it doesn't exist + file, err := os.OpenFile(vmSerialLog, os.O_RDWR, 0644) + if err != nil { + return fmt.Errorf("failed to open serial log file: %w", err) + } + defer file.Close() + + reader := bufio.NewReader(file) + lineBuffer := "" + for { + // Check if the current line contains the login prompt, and return if it does + if strings.Contains(lineBuffer, "login:") && !strings.Contains(lineBuffer, "mos") { + printAndSave(lineBuffer, verbose, localSerialLog, ansi_control_cleaner, ansi_cleaner) + return nil + } + + // Read a rune from reader, if EOF is encountered, retry until either a new + // character is read or the timeout is reached + var readRune rune + for { + if time.Since(startTime) >= timeout { + return fmt.Errorf("timeout waiting for login prompt after %d seconds", int(timeout.Seconds())) + } + // Read a rune from the serial log file + readRune, _, err = reader.ReadRune() + if err == io.EOF { + // Wait for new serial output + time.Sleep(10 * time.Millisecond) + continue + } + if err != nil { + return fmt.Errorf("failed to read from serial log: %w", err) + } + // Successfully read a rune, break out of the loop + break + } + // Handle the rune read from the serial log + runeStr := string(readRune) + if runeStr == "\n" { + // If the last character is a newline, print the line buffer + // and reset it + printAndSave(lineBuffer, verbose, localSerialLog, ansi_control_cleaner, ansi_cleaner) + lineBuffer = "" + } else { + // If non-newline, append the output to the buffer + lineBuffer += runeStr + } + } +} diff --git a/tools/storm/servicing/utils/ssh/ssh.go b/tools/storm/servicing/utils/ssh/ssh.go new file mode 100644 index 000000000..5c7d7f386 --- /dev/null +++ b/tools/storm/servicing/utils/ssh/ssh.go @@ -0,0 +1,160 @@ +package ssh + +import ( + "context" + "fmt" + "os" + "os/exec" + "strings" + "time" + "tridenttools/storm/servicing/utils/config" + "tridenttools/storm/utils" + + "github.com/sirupsen/logrus" + "golang.org/x/crypto/ssh" +) + +func SshCommandWithRetries(cfg config.VMConfig, vmIP, command string, connectionRetryCount int, commandRetryCount int) (string, error) { + return innerSshCommand(cfg, vmIP, command, false, connectionRetryCount, commandRetryCount) +} + +func SshCommand(cfg config.VMConfig, vmIP, command string) (string, error) { + return innerSshCommand(cfg, vmIP, command, false, 0, 0) +} + +func SshCommandCombinedOutput(cfg config.VMConfig, vmIP, command string) (string, error) { + return innerSshCommand(cfg, vmIP, command, true, 0, 0) +} + +func StartSshProxyPortAndWait(ctx context.Context, port int, vmIP string, sshUser string, sshKeyPath string, startedChannel chan bool) error { + cmd := exec.CommandContext(ctx, + "ssh", + "-R", fmt.Sprintf("%d:localhost:%d", port, port), + "-N", + "-o", "BatchMode=yes", + "-o", "ConnectTimeout=10", + "-o", "ServerAliveCountMax=3", + "-o", "ServerAliveInterval=5", + "-o", "StrictHostKeyChecking=no", + "-o", "UserKnownHostsFile=/dev/null", + "-i", sshKeyPath, + fmt.Sprintf("%s@%s", sshUser, vmIP), + ) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + logrus.Tracef("Starting SSH proxy for port %d to VM %s with user %s", port, vmIP, sshUser) + if err := cmd.Start(); err != nil { + return fmt.Errorf("failed to start SSH proxy for port %d: %w", port, err) + } + // Signal that the SSH proxy has started + startedChannel <- true + // Wait for the command to finish + if err := cmd.Wait(); err != nil { + return fmt.Errorf("SSH proxy for port %d failed: %w", port, err) + } + logrus.Tracef("SSH proxy for port %d exited", port) + + return nil +} + +func ScpDownloadFile(cfg config.VMConfig, vmIP, src, dest string) error { + args := []string{ + "-i", cfg.SshPrivateKeyPath, + "-r", + "-o", "StrictHostKeyChecking=no", + "-o", "UserKnownHostsFile=/dev/null", + fmt.Sprintf("%s@%s:%s", cfg.User, vmIP, src), + dest, + } + logrus.Tracef("Running scp download with args: %v", args) + cmd := exec.Command("scp", args...) + return cmd.Run() +} + +func ScpUploadFile(cfg config.VMConfig, vmIP, src, dest string) error { + args := []string{ + "-i", cfg.SshPrivateKeyPath, + "-r", + "-o", "StrictHostKeyChecking=no", + "-o", "UserKnownHostsFile=/dev/null", + src, + fmt.Sprintf("%s@%s:%s", cfg.User, vmIP, dest), + } + logrus.Tracef("Running scp upload with args: %v", args) + cmd := exec.Command("scp", args...) + return cmd.Run() +} + +func ScpUploadFileWithSudo(cfg config.VMConfig, vmIP, src, dest string) error { + // Create a temporary file on the VM to upload the file + tmpFile, err := SshCommand(cfg, vmIP, "mktemp") + if err != nil { + return fmt.Errorf("failed to create temporary file on VM: %w", err) + } + // Use scp to upload file to temporary location + if err = ScpUploadFile(cfg, vmIP, src, tmpFile); err != nil { + return fmt.Errorf("failed to upload file to VM: %w", err) + } + // Move file to destination with sudo + if _, err = SshCommand(cfg, vmIP, fmt.Sprintf("sudo mv %s %s", tmpFile, dest)); err != nil { + return fmt.Errorf("failed to move file on VM: %w", err) + } + return nil +} + +func innerSshCommand(cfg config.VMConfig, vmIP, command string, combineOutput bool, connectionRetryCount int, commandRetryCount int) (string, error) { + sshCliSettings := utils.SshCliSettings{ + PrivateKeyPath: cfg.SshPrivateKeyPath, + Host: vmIP, + User: cfg.User, + Port: 22, + Timeout: 5, + } + var err error + client, err := utils.Retry( + time.Second*time.Duration(connectionRetryCount), + time.Second*time.Duration(1), + func(attempt int) (*ssh.Client, error) { + logrus.Tracef("SSH dial to '%s' (attempt %d)", sshCliSettings.FullHost(), attempt) + return utils.OpenSshClient(sshCliSettings) + }, + ) + if err != nil { + return "", fmt.Errorf("failed to create SSH client: %w", err) + } + defer client.Close() + + output, err := utils.Retry( + time.Second*time.Duration(commandRetryCount), + time.Second*time.Duration(1), + func(attempt int) (*string, error) { + session, err := client.NewSession() + if err != nil { + return nil, fmt.Errorf("failed to create SSH session: %w", err) + } + defer session.Close() + + var output []byte + if combineOutput { + output, err = session.CombinedOutput(command) + } else { + output, err = session.Output(command) + } + if err != nil { + return nil, fmt.Errorf("failed to run command '%s': %w\nOutput: %s", command, err, output) + } + + sanitizedOutput := strings.TrimSpace(string(output)) + return &sanitizedOutput, nil + }, + ) + if err != nil { + return "", fmt.Errorf("failed to run command '%s' on VM '%s': %w", command, vmIP, err) + } + if output == nil { + return "", fmt.Errorf("no output received from command '%s' on VM '%s'", command, vmIP) + } + logrus.Tracef("SSH command '%s' output on VM '%s': %s", command, vmIP, *output) + return *output, nil +} diff --git a/storm/suites/trident/utils/env.go b/tools/storm/utils/env.go similarity index 100% rename from storm/suites/trident/utils/env.go rename to tools/storm/utils/env.go diff --git a/tools/storm/utils/libvirt.go b/tools/storm/utils/libvirt.go new file mode 100644 index 000000000..fc2003490 --- /dev/null +++ b/tools/storm/utils/libvirt.go @@ -0,0 +1,113 @@ +package utils + +import ( + "fmt" + "net/url" + "os" + "os/exec" + + libvirtxml "libvirt.org/libvirt-go-xml" + + "github.com/digitalocean/go-libvirt" + "github.com/google/uuid" + "github.com/sirupsen/logrus" +) + +type LibvirtVm struct { + libvirt *libvirt.Libvirt + domain libvirt.Domain +} + +func InitializeVm(vmUuid uuid.UUID) (*LibvirtVm, error) { + logrus.Infof("Initializing VM with UUID '%s'", vmUuid.String()) + + uri, _ := url.Parse(string(libvirt.QEMUSession)) + l, err := libvirt.ConnectToURI(uri) + if err != nil { + return nil, fmt.Errorf("failed to connect: %v", err) + } + + var uuidSlice [16]byte + copy(uuidSlice[:], vmUuid[:]) + + domain, err := l.DomainLookupByUUID(uuidSlice) + if err != nil { + return nil, fmt.Errorf("failed to lookup domain by UUID '%s': %w", vmUuid.String(), err) + } + + domainState, _, err := l.DomainGetState(domain, 0) + if err != nil { + return nil, fmt.Errorf("failed to get domain state: %w", err) + } + + // Shutdown the VM if necessary + if domainState != int32(libvirt.DomainShutoff) { + logrus.Infof("Shutting down VM '%s'", domain.Name) + if err = l.DomainDestroy(domain); err != nil { + logrus.Warnf("failed to reset domain '%s': %s", domain.Name, err.Error()) + } + } + + return &LibvirtVm{l, domain}, nil +} + +func (vm *LibvirtVm) SetVmHttpBootUri(url string) error { + // Get the domain XML + domainXml, err := vm.libvirt.DomainGetXMLDesc(vm.domain, libvirt.DomainXMLUpdateCPU) + if err != nil { + return fmt.Errorf("failed to get XML description of domain '%s': %w", vm.domain.Name, err) + } + logrus.Tracef("Domain XML:\n%s", domainXml) + + // Parse the domain XML + parsedDomainXml := &libvirtxml.Domain{} + if err := parsedDomainXml.Unmarshal(domainXml); err != nil { + return fmt.Errorf("failed to parse domain XML: %w", err) + } + + // Find the NVRAM path + nvram := parsedDomainXml.OS.NVRam + if nvram != nil { + logrus.Debugf("Extracted NVRAM path: %s", nvram.NVRam) + } else { + return fmt.Errorf("no node found in domain XML") + } + + // Check if a file exists at the NVRAM path + if _, err := os.Stat(nvram.NVRam); err != nil { + // If not, start the VM in a paused state and then immediately stop it. + // This will cause libvirt to create the NVRAM file. + if vm.domain, err = vm.libvirt.DomainCreateWithFlags(vm.domain, uint32(libvirt.DomainStartPaused)); err != nil { + return fmt.Errorf("failed to create domain '%s': %w", vm.domain.Name, err) + } + if err = vm.libvirt.DomainDestroy(vm.domain); err != nil { + return fmt.Errorf("failed to destroy domain '%s': %w", vm.domain.Name, err) + } + } + + cmd := exec.Command("virt-fw-vars", "--inplace", nvram.NVRam, "--set-boot-uri", url) + if output, err := cmd.CombinedOutput(); err != nil { + logrus.Debugf("virt-fw-vars output:\n%s\n", output) + return fmt.Errorf("failed to set boot URI: %w", err) + } + logrus.Infof("Set boot URI to %s", url) + + return nil +} + +func (vm *LibvirtVm) Start() error { + logrus.Infof("Starting VM '%s'", vm.domain.Name) + + if err := vm.libvirt.DomainCreate(vm.domain); err != nil { + logrus.Errorf("failed to start domain '%s'", vm.domain.Name) + return err + } + + return nil +} + +func (vm *LibvirtVm) Disconnect() { + if err := vm.libvirt.Disconnect(); err != nil { + logrus.Errorf("failed to disconnect from libvirt: %s", err.Error()) + } +} diff --git a/storm/suites/trident/utils/retry.go b/tools/storm/utils/retry.go similarity index 100% rename from storm/suites/trident/utils/retry.go rename to tools/storm/utils/retry.go diff --git a/storm/suites/trident/utils/ssh.go b/tools/storm/utils/ssh.go similarity index 95% rename from storm/suites/trident/utils/ssh.go rename to tools/storm/utils/ssh.go index 473551ab9..59154e584 100644 --- a/storm/suites/trident/utils/ssh.go +++ b/tools/storm/utils/ssh.go @@ -160,16 +160,16 @@ func RunCommand(client *ssh.Client, command string) (*SshCmdOutput, error) { return out, nil } -func CommandOutput(client *ssh.Client, log *logrus.Logger, command string) (string, error) { - log.WithField("command", command).Debug("Executing command") +func CommandOutput(client *ssh.Client, command string) (string, error) { + logrus.WithField("command", command).Debug("Executing command") output, err := RunCommand(client, command) if err != nil { - log.Errorf("Failed to run command: %s", err) + logrus.Errorf("Failed to run command: %s", err) return "", fmt.Errorf("failed to run command: %w", err) } if err := output.Check(); err != nil { - log.Errorf("Command failed: %s", output.Report()) + logrus.Errorf("Command failed: %s", output.Report()) return "", fmt.Errorf("command failed: %w", err) } diff --git a/storm/suites/trident/utils/trident.go b/tools/storm/utils/trident.go similarity index 89% rename from storm/suites/trident/utils/trident.go rename to tools/storm/utils/trident.go index a8f4c44ca..6cedca2ba 100644 --- a/storm/suites/trident/utils/trident.go +++ b/tools/storm/utils/trident.go @@ -82,7 +82,7 @@ func LoadTridentContainer(client *ssh.Client) error { return nil } -func CheckTridentService(client *ssh.Client, log *logrus.Logger, env TridentEnvironment, timeout time.Duration) error { +func CheckTridentService(client *ssh.Client, env TridentEnvironment, timeout time.Duration) error { if client == nil { return fmt.Errorf("SSH client is nil") } @@ -101,10 +101,10 @@ func CheckTridentService(client *ssh.Client, log *logrus.Logger, env TridentEnvi timeout, time.Second*5, func(attempt int) (*bool, error) { - log.Infof("Checking Trident service status (attempt %d)", attempt) - err := checkTridentServiceInner(log, client, serviceName) + logrus.Infof("Checking Trident service status (attempt %d)", attempt) + err := checkTridentServiceInner(client, serviceName) if err != nil { - log.Warnf("Trident service is not in expected state: %s", err) + logrus.Warnf("Trident service is not in expected state: %s", err) return nil, err } @@ -118,7 +118,7 @@ func CheckTridentService(client *ssh.Client, log *logrus.Logger, env TridentEnvi return nil } -func checkTridentServiceInner(log *logrus.Logger, client *ssh.Client, serviceName string) error { +func checkTridentServiceInner(client *ssh.Client, serviceName string) error { session, err := client.NewSession() if err != nil { return fmt.Errorf("failed to create SSH session: %w", err) @@ -140,7 +140,7 @@ func checkTridentServiceInner(log *logrus.Logger, client *ssh.Client, serviceNam outputStr := string(output) - log.Debugf("Trident service status:\n%s", outputStr) + logrus.Debugf("Trident service status:\n%s", outputStr) if !strings.Contains(outputStr, "Active: inactive (dead)") { return fmt.Errorf("expected to find 'Active: inactive (dead)' in Trident service status") @@ -163,7 +163,7 @@ func checkTridentServiceInner(log *logrus.Logger, client *ssh.Client, serviceNam return fmt.Errorf("expected to find '(code=exited, status=0/SUCCESS)' in Trident service status") } - log.Info("Trident service ran successfully") + logrus.Info("Trident service ran successfully") return nil } diff --git a/tools/storm/utils/vmip.go b/tools/storm/utils/vmip.go new file mode 100644 index 000000000..df5e8fc1a --- /dev/null +++ b/tools/storm/utils/vmip.go @@ -0,0 +1,34 @@ +package utils + +import ( + "fmt" + "tridenttools/storm/servicing/utils/config" +) + +func GetVmIP(cfg config.ServicingConfig) (string, error) { + allIps, err := GetAllVmIPAddresses(cfg) + if err != nil { + return "", fmt.Errorf("failed to get all VM IP addresses: %w", err) + } + if len(allIps) == 0 { + return "", fmt.Errorf("no IP addresses found for VM '%s'", cfg.VMConfig.Name) + } + return allIps[0], nil +} + +func GetAllVmIPAddresses(cfg config.ServicingConfig) ([]string, error) { + if cfg.VMConfig.Platform == config.PlatformQEMU { + ips, err := cfg.QemuConfig.GetAllVmIPAddresses(cfg.VMConfig.Name) + if err != nil { + return nil, fmt.Errorf("failed to get QEMU VM IP addresses: %w", err) + } + return ips, nil + } else if cfg.VMConfig.Platform == config.PlatformAzure { + ips, err := cfg.AzureConfig.GetAllVmIPAddresses(cfg.VMConfig.Name, cfg.TestConfig.BuildId) + if err != nil { + return nil, fmt.Errorf("failed to get Azure VM IP addresses: %w", err) + } + return ips, nil + } + return nil, fmt.Errorf("unknown platform: %s", cfg.VMConfig.Platform) +} diff --git a/trident-mos/files/download-trident.service b/trident-mos/files/download-trident.service index b796e80cf..965963574 100644 --- a/trident-mos/files/download-trident.service +++ b/trident-mos/files/download-trident.service @@ -7,6 +7,7 @@ Before=trident-install.service ExecStart=/bin/bash /trident_cdrom/pre-trident-script.sh ExecStart=/bin/bash -c "curl $(grep -oP '^\s*phonehome: \\K.*(?=phonehome)' /etc/trident/config.yaml)files/trident -o /usr/bin/trident -f" ExecStart=chmod +x /usr/bin/trident +ExecStart=/sbin/restorecon -v /usr/bin/trident ExecStart=/bin/bash -c "curl $(grep -oP '^\s*phonehome: \\K.*(?=phonehome)' /etc/trident/config.yaml)files/osmodifier -o /usr/bin/osmodifier -f" ExecStart=/bin/chmod +x /usr/bin/osmodifier Type=oneshot diff --git a/trident-mos/iso.yaml b/trident-mos/iso.yaml index d936ce2b7..ba98d06ce 100644 --- a/trident-mos/iso.yaml +++ b/trident-mos/iso.yaml @@ -54,6 +54,9 @@ os: - tpm2-tools - veritysetup - vim + # Support for building SELinux module and debugging + - selinux-policy-devel + - setools-console additionalFiles: - source: files/getty@.service @@ -66,6 +69,12 @@ os: destination: /usr/lib/systemd/system/trident-install.service - source: files/download-trident.service destination: /usr/lib/systemd/system/download-trident.service + - source: ../selinux-policy-trident/trident.if + destination: /usr/share/selinux/packages/trident/trident.if + - source: ../selinux-policy-trident/trident.fc + destination: /usr/share/selinux/packages/trident/trident.fc + - source: ../selinux-policy-trident/trident.te + destination: /usr/share/selinux/packages/trident/trident.te services: enable: @@ -87,4 +96,5 @@ iso: - console=tty0 - console=ttyS0 - rd.luks=0 - - selinux=0 + - selinux=1 + - enforcing=0 diff --git a/trident-mos/post-install.sh b/trident-mos/post-install.sh index 74d7296bc..825008afb 100755 --- a/trident-mos/post-install.sh +++ b/trident-mos/post-install.sh @@ -6,3 +6,8 @@ if [ ! -d /etc/trident ]; then mkdir /etc/trident fi ln -s /trident_cdrom/trident-config.yaml /etc/trident/config.yaml + +# Compile and load Trident SELinux module (this is otherwise handled in trident.spec) +cd /usr/share/selinux/packages/trident +make -f /usr/share/selinux/devel/Makefile trident.pp +semodule -i trident.pp \ No newline at end of file diff --git a/trident-selinuxpolicies.cil b/trident-selinuxpolicies.cil deleted file mode 100644 index f15db87cd..000000000 --- a/trident-selinuxpolicies.cil +++ /dev/null @@ -1,140 +0,0 @@ -; Allow auditctl_t to manage auditd_etc_t files -(allow auditctl_t auditd_etc_t (file (map))) -(allow auditctl_t proc_t (filesystem (getattr))) - -; Allow chkpwd_t to access sysctl_kernel_t directories and files for password verification -(allow chkpwd_t proc_t (filesystem (getattr))) -(allow chkpwd_t sysctl_kernel_t (dir (search))) -(allow chkpwd_t sysctl_kernel_t (file (open read))) - -; Allow cloud_init_t to perform various operations for cloud instance initialization -; cloud-init -(allow cloud_init_t file_context_t (file (getattr open read map))) -(allow cloud_init_t gpg_secret_t (file (getattr))) -(allow cloud_init_t security_t (security (check_context))) -(allow cloud_init_t self (capability (net_admin ))) -(allow cloud_init_t selinux_config_t (file (getattr open read))) -(allow cloud_init_t sshd_key_t (file (relabelto))) -(allow cloud_init_t user_home_t (file (getattr))) - -; Allow fsadm_t to manage efivarfs_t files for filesystem administration -; efibootmgr -(allow fsadm_t efivarfs_t (dir (read))) -(allow fsadm_t efivarfs_t (file (getattr open read write))) -(allow fsadm_t efivarfs_t (filesystem (getattr))) - -; Load/Save OS Random Seed -(allow kernel_t self (capability2 (checkpoint_restore))) - -; Allow kmod_t to read iptables_runtime_t files for kernel module management -(allow kmod_t iptables_runtime_t (file (read))) - -; Allow lvm_t to perform various operations for logical volume management -; Disk Encryption -(allow lvm_t bpf_t (dir (search))) -(allow lvm_t cgroup_t (dir (search))) -(allow lvm_t cgroup_t (filesystem (getattr))) -(allow lvm_t init_t (key (search))) -(allow lvm_t initrc_runtime_t (dir (add_name open read write search))) -(allow lvm_t initrc_runtime_t (file (create open read write lock getattr))) -(allow lvm_t initrc_t (sem (associate read unix_read unix_write write))) -(allow lvm_t kernel_t (key (search))) -(allow lvm_t proc_t (filesystem (getattr))) -(allow lvm_t pstore_t (dir (search))) -(allow lvm_t self (capability (dac_read_search))) -(allow lvm_t systemd_passwd_runtime_t (dir (getattr search))) -(allow lvm_t tmpfs_t (filesystem (getattr))) -(allow lvm_t tpm_device_t (chr_file (read write open ioctl))) -(allow lvm_t user_home_dir_t (dir (search))) -(allow lvm_t var_run_t (dir (create))) - -; Allow mdadm_t to perform RAID operations using the mdadm utility -(allow mdadm_t debugfs_t (dir (search read write add_name remove_name getattr open))) -(allow mdadm_t device_t (lnk_file (create read write getattr open link rename setattr unlink))) -(allow mdadm_t event_device_t (chr_file (getattr))) -(allow mdadm_t lvm_control_t (chr_file (getattr))) -(allow mdadm_t nvram_device_t (chr_file (getattr))) -(allow mdadm_t udev_runtime_t (dir (search read write add_name remove_name getattr open))) -(allow mdadm_t vfio_device_t (chr_file (getattr))) -(allow mdadm_t vhost_device_t (chr_file (getattr))) - -(allow ntpd_t proc_t (file (write))) - -; Allow passwd_t to manage proc files for password updates -(allow passwd_t proc_t (filesystem (getattr))) - -; Allow semanage_t and setfiles_t to get attributes of the proc_t filesystem -(allow semanage_t proc_t (filesystem (getattr))) -(allow setfiles_t proc_t (filesystem (getattr))) - -; Allow ssh_keygen_t to manage files and directories for SSH key generation -(allow ssh_keygen_t security_t (filesystem (getattr))) -(allow ssh_keygen_t selinux_config_t (dir (search))) - -; Allow sshd_t to get attributes of the proc_t filesystem for SSH daemon -(allow sshd_t proc_t (filesystem (getattr))) -(allow sshd_t self (capability (net_admin))) - -; Allow syslogd_t to manage logging files and directories -(allow syslogd_t cgroup_t (dir (read))) -(allow syslogd_t proc_t (file (write read append getattr lock open))) -(allow syslogd_t systemd_journal_t (file (relabelfrom relabelto))) - -; Allow systemd_cgroups_t to manage proc_t filesystem -(allow systemd_cgroups_t proc_t (filesystem (getattr))) - -; Allow systemd_generator_t to manage home_root_t directories and selinux_config_t directories -(allow systemd_generator_t home_root_t (dir (read search write add_name remove_name getattr open))) -(allow systemd_generator_t self (capability (sys_rawio))) -(allow systemd_generator_t selinux_config_t (dir (search))) - -; Allow systemd_hw_t to manage capabilities for systemd hardware management -(allow systemd_hw_t self (capability (dac_override))) - -; Allow systemd_locale_t to manage selinux_config_t files for locale settings -(allow systemd_locale_t selinux_config_t (file (getattr open read))) - -; Allow systemd_logind_t to manage various directories and files for login daemon -(allow systemd_logind_t proc_t (file (write ))) -(allow systemd_logind_t proc_t (filesystem (getattr))) - -; Allow systemd_networkd_t to manage various directories and files for network management -(allow systemd_networkd_t proc_t (file (write))) -(allow systemd_networkd_t selinux_config_t (dir (search))) - -; Allow systemd_resolved_t to perform name binding on howl_port_t, write to proc_t files, and search tmpfs_t directories for DNS resolution -(allow systemd_resolved_t howl_port_t (udp_socket (name_bind))) -(allow systemd_resolved_t proc_t (file (write))) -(allow systemd_resolved_t tmpfs_t (dir (search))) - -(allow systemd_sessions_t self (capability (net_admin))) - -; Allow systemd_sysctl_t to manage selinux_config_t directories and perform various operations on tmpfs_t directories for system configuration -(allow systemd_sysctl_t selinux_config_t (dir (search))) -(allow systemd_sysctl_t tmpfs_t (dir (search read write add_name remove_name getattr open))) - -; Allow systemd_tmpfiles_t to manage etc_t symbolic links and various directories and files for temporary file management -(allow systemd_tmpfiles_t etc_t (lnk_file (relabelto relabelfrom))) -(allow systemd_tmpfiles_t init_var_lib_t (dir (create))) -(allow systemd_tmpfiles_t user_home_dir_t (dir (write relabelto relabelfrom))) - -(allow systemd_update_done_t self (capability (net_admin))) - -; Allow systemd_user_runtime_dir_t to get attributes of the proc_t filesystem for user runtime directory management -(allow systemd_user_runtime_dir_t proc_t (filesystem (getattr))) -(allow systemd_user_runtime_dir_t self (capability (net_admin))) - -; Allow systemd_userdbd_t to connect to kernel_t Unix stream sockets, write to proc_t files for user database operations -(allow systemd_userdbd_t kernel_t (unix_stream_socket (connectto))) -(allow systemd_userdbd_t proc_t (file (write))) -(allow systemd_userdbd_t self (capability (sys_resource))) -(allow systemd_userdbd_t self (process (getcap))) - -; Allow udev_t to manage init_runtime_t directories, write to proc_t files for device management -; udev -(allow udev_t init_runtime_t (dir (read))) -(allow udev_t proc_t (file (write))) - -; Allow useradd_t to manage shadow_t files for user addition -; OSModifier -(allow useradd_t shadow_t (file (open read))) diff --git a/trident.spec b/trident.spec index 9038c6310..51d430bf5 100644 --- a/trident.spec +++ b/trident.spec @@ -1,3 +1,5 @@ +%global selinuxtype targeted + Summary: Agent for bare metal platform Name: trident Version: %{rpm_ver} @@ -5,7 +7,9 @@ Release: %{rpm_rel}%{?dist} Vendor: Microsoft Corporation License: Proprietary Source1: osmodifier -Source2: trident-selinuxpolicies.cil +Source2: trident.fc +Source3: trident.if +Source4: trident.te BuildRequires: openssl-devel BuildRequires: rust BuildRequires: systemd-units @@ -17,6 +21,7 @@ Requires: efibootmgr Requires: lsof Requires: systemd >= 255 Requires: systemd-udev +Requires: (%{name}-selinux if selinux-policy-%{selinuxtype}) # Optional dependencies for various optional features @@ -42,20 +47,6 @@ Agent for bare metal platform %{_bindir}/%{name} %dir /etc/%{name} %{_bindir}/osmodifier -%{_datadir}/selinux/packages/trident-selinuxpolicies.cil - -%post -#!/bin/sh -# Apply required selinux policies only if selinux-policy is present -if rpm -q selinux-policy &> /dev/null; then - semodule -i %{_datadir}/selinux/packages/trident-selinuxpolicies.cil -fi - -%postun -# If selinux-policy is present, remove the trident-selinuxpolicies module -if rpm -q selinux-policy &> /dev/null; then - semodule -r trident-selinuxpolicies -fi # ------------------------------------------------------------------------------ @@ -123,10 +114,67 @@ SystemD timer for update polling with Harpoon. # ------------------------------------------------------------------------------ +%package selinux +Summary: Trident SELinux policy +BuildArch: noarch +Requires: selinux-policy-%{selinuxtype} +Requires(post): selinux-policy-%{selinuxtype} +BuildRequires: selinux-policy-devel +%{?selinux_requires} + +%description selinux +Custom SELinux policy module + +%files selinux +%{_datadir}/selinux/packages/%{selinuxtype}/%{name}.pp.* +%{_datadir}/selinux/devel/include/distributed/%{name}.if +%ghost %verify(not md5 size mode mtime) %{_sharedstatedir}/selinux/%{selinuxtype}/active/modules/200/%{name} + +# SELinux contexts are saved so that only affected files can be +# relabeled after the policy module installation +%pre selinux +%selinux_relabel_pre -s %{selinuxtype} + +%post selinux +%selinux_modules_install -s %{selinuxtype} %{_datadir}/selinux/packages/%{selinuxtype}/%{name}.pp.bz2 + +%postun selinux +if [ $1 -eq 0 ]; then + %selinux_modules_uninstall -s %{selinuxtype} %{name} +fi + +%posttrans selinux +%selinux_relabel_post -s %{selinuxtype} + +# ------------------------------------------------------------------------------ + +%package static-pcrlock-files +Summary: Statically defined .pcrlock files +Requires: %{name} + +%description static-pcrlock-files +Statically defined .pcrlock files for PCR-based encryption. This is a workaround needed because AZL +3.0 fails to provide these files inside the same package as the systemd-pcrlock binary; this should +be removed once the fix is merged in AZL 4.0. + +%files static-pcrlock-files +%dir %{_sharedstatedir}/pcrlock.d +%{_sharedstatedir}/pcrlock.d/ + +# ------------------------------------------------------------------------------ + %build export TRIDENT_VERSION="%{trident_version}" cargo build --release +mkdir selinux +cp -p %{SOURCE2} selinux/ +cp -p %{SOURCE3} selinux/ +cp -p %{SOURCE4} selinux/ + +make -f %{_datadir}/selinux/devel/Makefile %{name}.pp +bzip2 -9 %{name}.pp + %check test "$(./target/release/trident --version)" = "trident %{trident_version}" @@ -135,6 +183,10 @@ install -D -m 755 %{SOURCE1} %{buildroot}%{_bindir}/osmodifier install -D -m 755 target/release/%{name} %{buildroot}/%{_bindir}/%{name} +# Copy Trident SELinux policy module to /usr/share/selinux/packages +install -D -m 0644 %{name}.pp.bz2 %{buildroot}%{_datadir}/selinux/packages/%{selinuxtype}/%{name}.pp.bz2 +install -D -p -m 0644 selinux/%{name}.if %{buildroot}%{_datadir}/selinux/devel/include/distributed/%{name}.if + mkdir -p %{buildroot}%{_unitdir} install -D -m 644 systemd/%{name}.service %{buildroot}%{_unitdir}/%{name}.service install -D -m 644 systemd/%{name}-network.service %{buildroot}%{_unitdir}/%{name}-network.service @@ -142,6 +194,13 @@ install -D -m 644 systemd/%{name}.timer %{buildroot}%{_unitdir}/%{name}.timer mkdir -p %{buildroot}/etc/%{name} -# Copy the trident-selinuxpolicies file to /usr/share/selinux/packages/ -mkdir -p %{buildroot}%{_datadir}/selinux/packages/ -install -m 755 %{SOURCE2} %{buildroot}%{_datadir}/selinux/packages/ \ No newline at end of file +# Copy statically defined .pcrlock files into /var/lib/pcrlock.d +pcrlockroot="%{buildroot}%{_sharedstatedir}/pcrlock.d" +mkdir -p "$pcrlockroot" +( + cd %{_sourcedir}/static-pcrlock-files + find . -type f -print0 | while IFS= read -r -d '' f; do + mkdir -p "$pcrlockroot/$(dirname "$f")" + install -m 644 "$f" "$pcrlockroot/$f" + done +) diff --git a/trident_api/schemas/host-config-schema.json b/trident_api/schemas/host-config-schema.json index c60de845b..81a5129fd 100644 --- a/trident_api/schemas/host-config-schema.json +++ b/trident_api/schemas/host-config-schema.json @@ -1200,7 +1200,7 @@ "type": "array", "oneOf": [ { - "$ref": "#/definitions/SwapDevice" + "$ref": "#/definitions/Swap" }, { "type": "string" @@ -1209,7 +1209,7 @@ "items": { "oneOf": [ { - "$ref": "#/definitions/SwapDevice" + "$ref": "#/definitions/Swap" }, { "type": "string" @@ -1228,14 +1228,14 @@ }, "additionalProperties": false }, - "SwapDevice": { + "Swap": { "type": "object", "required": [ "deviceId" ], "properties": { "deviceId": { - "description": "The ID of the block device to use for this swap device.", + "description": "The ID of the block device to use for this swap area.", "type": "string", "format": "Block Device ID" } diff --git a/trident_api/src/config/host/error.rs b/trident_api/src/config/host/error.rs index 9be381f5b..6b3ec343e 100644 --- a/trident_api/src/config/host/error.rs +++ b/trident_api/src/config/host/error.rs @@ -123,11 +123,6 @@ pub enum HostConfigurationStaticValidationError { #[error("In order to use usr-verity, UKI support must be enabled")] UsrVerityRequiresUkiSupport, - #[error( - "SELinux mode '{selinux_mode}' is not supported with verity, must be set to 'disabled'" - )] - VerityAndSelinuxUnsupported { selinux_mode: String }, - #[error("Verity device '{device_name}' must define a mount point.")] VerityFilesystemWithoutMountPoint { device_name: String }, @@ -212,11 +207,12 @@ pub enum HostConfigurationDynamicValidationError { #[error("Failed to load script '{name}' at '{path}'")] LoadScript { name: String, path: String }, - #[error("Cannot modify storage configuration during update")] - StorageConfigurationChanged, - #[error( - "SELinux mode '{selinux_mode}' is not supported with verity, must be set to 'disabled'" + "SELinux is not support with root-verity and grub. SELinux is set to '{selinux_mode}', \ + but should be set to 'disabled'." )] - VerityAndSelinuxUnsupported { selinux_mode: String }, + RootVerityAndSelinuxUnsupported { selinux_mode: String }, + + #[error("Cannot modify storage configuration during update")] + StorageConfigurationChanged, } diff --git a/trident_api/src/config/host/mod.rs b/trident_api/src/config/host/mod.rs index 953cc776c..5258b210f 100644 --- a/trident_api/src/config/host/mod.rs +++ b/trident_api/src/config/host/mod.rs @@ -4,9 +4,7 @@ use serde::{Deserialize, Serialize}; #[cfg(feature = "schemars")] use schemars::JsonSchema; -use crate::{ - constants::internal_params::ENABLE_UKI_SUPPORT, is_default, storage_graph::graph::StorageGraph, -}; +use crate::{is_default, storage_graph::graph::StorageGraph}; pub(crate) mod error; pub(crate) mod harpoon; @@ -83,11 +81,6 @@ impl HostConfiguration { self.validate_datastore_location()?; - // Gate usr-verity support behind the UKI support parameter. - if graph.usr_fs_is_verity() && !self.internal_params.get_flag(ENABLE_UKI_SUPPORT) { - return Err(HostConfigurationStaticValidationError::UsrVerityRequiresUkiSupport); - } - Ok(()) } @@ -114,20 +107,14 @@ impl HostConfiguration { return Err(HostConfigurationStaticValidationError::SelfUpgradeOnReadOnlyRootVerityFs); } - // If SELinux is in `enforcing` mode, produce an error. Warn if SELinux - // is in `permissive` mode. - match self.os.selinux.mode { - Some(SelinuxMode::Enforcing) => { - return Err( - HostConfigurationStaticValidationError::VerityAndSelinuxUnsupported { - selinux_mode: SelinuxMode::Enforcing.to_string(), - }, + // Warn if SELinux is not `disbled. + if let Some(selinux_mode) = self.os.selinux.mode { + if selinux_mode != SelinuxMode::Disabled { + warn!( + "The use of SELinux with root-verity and grub is not supported. This \ + configuration will only work with a UKI-based image." ); } - Some(SelinuxMode::Permissive) => { - warn!("The use of SELinux with verity is not supported. SELinux mode is currently set to '{}', but should be 'disabled'.", SelinuxMode::Permissive.to_string()); - } - _ => {} } Ok(()) @@ -419,23 +406,16 @@ mod tests { let graph = host_config.storage.build_graph().unwrap(); - // Check that 'enforcing' mode returns an error - host_config.os.selinux.mode = Some(SelinuxMode::Enforcing); + // Check that if 'selfUpgrade' is set, we return an error + host_config.trident.self_upgrade = true; let validation_error = host_config.validate_root_verity_config(&graph).unwrap_err(); assert_eq!( validation_error, - HostConfigurationStaticValidationError::VerityAndSelinuxUnsupported { - selinux_mode: SelinuxMode::Enforcing.to_string() - }, - "{validation_error}" + HostConfigurationStaticValidationError::SelfUpgradeOnReadOnlyRootVerityFs ); - // Check that 'permissive' mode does not return an error - host_config.os.selinux.mode = Some(SelinuxMode::Permissive); - host_config.validate_root_verity_config(&graph).unwrap(); - - // Check that 'disabled' mode does not return an error - host_config.os.selinux.mode = Some(SelinuxMode::Disabled); + // Check that if 'selfUpgrade' is not set, no error is returned + host_config.trident.self_upgrade = false; host_config.validate_root_verity_config(&graph).unwrap(); } } diff --git a/trident_api/src/config/host/storage/mod.rs b/trident_api/src/config/host/storage/mod.rs index 0b334f2cc..4e9bb4312 100644 --- a/trident_api/src/config/host/storage/mod.rs +++ b/trident_api/src/config/host/storage/mod.rs @@ -8,7 +8,7 @@ use serde::{Deserialize, Serialize}; #[cfg(feature = "schemars")] use schemars::JsonSchema; -use swap::SwapDevice; +use swap::Swap; use crate::{ constants::{ @@ -87,10 +87,10 @@ pub struct Storage { #[cfg_attr( feature = "schemars", schemars( - schema_with = "crate::primitives::shortcuts::vec_string_or_struct_schema::" + schema_with = "crate::primitives::shortcuts::vec_string_or_struct_schema::" ) )] - pub swap: Vec, + pub swap: Vec, } impl Storage { @@ -157,6 +157,10 @@ impl Storage { builder.add_node(fs.into()); } + for swap in &self.swap { + builder.add_node(swap.into()); + } + // Try to build the graph builder.build() } diff --git a/trident_api/src/config/host/storage/storage_graph/conversions.rs b/trident_api/src/config/host/storage/storage_graph/conversions.rs index b7fa03f73..1b52afacc 100644 --- a/trident_api/src/config/host/storage/storage_graph/conversions.rs +++ b/trident_api/src/config/host/storage/storage_graph/conversions.rs @@ -2,7 +2,7 @@ use crate::config::{ AbVolumePair, AdoptedPartition, Disk, EncryptedVolume, FileSystem, FileSystemSource, Partition, - SoftwareRaidArray, VerityDevice, + SoftwareRaidArray, Swap, VerityDevice, }; use super::{ @@ -95,6 +95,13 @@ impl From<&FileSystem> for StorageGraphNode { } } +/// Get a StorageGraphNode from a SwapDevice reference. +impl From<&Swap> for StorageGraphNode { + fn from(swap: &Swap) -> Self { + Self::new_swap(swap.clone()) + } +} + /// Get a BlkDevReferrerKind from a FileSystem reference. impl From<&FileSystem> for BlkDevReferrerKind { fn from(fs: &FileSystem) -> Self { diff --git a/trident_api/src/config/host/storage/storage_graph/display.rs b/trident_api/src/config/host/storage/storage_graph/display.rs index bf046098f..40bd92e52 100644 --- a/trident_api/src/config/host/storage/storage_graph/display.rs +++ b/trident_api/src/config/host/storage/storage_graph/display.rs @@ -32,7 +32,6 @@ impl Display for BlkDevKind { Self::ABVolume => write!(f, "ab-volume"), Self::EncryptedVolume => write!(f, "encrypted-volume"), Self::VerityDevice => write!(f, "verity-device"), - Self::SwapDevice => write!(f, "swap-device"), } } } @@ -45,7 +44,7 @@ impl Display for BlkDevReferrerKind { Self::ABVolume => write!(f, "ab-volume"), Self::EncryptedVolume => write!(f, "encrypted-volume"), Self::VerityDevice => write!(f, "verity-device"), - Self::SwapDevice => write!(f, "swap-device"), + Self::Swap => write!(f, "swap-device"), Self::FileSystemNew => write!(f, "filesystem-new"), Self::FileSystemEsp => write!(f, "filesystem-esp"), Self::FileSystemAdopted => write!(f, "filesystem-adopted"), diff --git a/trident_api/src/config/host/storage/storage_graph/error.rs b/trident_api/src/config/host/storage/storage_graph/error.rs index c6a8fb668..aba1eaf69 100644 --- a/trident_api/src/config/host/storage/storage_graph/error.rs +++ b/trident_api/src/config/host/storage/storage_graph/error.rs @@ -22,6 +22,7 @@ fn pretty_node_id(node_identifier: &NodeIdentifier) -> String { match node_identifier { NodeIdentifier::BlockDevice(id) => format!("'{}'", id), NodeIdentifier::FileSystem(fs) => format!("filesystem [{}]", fs), + NodeIdentifier::Swap(swap) => format!("swap on '{}'", swap), } } diff --git a/trident_api/src/config/host/storage/storage_graph/graph.rs b/trident_api/src/config/host/storage/storage_graph/graph.rs index e9593e877..7616e9599 100644 --- a/trident_api/src/config/host/storage/storage_graph/graph.rs +++ b/trident_api/src/config/host/storage/storage_graph/graph.rs @@ -323,12 +323,6 @@ fn block_device_size(graph: &StoragePetgraph, idx: NodeIndex) -> Option { // For adopted partitions, we report None, as we don't know the size. HostConfigBlockDevice::AdoptedPartition(_) => None, - - // For swap devices, report the size of the backing device. - HostConfigBlockDevice::SwapDevice(_) => { - let backing_node_idx = graph.neighbors_directed(idx, Direction::Outgoing).next()?; - block_device_size(graph, backing_node_idx) - } } } diff --git a/trident_api/src/config/host/storage/storage_graph/node.rs b/trident_api/src/config/host/storage/storage_graph/node.rs index 2c5d7dfdd..2c87cca64 100644 --- a/trident_api/src/config/host/storage/storage_graph/node.rs +++ b/trident_api/src/config/host/storage/storage_graph/node.rs @@ -1,6 +1,9 @@ use serde::{Deserialize, Serialize}; -use crate::{config::FileSystem, BlockDeviceId}; +use crate::{ + config::{FileSystem, Swap}, + BlockDeviceId, +}; use super::{ references::{SpecialReferenceKind, StorageReference}, @@ -17,6 +20,7 @@ pub struct BlockDevice { pub enum StorageGraphNode { BlockDevice(BlockDevice), FileSystem(FileSystem), + Swap(Swap), } impl StorageGraphNode { @@ -33,11 +37,17 @@ impl StorageGraphNode { Self::FileSystem(fs) } + /// Creates a new swap device node. + pub fn new_swap(swap: Swap) -> Self { + Self::Swap(swap) + } + /// Returns a user friendly identifier of the node. pub fn identifier(&self) -> NodeIdentifier { match self { Self::BlockDevice(dev) => NodeIdentifier::from(dev), Self::FileSystem(fs) => NodeIdentifier::from(fs), + Self::Swap(swap) => NodeIdentifier::from(swap), } } @@ -54,8 +64,9 @@ impl StorageGraphNode { /// - `verity filesystem 'root'` pub fn describe(&self) -> String { match self { - Self::BlockDevice(dev) => format!("block device '{}'", dev.id), + Self::BlockDevice(dev) => format!("{} '{}'", dev.kind(), dev.id), Self::FileSystem(fs) => format!("filesystem [{}]", fs.description()), + Self::Swap(swap) => format!("swap on '{}'", swap.device_id), } } @@ -64,6 +75,7 @@ impl StorageGraphNode { match self { Self::BlockDevice(dev) => Some(&dev.id), Self::FileSystem(_) => None, + Self::Swap(_) => None, } } @@ -84,11 +96,20 @@ impl StorageGraphNode { } } + /// Returns the inner swap device, if this node is a swap device. + #[allow(dead_code)] + pub fn as_swap_device(&self) -> Option<&Swap> { + match self { + Self::Swap(swap) => Some(swap), + _ => None, + } + } + /// Returns the kind of block device this node represents. pub fn device_kind(&self) -> BlkDevKind { match self { Self::BlockDevice(dev) => dev.kind(), - Self::FileSystem(_) => BlkDevKind::None, + Self::FileSystem(_) | Self::Swap(_) => BlkDevKind::None, } } @@ -97,6 +118,7 @@ impl StorageGraphNode { match self { Self::BlockDevice(dev) => dev.host_config_ref.referrer_kind(), Self::FileSystem(fs) => (fs).into(), + Self::Swap(_) => BlkDevReferrerKind::Swap, } } @@ -133,9 +155,6 @@ impl StorageGraphNode { ), ] } - HostConfigBlockDevice::SwapDevice(swap_dev) => { - vec![StorageReference::new_regular(&swap_dev.device_id)] - } }, Self::FileSystem(fs) => fs .device_id @@ -143,6 +162,7 @@ impl StorageGraphNode { .map(StorageReference::new_regular) .into_iter() .collect(), + Self::Swap(swap) => vec![StorageReference::new_regular(&swap.device_id)], } } } @@ -152,6 +172,7 @@ impl StorageGraphNode { pub enum NodeIdentifier { BlockDevice(String), FileSystem(String), + Swap(String), } impl From<&FileSystem> for NodeIdentifier { @@ -166,6 +187,12 @@ impl From<&BlockDevice> for NodeIdentifier { } } +impl From<&Swap> for NodeIdentifier { + fn from(swap: &Swap) -> Self { + Self::Swap(swap.device_id.to_string()) + } +} + #[cfg(test)] impl NodeIdentifier { pub fn block_device(id: &str) -> Self { diff --git a/trident_api/src/config/host/storage/storage_graph/rules/mod.rs b/trident_api/src/config/host/storage/storage_graph/rules/mod.rs index 12104e14a..1a74a5742 100644 --- a/trident_api/src/config/host/storage/storage_graph/rules/mod.rs +++ b/trident_api/src/config/host/storage/storage_graph/rules/mod.rs @@ -90,7 +90,6 @@ impl HostConfigBlockDevice { Self::ABVolume(_) => (), Self::EncryptedVolume(_) => (), Self::VerityDevice(_) => (), - Self::SwapDevice(_) => (), } Ok(()) @@ -184,7 +183,7 @@ impl BlkDevReferrerKind { Self::ABVolume => ValidCardinality::new_exact(2), Self::EncryptedVolume => ValidCardinality::new_exact(1), Self::VerityDevice => ValidCardinality::new_exact(2), - Self::SwapDevice => ValidCardinality::new_exact(1), + Self::Swap => ValidCardinality::new_exact(1), Self::FileSystemNew => ValidCardinality::new_at_most(1), Self::FileSystemEsp => ValidCardinality::new_exact(1), @@ -226,7 +225,7 @@ impl BlkDevReferrerKind { Self::VerityDevice => { BlkDevKindFlag::Partition | BlkDevKindFlag::RaidArray | BlkDevKindFlag::ABVolume } - Self::SwapDevice => BlkDevKindFlag::Partition | BlkDevKindFlag::EncryptedVolume, + Self::Swap => BlkDevKindFlag::Partition | BlkDevKindFlag::EncryptedVolume, } } } @@ -265,7 +264,7 @@ impl BlkDevReferrerKind { | Self::ABVolume | Self::EncryptedVolume | Self::VerityDevice - | Self::SwapDevice + | Self::Swap | Self::FileSystemNew | Self::FileSystemEsp | Self::FileSystemAdopted @@ -288,7 +287,7 @@ impl BlkDevReferrerKind { | Self::ABVolume | Self::EncryptedVolume | Self::VerityDevice - | Self::SwapDevice => true, + | Self::Swap => true, // These only have one target, so enforcing this is meaningless. Self::FileSystemNew @@ -374,7 +373,6 @@ impl BlkDevKind { Ok(Some(blkdev.unwrap_verity_device()?.name.as_bytes())) }), )]), - Self::SwapDevice => None, } } } @@ -400,7 +398,7 @@ impl BlkDevReferrerKind { // These don't really care about partition sizes. Self::EncryptedVolume - | Self::SwapDevice + | Self::Swap | Self::FileSystemNew | Self::FileSystemEsp | Self::FileSystemAdopted @@ -426,7 +424,7 @@ impl BlkDevReferrerKind { // These care about having all underlying partitions be of the same // type. Self::EncryptedVolume - | Self::SwapDevice + | Self::Swap | Self::FileSystemNew | Self::FileSystemEsp | Self::FileSystemAdopted @@ -488,7 +486,7 @@ impl BlkDevReferrerKind { ]) } Self::FileSystemImage => AllowBlockList::Any, - Self::SwapDevice => AllowBlockList::Allow(vec![PartitionType::Swap]), + Self::Swap => AllowBlockList::Allow(vec![PartitionType::Swap]), } } } @@ -588,7 +586,7 @@ impl BlkDevReferrerKind { | Self::ABVolume | Self::EncryptedVolume | Self::VerityDevice - | Self::SwapDevice + | Self::Swap | Self::FileSystemNew | Self::FileSystemEsp | Self::FileSystemAdopted diff --git a/trident_api/src/config/host/storage/storage_graph/types.rs b/trident_api/src/config/host/storage/storage_graph/types.rs index f8fd46b1c..cedd3d55e 100644 --- a/trident_api/src/config/host/storage/storage_graph/types.rs +++ b/trident_api/src/config/host/storage/storage_graph/types.rs @@ -10,7 +10,7 @@ use serde::{Deserialize, Serialize}; use crate::config::{ AbVolumePair, AdoptedPartition, Disk, EncryptedVolume, Partition, SoftwareRaidArray, - SwapDevice, VerityDevice, + VerityDevice, }; /// Enum for supported block device types @@ -44,9 +44,6 @@ pub enum BlkDevKind { /// A verity device VerityDevice, - - /// A swap partition - SwapDevice, } bitflags::bitflags! { @@ -62,7 +59,6 @@ bitflags::bitflags! { const ABVolume = 1 << 4; const EncryptedVolume = 1 << 5; const VerityDevice = 1 << 6; - const SwapDevice = 1 << 7; } } @@ -89,9 +85,6 @@ pub enum HostConfigBlockDevice { /// A verity device VerityDevice(VerityDevice), - - /// A swap partition - SwapDevice(SwapDevice), } /// Enum for referrer kinds. @@ -122,8 +115,8 @@ pub enum BlkDevReferrerKind { /// A verity device VerityDevice, - /// A swap device - SwapDevice, + /// A swap mount + Swap, /// A new filesystem FileSystemNew, @@ -186,7 +179,6 @@ impl HostConfigBlockDevice { Self::ABVolume(_) => BlkDevKind::ABVolume, Self::EncryptedVolume(_) => BlkDevKind::EncryptedVolume, Self::VerityDevice(_) => BlkDevKind::VerityDevice, - Self::SwapDevice(_) => BlkDevKind::SwapDevice, } } @@ -200,7 +192,6 @@ impl HostConfigBlockDevice { Self::ABVolume(_) => BlkDevReferrerKind::ABVolume, Self::EncryptedVolume(_) => BlkDevReferrerKind::EncryptedVolume, Self::VerityDevice(_) => BlkDevReferrerKind::VerityDevice, - Self::SwapDevice(_) => BlkDevReferrerKind::SwapDevice, } } @@ -279,7 +270,6 @@ impl BlkDevKind { Self::ABVolume => BlkDevKindFlag::ABVolume, Self::EncryptedVolume => BlkDevKindFlag::EncryptedVolume, Self::VerityDevice => BlkDevKindFlag::VerityDevice, - Self::SwapDevice => BlkDevKindFlag::SwapDevice, } } } @@ -293,7 +283,7 @@ impl BlkDevReferrerKind { Self::ABVolume => BlkDevReferrerKindFlag::ABVolume, Self::EncryptedVolume => BlkDevReferrerKindFlag::EncryptedVolume, Self::VerityDevice => BlkDevReferrerKindFlag::VerityDevice, - Self::SwapDevice => BlkDevReferrerKindFlag::SwapDevice, + Self::Swap => BlkDevReferrerKindFlag::SwapDevice, Self::FileSystemNew => BlkDevReferrerKindFlag::FileSystemNew, Self::FileSystemEsp => BlkDevReferrerKindFlag::FileSystemEsp, Self::FileSystemAdopted => BlkDevReferrerKindFlag::FileSystemAdopted, @@ -334,7 +324,6 @@ impl BitFlagsBackingEnumVec for BlkDevKindFlag { Self::ABVolume => BlkDevKind::ABVolume, Self::EncryptedVolume => BlkDevKind::EncryptedVolume, Self::VerityDevice => BlkDevKind::VerityDevice, - Self::SwapDevice => BlkDevKind::SwapDevice, _ => unreachable!("Invalid block device kind flag: {:?}", kind), }) .collect() @@ -350,7 +339,7 @@ impl BitFlagsBackingEnumVec for BlkDevReferrerKindFlag { Self::RaidArray => BlkDevReferrerKind::RaidArray, Self::ABVolume => BlkDevReferrerKind::ABVolume, Self::VerityDevice => BlkDevReferrerKind::VerityDevice, - Self::SwapDevice => BlkDevReferrerKind::SwapDevice, + Self::SwapDevice => BlkDevReferrerKind::Swap, Self::EncryptedVolume => BlkDevReferrerKind::EncryptedVolume, Self::FileSystemNew => BlkDevReferrerKind::FileSystemNew, Self::FileSystemEsp => BlkDevReferrerKind::FileSystemEsp, diff --git a/trident_api/src/config/host/storage/swap.rs b/trident_api/src/config/host/storage/swap.rs index b528867a9..5027dd063 100644 --- a/trident_api/src/config/host/storage/swap.rs +++ b/trident_api/src/config/host/storage/swap.rs @@ -10,8 +10,8 @@ use crate::BlockDeviceId; #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] #[serde(rename_all = "camelCase", deny_unknown_fields)] #[cfg_attr(feature = "schemars", derive(JsonSchema))] -pub struct SwapDevice { - /// The ID of the block device to use for this swap device. +pub struct Swap { + /// The ID of the block device to use for this swap area. #[cfg_attr( feature = "schemars", schemars(schema_with = "crate::schema_helpers::block_device_id_schema") @@ -19,18 +19,18 @@ pub struct SwapDevice { pub device_id: BlockDeviceId, } -impl FromStr for SwapDevice { +impl FromStr for Swap { type Err = String; fn from_str(s: &str) -> Result { - Ok(SwapDevice { + Ok(Swap { device_id: s.to_owned(), }) } } #[cfg(feature = "schemars")] -impl crate::primitives::shortcuts::StringOrStructMetadata for SwapDevice { +impl crate::primitives::shortcuts::StringOrStructMetadata for Swap { fn shorthand_format() -> &'static str { crate::schema_helpers::BLOCK_DEVICE_ID_FORMAT } diff --git a/trident_api/src/config/mod.rs b/trident_api/src/config/mod.rs index 64e8d9e5c..b39a3f394 100644 --- a/trident_api/src/config/mod.rs +++ b/trident_api/src/config/mod.rs @@ -21,7 +21,7 @@ pub use host::{ filesystem_types::{AdoptedFileSystemType, FileSystemType, NewFileSystemType}, partitions::{AdoptedPartition, Partition, PartitionSize, PartitionType}, raid::{Raid, RaidLevel, SoftwareRaidArray}, - swap::SwapDevice, + swap::Swap, verity::{VerityCorruptionOption, VerityDevice}, Storage, }, diff --git a/trident_api/src/constants.rs b/trident_api/src/constants.rs index e84674208..ddb942294 100644 --- a/trident_api/src/constants.rs +++ b/trident_api/src/constants.rs @@ -212,4 +212,7 @@ pub mod internal_params { /// Allow unused images in a COSI file. pub const ALLOW_UNUSED_FILESYSTEMS_IN_COSI: &str = "allowUnusedFilesystems"; + + /// Overrides the default PCR registries to use when sealing encryption keys. + pub const OVERRIDE_ENCRYPTION_PCRS: &str = "overrideEncryptionPcrs"; } diff --git a/trident_api/src/error.rs b/trident_api/src/error.rs index dac40a53d..88fc90f59 100644 --- a/trident_api/src/error.rs +++ b/trident_api/src/error.rs @@ -11,6 +11,7 @@ use url::Url; use crate::{ config::{HostConfigurationDynamicValidationError, HostConfigurationStaticValidationError}, + primitives::bytes::ByteCount, status::ServicingState, storage_graph::error::StorageGraphBuildError, }; @@ -141,10 +142,16 @@ pub enum InvalidInputError { CleanInstallOnProvisionedHost, #[error( - "Filesystem size exceeds underlying block device's size for device '{device_id}'. \ - The filesystem requires at least {min_size} bytes on the block device." + "Filesystem mounted at '{mount_point}' requires at least {} [{fs_size} bytes] of storage. \ + However, the underlying block device '{device_id}' has storage size {} [{device_size} bytes].", + fs_size.to_human_readable_approx(), device_size.to_human_readable_approx() )] - FilesystemSizeExceedsBlockDevice { device_id: String, min_size: u64 }, + FilesystemSizeExceedsBlockDevice { + device_id: String, + mount_point: String, + device_size: ByteCount, + fs_size: ByteCount, + }, #[error("Image is corrupt: multiple partitions have be assigned the same FS UUID: {uuid}")] DuplicateFsUuid { uuid: String }, @@ -254,7 +261,7 @@ pub enum InvalidInputError { #[error( "Filesystem at '{mount_point}' in OS Image is not being used by the provided Host \ - Configuration. This could mean that the Host COnfiguration is missing a filesystem \ + Configuration. This could mean that the Host Configuration is missing a filesystem \ definition." )] UnusedOsImageFilesystem { mount_point: String }, @@ -374,6 +381,9 @@ pub enum ServicingError { #[error("Failed to deploy images")] DeployImages, + #[error("Failed to disable cloud-init networking")] + DisableCloudInitNetworking, + #[error( "Failed to encrypt and open block device '{device_path}' with id '{device_id}' as \ '{encrypted_volume_device_name}' for encrypted volume '{encrypted_volume}'" @@ -388,6 +398,9 @@ pub enum ServicingError { #[error("Failed to enter chroot")] EnterChroot, + #[error("Failed to enumerate UKIs")] + EnumerateUkis, + #[error("Failed to exit chroot")] ExitChroot, @@ -409,9 +422,15 @@ pub enum ServicingError { #[error("Failed to generate Netplan config")] GenerateNetplanConfig, + #[error("Failed to generate .pcrlock file at '{pcrlock_file}'")] + GeneratePcrlockFile { pcrlock_file: String }, + #[error("Failed to generate recovery key file '{key_file}'")] GenerateRecoveryKeyFile { key_file: String }, + #[error("Failed to generate a new TPM 2.0 access policy")] + GenerateTpm2AccessPolicy, + #[error("Failed to get block device path for device '{device_id}'")] GetBlockDevicePath { device_id: String }, @@ -475,6 +494,9 @@ pub enum ServicingError { #[error("Failed to do a read operation with efibootmgr")] ReadEfibootmgr, + #[error("Failed to read EFI variable '{name}'")] + ReadEfiVariable { name: String }, + #[error("Failed to read current system hostname from {path}")] ReadHostname { path: String }, @@ -515,6 +537,9 @@ pub enum ServicingError { #[error("Failed to run post-provision script '{script_name}'")] RunPostProvisionScript { script_name: String }, + #[error("Failed to set EFI variable '{name}'")] + SetEfiVariable { name: String }, + #[error("Failed to set permissions on temporary recovery key file '{key_file}'")] SetRecoveryKeyFilePermissions { key_file: String }, @@ -524,6 +549,9 @@ pub enum ServicingError { #[error("Failed to start network")] StartNetwork, + #[error("Failed to update UKI")] + UpdateUki, + #[error( "Volume {active_volume} is active but active volume in Host Status is set to \ {hs_active_volume}" @@ -533,6 +561,9 @@ pub enum ServicingError { hs_active_volume: String, }, + #[error("Failed to validate systemd-pcrlock log output")] + ValidatePcrlockLog, + #[error("Trident rebuild-raid validation failed")] ValidateRebuildRaid, diff --git a/trident_api/src/samples/sample_hc.rs b/trident_api/src/samples/sample_hc.rs index db59fa702..dd2fa2c75 100644 --- a/trident_api/src/samples/sample_hc.rs +++ b/trident_api/src/samples/sample_hc.rs @@ -15,7 +15,7 @@ use crate::{ FileSystemSource, HostConfiguration, ImageSha384, MountOptions, MountPoint, NewFileSystemType, Os, OsImage, Partition, PartitionTableType, PartitionType, Raid, RaidLevel, Script, ScriptSource, Scripts, Services, ServicingTypeSelection, - SoftwareRaidArray, SshMode, Storage, SwapDevice, User, VerityDevice, + SoftwareRaidArray, SshMode, Storage, Swap, User, VerityDevice, }, constants::{self, MOUNT_OPTION_READ_ONLY, ROOT_MOUNT_POINT_PATH}, }; @@ -307,7 +307,7 @@ pub fn sample_host_configuration(name: &str) -> Result<(&'static str, HostConfig volume_b_id: "root-b".into(), }], }), - swap: vec![SwapDevice { + swap: vec![Swap { device_id: "swap".into(), }], ..Default::default() @@ -907,10 +907,10 @@ pub fn sample_host_configuration(name: &str) -> Result<(&'static str, HostConfig ..Default::default() }], swap: vec![ - SwapDevice { + Swap { device_id: "swap1".into(), }, - SwapDevice { + Swap { device_id: "swap2".into(), }, ] @@ -1049,9 +1049,9 @@ pub fn sample_host_configuration(name: &str) -> Result<(&'static str, HostConfig source: FileSystemSource::Image, }, ], - swap: vec![SwapDevice { + swap: vec![Swap { device_id: "swap1".into(), - }, SwapDevice { + }, Swap { device_id: "swap2".into(), }], ..Default::default() @@ -1194,7 +1194,7 @@ pub fn sample_host_configuration(name: &str) -> Result<(&'static str, HostConfig source: FileSystemSource::New(NewFileSystemType::Ext4), }, ], - swap: vec![SwapDevice { + swap: vec![Swap { device_id: "swap".into(), }], ..Default::default()