From 31cdc411d29758bae54a0b3fdb37be2777bd9fdc Mon Sep 17 00:00:00 2001 From: Vishwas Siravara Date: Wed, 10 Jan 2024 08:19:44 -0800 Subject: [PATCH] exp: make finch work on windows with wsl2 (#649) Issue #, if available: *Description of changes:* - translation logic to wsl paths - persistent disk for windows - CI/CD (workflows to run CI on every PR on windows runners, MSI builder, Windows release automation) This PR combines 4 distinct PRs to a separate windev branch. - additional disk for windows https://github.com/runfinch/finch/pull/594 - translation logic for wsl paths https://github.com/runfinch/finch/pull/581 - CI https://github.com/runfinch/finch/pull/581 - Installer https://github.com/runfinch/finch/pull/624 This PR also contains bug fixes and modifications to e2e tests. *Testing done:* Yes - [X] I've reviewed the guidance in CONTRIBUTING.md #### License Acceptance By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license. --------- Signed-off-by: Vishwas Siravara Signed-off-by: Vishwas Siravara Signed-off-by: Gavin Inglis Signed-off-by: Justin Alvarez Signed-off-by: chaoningusc Signed-off-by: cnkevin@amazon.com Signed-off-by: Kevin Li Co-authored-by: Vishwas Siravara Co-authored-by: Gavin Inglis <43075615+ginglis13@users.noreply.github.com> Co-authored-by: Justin Co-authored-by: Kevin Li Co-authored-by: chaoningusc Co-authored-by: Justin Alvarez --- .github/workflows/benchmark.yaml | 4 +- .github/workflows/build-and-test-msi.yaml | 251 ++++ .github/workflows/build-and-test-pkg.yaml | 28 +- .github/workflows/ci-docs.yaml | 4 +- .github/workflows/ci.yaml | 148 +- .github/workflows/lint-pr-title.yaml | 2 +- .github/workflows/release-automation.yaml | 4 +- .github/workflows/release-homebrew.yaml | 20 +- .github/workflows/release-please.yaml | 2 +- .../workflows/sync-submodules-and-deps.yaml | 6 +- .github/workflows/upload-build-to-S3.yaml | 20 +- .../upload-installer-to-release.yaml | 4 +- .github/workflows/upload-msi-to-release.yaml | 60 + .gitignore | 2 + .gitmodules | 2 +- .markdownlint.yaml | 4 + CONTRIBUTING.md | 6 + Makefile | 109 +- README.md | 67 +- benchmark/suite.go | 2 +- cmd/finch/lima_args_darwin.go | 10 + cmd/finch/lima_args_windows.go | 20 + cmd/finch/main.go | 57 +- cmd/finch/main_darwin.go | 42 + cmd/finch/main_test.go | 84 +- cmd/finch/main_windows.go | 42 + cmd/finch/nerdctl.go | 87 +- cmd/finch/nerdctl_darwin.go | 38 + ...nerdctl_test.go => nerdctl_darwin_test.go} | 179 +-- cmd/finch/nerdctl_shared_test.go | 210 +++ cmd/finch/nerdctl_windows.go | 397 ++++++ cmd/finch/nerdctl_windows_test.go | 1214 +++++++++++++++++ cmd/finch/virtual_machine.go | 36 +- cmd/finch/virtual_machine_init.go | 6 + cmd/finch/virtual_machine_init_test.go | 28 +- cmd/finch/virtual_machine_remove.go | 15 +- cmd/finch/virtual_machine_remove_test.go | 40 +- cmd/finch/virtual_machine_start.go | 1 + cmd/finch/virtual_machine_start_test.go | 24 +- cmd/finch/virtual_machine_stop.go | 18 +- cmd/finch/virtual_machine_stop_test.go | 45 +- deps/finch-core | 2 +- e2e/container/container_test.go | 27 +- e2e/e2e.go | 2 +- e2e/vm/additional_disk_test.go | 14 +- e2e/vm/config_darwin_test.go | 54 + e2e/vm/config_test.go | 35 +- e2e/vm/config_windows_test.go | 12 + e2e/vm/cred_helper_test.go | 20 +- e2e/vm/finch_config_file_test.go | 17 +- e2e/vm/lifecycle_test.go | 2 +- e2e/vm/soci_test.go | 20 +- e2e/vm/support_bundle_test.go | 63 +- ...lization_framework_rosetta_darwin_test.go} | 2 + e2e/vm/vm_darwin_test.go | 81 ++ e2e/vm/vm_test.go | 82 +- e2e/vm/vm_windows_test.go | 61 + finch.windows.yaml | 94 ++ finch.yaml | 4 +- go.mod | 17 +- go.sum | 25 +- msi-builder/BuildFinchMSI.ps1 | 101 ++ msi-builder/FinchMSITemplate.wxs | 123 ++ msi-builder/LICENSE.rtf | 326 +++++ msi-builder/README.md | 20 + msi-builder/finch.ico | Bin 0 -> 85514 bytes msi-builder/postinstall.bat | 17 + msi-builder/uninstall.bat | 5 + pkg/command/command.go | 1 + pkg/command/exec.go | 8 + pkg/command/lima.go | 48 +- pkg/command/lima_test.go | 4 +- pkg/command/lima_unix_test.go | 12 + pkg/command/lima_windows_test.go | 12 + pkg/config/config.go | 40 +- pkg/config/config_darwin.go | 39 + pkg/config/config_test.go | 111 +- pkg/config/config_windows.go | 15 + pkg/config/defaults.go | 37 +- pkg/config/defaults_darwin.go | 55 + pkg/config/defaults_test.go | 45 +- pkg/config/defaults_windows.go | 30 + pkg/config/lima_config_applier.go | 140 +- pkg/config/lima_config_applier_darwin.go | 103 ++ ....go => lima_config_applier_darwin_test.go} | 4 +- pkg/config/lima_config_applier_windows.go | 28 + pkg/config/nerdctl_config_applier.go | 174 ++- pkg/config/nerdctl_config_applier_test.go | 176 ++- .../{validate.go => validate_darwin.go} | 2 + ...lidate_test.go => validate_darwin_test.go} | 2 + pkg/config/validate_windows.go | 15 + pkg/dependency/credhelper/cred_helper.go | 14 +- .../credhelper/cred_helper_binary.go | 7 +- .../credhelper/cred_helper_binary_test.go | 17 +- .../vmnet/{binaries.go => binaries_unix.go} | 2 + ...binaries_test.go => binaries_unix_test.go} | 3 + .../{sudoers_file.go => sudoers_file_unix.go} | 2 + ...file_test.go => sudoers_file_unix_test.go} | 16 + ...go => update_override_lima_config_unix.go} | 2 + ... update_override_lima_config_unix_test.go} | 3 + .../vmnet/{vmnet.go => vmnet_unix.go} | 2 + .../{vmnet_test.go => vmnet_unix_test.go} | 3 + pkg/disk/disk.go | 209 +-- pkg/disk/disk_unix.go | 228 ++++ pkg/disk/{disk_test.go => disk_unix_test.go} | 11 +- pkg/disk/disk_windows.go | 138 ++ pkg/disk/min_win_disk.zip | Bin 0 -> 1431765 bytes pkg/flog/formatter_string.go | 24 + pkg/flog/log.go | 17 + pkg/flog/logrus.go | 14 +- pkg/fssh/fssh_test.go | 19 +- pkg/lima/lima.go | 5 +- pkg/lima/lima_test.go | 10 + pkg/mocks/command_command.go | 15 + pkg/mocks/finch_finder_deps.go | 15 + pkg/mocks/logger.go | 12 + pkg/mocks/nerdctl_cmd_system_deps.go | 62 + pkg/mocks/pkg_disk_disk.go | 47 +- pkg/path/finch.go | 38 +- pkg/path/finch_test.go | 28 +- pkg/path/finch_unix.go | 17 + pkg/path/finch_windows.go | 31 + pkg/path/finch_windows_test.go | 59 + pkg/support/config.go | 14 +- pkg/support/config_test.go | 30 +- pkg/support/redact.go | 3 +- pkg/support/support.go | 20 +- pkg/support/support_test.go | 37 +- pkg/system/stdlib.go | 16 + pkg/system/system.go | 20 + pkg/tools.go | 1 + pkg/winutil/io.go | 31 + pkg/winutil/io_test.go | 122 ++ scripts/gen-code-windows.ps1 | 21 + winres/winres.json | 58 + 135 files changed, 5918 insertions(+), 1190 deletions(-) create mode 100644 .github/workflows/build-and-test-msi.yaml create mode 100644 .github/workflows/upload-msi-to-release.yaml create mode 100644 cmd/finch/lima_args_darwin.go create mode 100644 cmd/finch/lima_args_windows.go create mode 100644 cmd/finch/main_darwin.go create mode 100644 cmd/finch/main_windows.go create mode 100644 cmd/finch/nerdctl_darwin.go rename cmd/finch/{nerdctl_test.go => nerdctl_darwin_test.go} (87%) create mode 100644 cmd/finch/nerdctl_shared_test.go create mode 100644 cmd/finch/nerdctl_windows.go create mode 100644 cmd/finch/nerdctl_windows_test.go create mode 100644 e2e/vm/config_darwin_test.go create mode 100644 e2e/vm/config_windows_test.go rename e2e/vm/{virtualization_framework_rosetta_test.go => virtualization_framework_rosetta_darwin_test.go} (99%) create mode 100644 e2e/vm/vm_darwin_test.go create mode 100644 e2e/vm/vm_windows_test.go create mode 100644 finch.windows.yaml create mode 100644 msi-builder/BuildFinchMSI.ps1 create mode 100644 msi-builder/FinchMSITemplate.wxs create mode 100644 msi-builder/LICENSE.rtf create mode 100644 msi-builder/README.md create mode 100644 msi-builder/finch.ico create mode 100644 msi-builder/postinstall.bat create mode 100644 msi-builder/uninstall.bat create mode 100644 pkg/command/lima_unix_test.go create mode 100644 pkg/command/lima_windows_test.go create mode 100644 pkg/config/config_darwin.go create mode 100644 pkg/config/config_windows.go create mode 100644 pkg/config/defaults_darwin.go create mode 100644 pkg/config/defaults_windows.go create mode 100644 pkg/config/lima_config_applier_darwin.go rename pkg/config/{lima_config_applier_test.go => lima_config_applier_darwin_test.go} (99%) create mode 100644 pkg/config/lima_config_applier_windows.go rename pkg/config/{validate.go => validate_darwin.go} (98%) rename pkg/config/{validate_test.go => validate_darwin_test.go} (99%) create mode 100644 pkg/config/validate_windows.go rename pkg/dependency/vmnet/{binaries.go => binaries_unix.go} (99%) rename pkg/dependency/vmnet/{binaries_test.go => binaries_unix_test.go} (99%) rename pkg/dependency/vmnet/{sudoers_file.go => sudoers_file_unix.go} (99%) rename pkg/dependency/vmnet/{sudoers_file_test.go => sudoers_file_unix_test.go} (96%) rename pkg/dependency/vmnet/{update_override_lima_config.go => update_override_lima_config_unix.go} (99%) rename pkg/dependency/vmnet/{update_override_lima_config_test.go => update_override_lima_config_unix_test.go} (99%) rename pkg/dependency/vmnet/{vmnet.go => vmnet_unix.go} (99%) rename pkg/dependency/vmnet/{vmnet_test.go => vmnet_unix_test.go} (94%) create mode 100644 pkg/disk/disk_unix.go rename pkg/disk/{disk_test.go => disk_unix_test.go} (98%) create mode 100644 pkg/disk/disk_windows.go create mode 100644 pkg/disk/min_win_disk.zip create mode 100644 pkg/flog/formatter_string.go create mode 100644 pkg/path/finch_unix.go create mode 100644 pkg/path/finch_windows.go create mode 100644 pkg/path/finch_windows_test.go create mode 100644 pkg/winutil/io.go create mode 100644 pkg/winutil/io_test.go create mode 100644 scripts/gen-code-windows.ps1 create mode 100644 winres/winres.json diff --git a/.github/workflows/benchmark.yaml b/.github/workflows/benchmark.yaml index 2f6d1128d..6b16609d5 100644 --- a/.github/workflows/benchmark.yaml +++ b/.github/workflows/benchmark.yaml @@ -29,7 +29,7 @@ jobs: ] runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 with: # We need to get all the git tags to make version injection work. See VERSION in Makefile for more detail. fetch-depth: 0 @@ -62,7 +62,7 @@ jobs: echo "OS_VERSION=$(sw_vers -productVersion | cut -d '.' -f 1)" >> $GITHUB_ENV echo "ARCH=$(uname -m)" >> $GITHUB_ENV - name: Store benchmark result - uses: benchmark-action/github-action-benchmark@v1 + uses: benchmark-action/github-action-benchmark@70405016b032d44f409e4b1b451c40215cbe2393 # v1.18.0 with: name: Finch Benchmark tool: 'go' diff --git a/.github/workflows/build-and-test-msi.yaml b/.github/workflows/build-and-test-msi.yaml new file mode 100644 index 000000000..5a30579e4 --- /dev/null +++ b/.github/workflows/build-and-test-msi.yaml @@ -0,0 +1,251 @@ +name: Build, test and upload .msi to S3 + +# TODO: add scheduler and tests +on: + workflow_dispatch: + workflow_call: + inputs: + ref_name: + required: true + type: string +env: + GO111MODULE: on + +permissions: + # This is required for configure-aws-credentials to request an OIDC JWT ID token to access AWS resources later on. + # More info: https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/about-security-hardening-with-openid-connect#adding-permissions-settings + id-token: write + contents: read # This is required for actions/checkout + +jobs: + get-tag-name: + name: Get tag name + runs-on: ubuntu-latest + outputs: + tag: ${{ steps.check-tag.outputs.tag }} + version: ${{ steps.check-tag.outputs.version }} + steps: + - name: Check tag from workflow input and github ref + id: check-tag + run: | + if [ -n "${{ inputs.ref_name }}" ]; then + tag=${{ inputs.ref_name }} + else + tag=${{ github.ref_name }} + fi + echo "tag=$tag" >> ${GITHUB_OUTPUT} + + version=${tag#v} + if [[ $version =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "Version matches format: $version" + else + echo "Version $version doesn't match format. Using test version: 0.0.1" + version="0.0.1" + fi + echo "version=$version" >> ${GITHUB_OUTPUT} + + windows-msi-build: + needs: get-tag-name + runs-on: [self-hosted, windows, amd64, release] + timeout-minutes: 100 + steps: + - name: Configure git CRLF settings + run: | + git config --global core.autocrlf false + git config --global core.eol lf + - name: Set up Python + uses: actions/setup-python@0a5c61591373683505ea898e09a3ea4f39ef2b9c # v5.0.0 + with: + python-version: '3.x' + - name: Install AWS CLI + run: | + python -m pip install --upgrade pip + pip install awscli + - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + with: + ref: ${{ needs.get-tag-name.outputs.tag }} + fetch-depth: 0 + persist-credentials: false + submodules: recursive + - name: configure aws credentials + uses: aws-actions/configure-aws-credentials@010d0da01d0b5a38af31e9c3470dbfdabdecca3a # v4.0.1 + with: + role-to-assume: ${{ secrets.WINDOWS_ROLE }} + role-session-name: windows-msi + aws-region: ${{ secrets.WINDOWS_REGION }} + - name: Remove Finch VM + run: | + wsl --list --verbose + wsl --shutdown + wsl --unregister lima-finch + wsl --list --verbose + - name: Clean up previous files + run: | + Remove-Item C:\Users\Administrator\.finch -Recurse -ErrorAction Ignore + Remove-Item C:\Users\Administrator\AppData\Local\.finch -Recurse -ErrorAction Ignore + make clean + cd deps/finch-core && make clean + - name: Build project + run: | + make FINCH_ROOTFS_LOCATION_ROOT=/__INSTALLFOLDER__ + - name: generate and download signed msi + run: | + $version="${{ needs.get-tag-name.outputs.version }}" + $tag="${{ needs.get-tag-name.outputs.tag }}" + powershell .\msi-builder\BuildFinchMSI.ps1 -Version $version + $timestamp=[math]::truncate((Get-Date (Get-Date).ToUniversalTime() -UFormat "%s")) + $unsignedMSI="Finch-$tag-$timestamp.msi" + Write-Host "Upload unsigned MSI: $unsignedMSI" + + aws s3 cp "./msi-builder/build/Finch-$version.msi" "${{ secrets.WINDOWS_UNSIGNED_BUCKET }}$unsignedMSI" --acl bucket-owner-full-control --no-progress + New-Item -Path "./msi-builder/build/signed/" -ItemType Directory -Force + + Write-Host "Attemp to download signed MSI" + $retryCount = 0 + $maxRetries = 20 + $delay = 5 + + while ($retryCount -lt $maxRetries) { + Start-Sleep -Seconds $delay + $signedMSI = aws s3 ls ${{ secrets.WINDOWS_SIGNED_BUCKET }} 2>&1 | Where-Object { $_ -match "$unsignedMSI" } | Sort-Object -Descending | Select-Object -First 1 | ForEach-Object { ($_ -split '\s+')[-1] } + if ($signedMSI -and ($signedMSI -notlike "*An error occurred (404) when calling the HeadObject operation*")) { + try { + aws s3 cp "${{ secrets.WINDOWS_SIGNED_BUCKET }}$signedMSI" "./msi-builder/build/signed/Finch-$tag.msi" + break + } catch { + Write-Host "Error during copy: $_" + } + } else { + $retryCount++ + Write-Host "Unable to find the signed MSI or encountered an error. Retry $retryCount/$maxRetries..." + } + } + + if ($retryCount -eq $maxRetries) { + throw "Failed after $maxRetries attempts." + } + - name: configure aws credentials for upload signed MSI to installer bucket + uses: aws-actions/configure-aws-credentials@010d0da01d0b5a38af31e9c3470dbfdabdecca3a # v4.0.1 + with: + role-to-assume: ${{ secrets.ROLE }} + role-session-name: windows-msi + aws-region: ${{ secrets.REGION }} + - name: upload signed MSI to S3 + run: | + $tag="${{ needs.get-tag-name.outputs.tag }}" + aws s3 cp "./msi-builder/build/signed/Finch-$tag.msi" "s3://${{ secrets.INSTALLER_PRIVATE_BUCKET_NAME }}/Finch-$tag.msi" --no-progress + - name: Remove Finch VM and Clean Up Previous Environment + if: ${{ always() }} + run: | + # We want these cleanup commands to always run, ignore errors so the step completes. + $ErrorActionPreference = 'Ignore' + wsl --list --verbose + wsl --shutdown + wsl --unregister lima-finch + wsl --list --verbose + Remove-Item C:\Users\Administrator\AppData\Local\.finch -Recurse + make clean + cd deps/finch-core && make clean + exit 0 # Cleanup may set the exit code e.g. if a file doesn't exist; just ignore + + msi-e2e-tests: + needs: + - get-tag-name + - windows-msi-build + strategy: + fail-fast: false + runs-on: [self-hosted, windows, amd64, release] + timeout-minutes: 180 + steps: + - name: Configure git CRLF settings + run: | + git config --global core.autocrlf false + git config --global core.eol lf + - name: Set up Python + uses: actions/setup-python@0a5c61591373683505ea898e09a3ea4f39ef2b9c # v5.0.0 + with: + python-version: '3.x' + - name: Install AWS CLI + run: | + python -m pip install --upgrade pip + pip install awscli + - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + with: + ref: ${{ needs.get-tag-name.outputs.tag }} + fetch-depth: 0 + persist-credentials: false + submodules: recursive + - name: Set output variables + id: vars + run: | + $has_creds="${{ github.event_name == 'push' || github.repository == github.event.pull_request.head.repo.full_name }}" + echo "has_creds=$has_creds" | Out-File -FilePath $Env:GITHUB_ENV -Encoding utf8 -Append + exit 0 # if $has_creds is false, powershell will exit with code 1 and this step will fail + - name: configure aws credentials + uses: aws-actions/configure-aws-credentials@010d0da01d0b5a38af31e9c3470dbfdabdecca3a # v4.0.1 + with: + role-to-assume: ${{ secrets.ROLE }} + role-session-name: msi-test + aws-region: ${{ secrets.REGION }} + - name: Remove Finch VM + run: | + wsl --list --verbose + wsl --shutdown + wsl --unregister lima-finch + wsl --list --verbose + - name: Clean up previous files + run: | + Remove-Item C:\Users\Administrator\.finch -Recurse -ErrorAction Ignore + Remove-Item C:\Users\Administrator\AppData\Local\.finch -Recurse -ErrorAction Ignore + make clean + cd deps/finch-core && make clean + - name: Uninstall Finch silently + run: | + $productCode = (Get-WmiObject -Class Win32_Product | Where-Object { $_.Name -like "*Finch*" } | Select-Object -ExpandProperty IdentifyingNumber) + if ($productCode) { + msiexec /x $productCode /qn + } else { + Write-Output "Finch not found or it wasn't installed using MSI." + } + - name: Download MSI from S3 + run: | + $tag="${{ needs.get-tag-name.outputs.tag }}" + aws s3 cp "s3://${{ secrets.INSTALLER_PRIVATE_BUCKET_NAME }}/Finch-$tag.msi" ./Finch.msi + - name: Install MSI silently + run: | + Start-Process 'Finch.msi' -ArgumentList '/quiet' -Wait + echo "C:\Program Files\Finch\bin" >> $env:GITHUB_PATH + - name: Run e2e tests + run: | + # set path to use newer ssh version + $newPath = (";C:\Program Files\Git\bin\;" + "C:\Program Files\Git\usr\bin\;" + "$env:Path") + $env:Path = $newPath + # set networking config option to allow for VM/container -> host communication + echo "[experimental]`nnetworkingMode=mirrored`nhostAddressLoopback=true" > C:\Users\Administrator\.wslconfig + + git status + git clean -f -d + $env:INSTALLED="true" + make test-e2e + - name: Uninstall Finch silently + if: ${{ always() }} + run: | + $productCode = (Get-WmiObject -Class Win32_Product | Where-Object { $_.Name -like "*Finch*" } | Select-Object -ExpandProperty IdentifyingNumber) + if ($productCode) { + msiexec /x $productCode /qn + } else { + Write-Output "Finch not found or it wasn't installed using MSI." + } + - name: Remove Finch VM and Clean Up Previous Environment + if: ${{ always() }} + run: | + # We want these cleanup commands to always run, ignore errors so the step completes. + $ErrorActionPreference = 'Ignore' + wsl --list --verbose + wsl --shutdown + wsl --unregister lima-finch + wsl --list --verbose + Remove-Item C:\Users\Administrator\AppData\Local\.finch -Recurse + make clean + cd deps/finch-core && make clean + exit 0 # Cleanup may set the exit code e.g. if a file doesn't exist; just ignore diff --git a/.github/workflows/build-and-test-pkg.yaml b/.github/workflows/build-and-test-pkg.yaml index 51f28c359..a7967a8ee 100644 --- a/.github/workflows/build-and-test-pkg.yaml +++ b/.github/workflows/build-and-test-pkg.yaml @@ -41,13 +41,13 @@ jobs: runs-on: [self-hosted, macos, arm64, 11, release] timeout-minutes: 60 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 with: ref: ${{ needs.get-tag-name.outputs.tag }} fetch-depth: 0 persist-credentials: false submodules: true - - uses: actions/setup-go@v5 + - uses: actions/setup-go@0c52d547c9bc32b1aa3301fd7a9cb496313a4491 # v5.0.0 with: go-version-file: go.mod cache: true @@ -63,7 +63,7 @@ jobs: shell: zsh {0} - name: configure aws credentials - uses: aws-actions/configure-aws-credentials@v4 + uses: aws-actions/configure-aws-credentials@010d0da01d0b5a38af31e9c3470dbfdabdecca3a # v4.0.1 with: role-to-assume: ${{ secrets.ROLE }} role-session-name: dependency-upload-session @@ -79,13 +79,13 @@ jobs: runs-on: [self-hosted, macos, amd64, 11, release] timeout-minutes: 60 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 with: ref: ${{ needs.get-tag-name.outputs.tag }} fetch-depth: 0 persist-credentials: false submodules: true - - uses: actions/setup-go@v5 + - uses: actions/setup-go@0c52d547c9bc32b1aa3301fd7a9cb496313a4491 # v5.0.0 with: go-version-file: go.mod cache: true @@ -101,7 +101,7 @@ jobs: shell: zsh {0} - name: configure aws credentials - uses: aws-actions/configure-aws-credentials@v4 + uses: aws-actions/configure-aws-credentials@010d0da01d0b5a38af31e9c3470dbfdabdecca3a # v4.0.1 with: role-to-assume: ${{ secrets.ROLE }} role-session-name: dependency-upload-session @@ -130,13 +130,13 @@ jobs: ACCESS_TOKEN: ${{ secrets.FINCH_BOT_TOKEN }} steps: - name: Checkout the tag - uses: actions/checkout@v4 + uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 with: ref: ${{ needs.get-tag-name.outputs.tag }} fetch-depth: 0 persist-credentials: false submodules: true - - uses: actions/setup-go@v5 + - uses: actions/setup-go@0c52d547c9bc32b1aa3301fd7a9cb496313a4491 # v5.0.0 with: go-version-file: go.mod cache: true @@ -152,7 +152,7 @@ jobs: sudo pkill '^socket_vmnet' fi - name: configure aws credentials - uses: aws-actions/configure-aws-credentials@v4 + uses: aws-actions/configure-aws-credentials@010d0da01d0b5a38af31e9c3470dbfdabdecca3a # v4.0.1 with: role-to-assume: ${{ secrets.ROLE }} role-session-name: download-installer-session @@ -210,7 +210,7 @@ jobs: # Example workflow run https://github.com/runfinch/finch/actions/runs/4367457552/jobs/7638794529 sudo installer -pkg Finch-${{ needs.get-tag-name.outputs.tag }}-aarch64.pkg -target / - name: Run e2e tests - uses: nick-fields/retry@v2 + uses: nick-fields/retry@14672906e672a08bd6eeb15720e9ed3ce869cdd4 # v2.9.0 with: timeout_minutes: 180 max_attempts: 3 @@ -241,13 +241,13 @@ jobs: ACCESS_TOKEN: ${{ secrets.FINCH_BOT_TOKEN }} steps: - name: Checkout the tag - uses: actions/checkout@v4 + uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 with: ref: ${{ needs.get-tag-name.outputs.tag }} fetch-depth: 0 persist-credentials: false submodules: true - - uses: actions/setup-go@v5 + - uses: actions/setup-go@0c52d547c9bc32b1aa3301fd7a9cb496313a4491 # v5.0.0 with: go-version-file: go.mod cache: true @@ -263,7 +263,7 @@ jobs: sudo pkill '^socket_vmnet' fi - name: configure aws credentials - uses: aws-actions/configure-aws-credentials@v4 + uses: aws-actions/configure-aws-credentials@010d0da01d0b5a38af31e9c3470dbfdabdecca3a # v4.0.1 with: role-to-assume: ${{ secrets.ROLE }} role-session-name: download-installer-session @@ -319,7 +319,7 @@ jobs: echo 'y' | sudo bash /Applications/Finch/uninstall.sh sudo installer -pkg Finch-${{ needs.get-tag-name.outputs.tag }}-x86_64.pkg -target / - name: Run e2e tests - uses: nick-fields/retry@v2 + uses: nick-fields/retry@14672906e672a08bd6eeb15720e9ed3ce869cdd4 # v2.9.0 with: timeout_minutes: 180 max_attempts: 3 diff --git a/.github/workflows/ci-docs.yaml b/.github/workflows/ci-docs.yaml index 58ef80952..c8accf070 100644 --- a/.github/workflows/ci-docs.yaml +++ b/.github/workflows/ci-docs.yaml @@ -61,8 +61,8 @@ jobs: mdlint: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: avto-dev/markdown-lint@v1 + - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + - uses: avto-dev/markdown-lint@04d43ee9191307b50935a753da3b775ab695eceb # v1.5.0 with: args: '**/*.md' # CHANGELOG.md is only updated by release-please bot. diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 2178865bd..15a954628 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -26,20 +26,31 @@ concurrency: jobs: gen-code-no-diff: - runs-on: ubuntu-latest + strategy: + matrix: + os: [macos-latest, windows-latest] + runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v4 - - uses: actions/setup-go@v5 + - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + - uses: actions/setup-go@0c52d547c9bc32b1aa3301fd7a9cb496313a4491 # v5.0.0 with: go-version-file: go.mod cache: true - run: make gen-code - run: git diff --exit-code unit-tests: - runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + os: [macos-latest, windows-latest] + runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v4 - - uses: actions/setup-go@v5 + - name: Configure git CRLF settings + run: | + git config --global core.autocrlf false + git config --global core.eol lf + - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + - uses: actions/setup-go@0c52d547c9bc32b1aa3301fd7a9cb496313a4491 # v5.0.0 with: # Since this repository is not meant to be used as a library, # we don't need to test the latest 2 major releases like Go does: https://go.dev/doc/devel/release#policy. @@ -48,36 +59,51 @@ jobs: - run: make test-unit # It's recommended to run golangci-lint in a job separate from other jobs (go test, etc) because different jobs run in parallel. go-linter: + name: lint runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: actions/setup-go@v5 + - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + - uses: actions/setup-go@0c52d547c9bc32b1aa3301fd7a9cb496313a4491 # v5.0.0 with: go-version-file: go.mod - cache: true - - name: golangci-lint - uses: golangci/golangci-lint-action@v3 + cache: false # caching can result in tar errors that files already exist + - name: set GOOS env to windows + run: | + echo "GOOS=windows" >> $GITHUB_ENV + - name: golangci-lint - windows + uses: golangci/golangci-lint-action@3a919529898de77ec3da873e3063ca4b10e7f5cc # v3.7.0 with: # Pin the version in case all the builds start to fail at the same time. # There may not be an automatic way (e.g., dependabot) to update a specific parameter of a GitHub Action, # so we will just update it manually whenever it makes sense (e.g., a feature that we want is added). version: v1.53.3 args: --fix=false --timeout=5m + - name: set GOOS env to darwin + run: | + echo "GOOS=darwin" >> $GITHUB_ENV + - name: golangci-lint - darwin + uses: golangci/golangci-lint-action@3a919529898de77ec3da873e3063ca4b10e7f5cc # v3.7.0 + with: + # Pin the version in case all the builds start to fail at the same time. + # There may not be an automatic way (e.g., dependabot) to update a specific parameter of a GitHub Action, + # so we will just update it manually whenever it makes sense (e.g., a feature that we want is added). + version: v1.53.3 + args: --fix=false --timeout=5m --skip-dirs="(^|/)deps($|/)" shellcheck: name: ShellCheck runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 - name: Run ShellCheck - uses: ludeeus/action-shellcheck@2.0.0 + uses: ludeeus/action-shellcheck@00cae500b08a931fb5698e11e79bfbd38e612a38 # 2.0.0 with: version: v0.9.0 continue-on-error: true go-mod-tidy-check: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: actions/setup-go@v5 + - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + - uses: actions/setup-go@0c52d547c9bc32b1aa3301fd7a9cb496313a4491 # v5.0.0 with: go-version-file: go.mod cache: true @@ -87,8 +113,8 @@ jobs: check-licenses: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: actions/setup-go@v5 + - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + - uses: actions/setup-go@0c52d547c9bc32b1aa3301fd7a9cb496313a4491 # v5.0.0 with: go-version-file: go.mod cache: true @@ -106,19 +132,19 @@ jobs: ] runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 with: # We need to get all the git tags to make version injection work. See VERSION in Makefile for more detail. fetch-depth: 0 persist-credentials: false - submodules: true + submodules: recursive - name: Set output variables id: vars run: | has_creds=${{ github.event_name == 'push' || github.repository == github.event.pull_request.head.repo.full_name }} echo "has_creds=$has_creds" >> $GITHUB_OUTPUT - name: configure aws credentials - uses: aws-actions/configure-aws-credentials@v4 + uses: aws-actions/configure-aws-credentials@010d0da01d0b5a38af31e9c3470dbfdabdecca3a # v4.0.1 if: steps.vars.outputs.has_creds == true with: role-to-assume: ${{ secrets.ROLE }} @@ -149,11 +175,89 @@ jobs: git clean -f -d REGISTRY=${{ steps.vars.outputs.has_creds == true && env.REGISTRY || '' }} make test-e2e shell: zsh {0} + windows-e2e-tests: + strategy: + fail-fast: false + matrix: + os: + [ + [self-hosted, windows, amd64, test], + ] + runs-on: ${{ matrix.os }} + timeout-minutes: 180 + steps: + - name: Configure git CRLF settings + run: | + git config --global core.autocrlf false + git config --global core.eol lf + - name: Cleanup previous checkouts + run: | + Remove-Item C:\actions-runner\_work\finch\finch -Recurse -Force -ErrorAction Ignore + - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + with: + # We need to get all the git tags to make version injection work. See VERSION in Makefile for more detail. + fetch-depth: 0 + persist-credentials: false + submodules: recursive + - name: Set output variables + id: vars + run: | + $has_creds="${{ github.event_name == 'push' || github.repository == github.event.pull_request.head.repo.full_name }}" + echo "has_creds=$has_creds" | Out-File -FilePath $Env:GITHUB_ENV -Encoding utf8 -Append + exit 0 # if $has_creds is false, powershell will exit with code 1 and this step will fail + - name: configure aws credentials + uses: aws-actions/configure-aws-credentials@010d0da01d0b5a38af31e9c3470dbfdabdecca3a # v4.0.1 + if: env.has_creds == 'true' + with: + role-to-assume: ${{ secrets.ROLE }} + role-session-name: credhelper-test + aws-region: ${{ secrets.REGION }} + - name: Remove Finch VM + run: | + wsl --list --verbose + wsl --shutdown + wsl --unregister lima-finch + wsl --list --verbose + - name: Clean up previous files + run: | + Remove-Item C:\Users\Administrator\.finch -Recurse -ErrorAction Ignore + Remove-Item C:\Users\Administrator\AppData\Local\.finch -Recurse -ErrorAction Ignore + make clean + cd deps/finch-core && make clean + - name: Build project + run: | + git status + make + - name: Run e2e tests + run: | + # set path to use newer ssh version + $newPath = (";C:\Program Files\Git\bin\;" + "C:\Program Files\Git\usr\bin\;" + "$env:Path") + $env:Path = $newPath + + # set networking config option to allow for VM/container -> host communication + echo "[experimental]`nnetworkingMode=mirrored`nhostAddressLoopback=true" > C:\Users\Administrator\.wslconfig + + git status + git clean -f -d + make test-e2e + - name: Remove Finch VM and Clean Up Previous Environment + if: ${{ always() }} + run: | + # We want these cleanup commands to always run, ignore errors so the step completes. + $ErrorActionPreference = 'Ignore' + wsl --list --verbose + wsl --shutdown + wsl --unregister lima-finch + wsl --list --verbose + Remove-Item C:\Users\Administrator\AppData\Local\.finch -Recurse + make clean + cd deps/finch-core && make clean + exit 0 # Cleanup may set the exit code e.g. if a file doesn't exist; just ignore mdlint: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: avto-dev/markdown-lint@v1 + - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + - uses: avto-dev/markdown-lint@04d43ee9191307b50935a753da3b775ab695eceb # v1.5.0 with: args: '**/*.md' # CHANGELOG.md is only updated by release-please bot. diff --git a/.github/workflows/lint-pr-title.yaml b/.github/workflows/lint-pr-title.yaml index 28fc45003..0c97aa0c6 100644 --- a/.github/workflows/lint-pr-title.yaml +++ b/.github/workflows/lint-pr-title.yaml @@ -14,7 +14,7 @@ jobs: name: conventional-commit runs-on: ubuntu-latest steps: - - uses: amannn/action-semantic-pull-request@v5 + - uses: amannn/action-semantic-pull-request@47b15d52c5c30e94a17ec87eb8dd51ff5221fed9 # v5.3.0 with: # List from https://github.com/commitizen/conventional-commit-types/blob/master/index.json # with custom types added at the end. diff --git a/.github/workflows/release-automation.yaml b/.github/workflows/release-automation.yaml index 80d925d95..085eb2f63 100644 --- a/.github/workflows/release-automation.yaml +++ b/.github/workflows/release-automation.yaml @@ -12,12 +12,12 @@ jobs: outputs: tag: ${{ steps.latest-tag.outputs.tag }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 with: fetch-depth: 0 - name: 'Get the latest tag' id: latest-tag - uses: "WyriHaximus/github-action-get-previous-tag@v1" + uses: "WyriHaximus/github-action-get-previous-tag@385a2a0b6abf6c2efeb95adfac83d96d6f968e0c" # v1.3.0 build-and-test-finch-pkg: needs: get-latest-tag diff --git a/.github/workflows/release-homebrew.yaml b/.github/workflows/release-homebrew.yaml index 9a6a835b0..aafb5b03c 100644 --- a/.github/workflows/release-homebrew.yaml +++ b/.github/workflows/release-homebrew.yaml @@ -13,12 +13,12 @@ jobs: tag: ${{ steps.latesttag.outputs.tag }} version: ${{ steps.latestversion.outputs.version }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 with: fetch-depth: 0 - name: 'Get the latest tag' id: latesttag - uses: "WyriHaximus/github-action-get-previous-tag@v1" + uses: "WyriHaximus/github-action-get-previous-tag@385a2a0b6abf6c2efeb95adfac83d96d6f968e0c" # v1.3.0 - name: 'Convert tag to version' id: latestversion run: | @@ -53,7 +53,7 @@ jobs: FINCH_TAG: ${{ needs.get-latest-tag.outputs.tag }} FINCH_VERSION: ${{ needs.get-latest-tag.outputs.version }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 with: ref: ${{ env.FINCH_TAG }} fetch-depth: 0 @@ -78,7 +78,7 @@ jobs: shell: zsh {0} - name: Set up Homebrew id: set-up-homebrew - uses: Homebrew/actions/setup-homebrew@master + uses: Homebrew/actions/setup-homebrew@40a8596d17543401e57ee60f640c2a5df7c88904 # master - name: Bump local cask version run: | brew update-reset @@ -144,7 +144,7 @@ jobs: brew reinstall --cask ./Casks/f/finch.rb shell: zsh {0} - name: Run e2e tests - uses: nick-fields/retry@v2 + uses: nick-fields/retry@14672906e672a08bd6eeb15720e9ed3ce869cdd4 # v2.9.0 with: timeout_minutes: 180 max_attempts: 3 @@ -176,13 +176,13 @@ jobs: FINCH_TAG: ${{ needs.get-latest-tag.outputs.tag }} FINCH_VERSION: ${{ needs.get-latest-tag.outputs.version }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 with: ref: ${{ env.FINCH_TAG }} fetch-depth: 0 persist-credentials: false submodules: true - - uses: actions/setup-go@v5 + - uses: actions/setup-go@0c52d547c9bc32b1aa3301fd7a9cb496313a4491 # v5.0.0 with: go-version-file: go.mod cache: true @@ -201,7 +201,7 @@ jobs: shell: zsh {0} - name: Set up Homebrew id: set-up-homebrew - uses: Homebrew/actions/setup-homebrew@master + uses: Homebrew/actions/setup-homebrew@40a8596d17543401e57ee60f640c2a5df7c88904 # master - name: Bump local cask version run: | brew update-reset @@ -262,7 +262,7 @@ jobs: brew reinstall --cask ./Casks/f/finch.rb shell: zsh {0} - name: Run e2e tests - uses: nick-fields/retry@v2 + uses: nick-fields/retry@14672906e672a08bd6eeb15720e9ed3ce869cdd4 # v2.9.0 with: timeout_minutes: 180 max_attempts: 3 @@ -283,7 +283,7 @@ jobs: steps: - name: Set up Homebrew id: set-up-homebrew - uses: Homebrew/actions/setup-homebrew@master + uses: Homebrew/actions/setup-homebrew@40a8596d17543401e57ee60f640c2a5df7c88904 # master - name: Open a pull request to homebrwe-cask run: brew bump-cask-pr --version=${FINCH_VERSION} finch shell: zsh {0} diff --git a/.github/workflows/release-please.yaml b/.github/workflows/release-please.yaml index 310a37a1d..79b23aed3 100644 --- a/.github/workflows/release-please.yaml +++ b/.github/workflows/release-please.yaml @@ -8,4 +8,4 @@ jobs: release-please: runs-on: ubuntu-latest steps: - - uses: google-github-actions/release-please-action@v4 + - uses: google-github-actions/release-please-action@cc61a07e2da466bebbc19b3a7dd01d6aecb20d1e # v4.0.2 diff --git a/.github/workflows/sync-submodules-and-deps.yaml b/.github/workflows/sync-submodules-and-deps.yaml index 7f32679e6..eedca5389 100644 --- a/.github/workflows/sync-submodules-and-deps.yaml +++ b/.github/workflows/sync-submodules-and-deps.yaml @@ -18,13 +18,13 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 with: submodules: recursive token: ${{ secrets.GITHUB_TOKEN }} - name: configure aws credentials - uses: aws-actions/configure-aws-credentials@v4 + uses: aws-actions/configure-aws-credentials@010d0da01d0b5a38af31e9c3470dbfdabdecca3a # v4.0.1 with: aws-region: ${{ secrets.REGION }} role-to-assume: ${{ secrets.ROLE }} @@ -47,7 +47,7 @@ jobs: ./deps/finch-core/bin/update-rootfs.sh -d ${{ secrets.DEPENDENCY_BUCKET_NAME }} - name: Create PR - uses: peter-evans/create-pull-request@v5 + uses: peter-evans/create-pull-request@153407881ec5c347639a548ade7d8ad1d6740e38 # v5.0.2 with: token: ${{ secrets.GITHUB_TOKEN }} signoff: true diff --git a/.github/workflows/upload-build-to-S3.yaml b/.github/workflows/upload-build-to-S3.yaml index c8ad0d3a0..0c9b0993e 100644 --- a/.github/workflows/upload-build-to-S3.yaml +++ b/.github/workflows/upload-build-to-S3.yaml @@ -16,12 +16,12 @@ jobs: runs-on: [self-hosted, macos, arm64, 11, release] timeout-minutes: 60 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 with: fetch-depth: 0 persist-credentials: false submodules: true - - uses: actions/setup-go@v5 + - uses: actions/setup-go@0c52d547c9bc32b1aa3301fd7a9cb496313a4491 # v5.0.0 with: go-version-file: go.mod cache: true @@ -36,7 +36,7 @@ jobs: shell: zsh {0} - name: Upload macos aarch64 build - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@c7d193f32edcb7bfad88892161225aeda64e9392 # v4.0.0 with: name: finch.macos-aarch64 path: finch.*.aarch64.tar.gz @@ -46,12 +46,12 @@ jobs: runs-on: [self-hosted, macos, amd64, 11, release] timeout-minutes: 60 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 with: fetch-depth: 0 persist-credentials: false submodules: true - - uses: actions/setup-go@v5 + - uses: actions/setup-go@0c52d547c9bc32b1aa3301fd7a9cb496313a4491 # v5.0.0 with: go-version-file: go.mod cache: true @@ -66,7 +66,7 @@ jobs: shell: zsh {0} - name: Upload macos x86_64 build - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@c7d193f32edcb7bfad88892161225aeda64e9392 # v4.0.0 with: name: finch.macos-x86_64 path: finch.*.x86_64.tar.gz @@ -79,26 +79,26 @@ jobs: - macos-x86_64-build - macos-aarch64-build steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 with: fetch-depth: 0 persist-credentials: false - name: configure aws credentials - uses: aws-actions/configure-aws-credentials@v4 + uses: aws-actions/configure-aws-credentials@010d0da01d0b5a38af31e9c3470dbfdabdecca3a # v4.0.1 with: role-to-assume: ${{ secrets.ROLE }} role-session-name: dependency-upload-session aws-region: ${{ secrets.REGION }} - name: Download macos aarch64 build - uses: actions/download-artifact@v4 + uses: actions/download-artifact@f44cd7b40bfd40b6aa1cc1b9b5b7bf03d3c67110 # v4.1.0 with: name: finch.macos-aarch64 path: build - name: Download macos x86_64 build - uses: actions/download-artifact@v4 + uses: actions/download-artifact@f44cd7b40bfd40b6aa1cc1b9b5b7bf03d3c67110 # v4.1.0 with: name: finch.macos-x86_64 path: build diff --git a/.github/workflows/upload-installer-to-release.yaml b/.github/workflows/upload-installer-to-release.yaml index 7df1e9d80..ec90fe9ec 100644 --- a/.github/workflows/upload-installer-to-release.yaml +++ b/.github/workflows/upload-installer-to-release.yaml @@ -32,7 +32,7 @@ jobs: runs-on: ubuntu-latest steps: - name: configure aws credentials - uses: aws-actions/configure-aws-credentials@v4 + uses: aws-actions/configure-aws-credentials@010d0da01d0b5a38af31e9c3470dbfdabdecca3a # v4.0.1 with: role-to-assume: ${{ secrets.ROLE }} role-session-name: download-installer-session @@ -43,7 +43,7 @@ jobs: aws s3 cp s3://${{ secrets.INSTALLER_PRIVATE_BUCKET_NAME }}/Finch-${{ needs.get-tag-name.outputs.tag }}-x86_64.pkg Finch-${{ needs.get-tag-name.outputs.tag }}-x86_64.pkg aws s3 cp s3://${{ secrets.DEPENDENCY_BUCKET_NAME }}/dependency-sources.tar.gz DependenciesSourceCode.tar.gz - name: Upload installers and dependency source code to release - uses: softprops/action-gh-release@v1 + uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v0.1.15 with: tag_name: ${{ needs.get-tag-name.outputs.tag }} files: | diff --git a/.github/workflows/upload-msi-to-release.yaml b/.github/workflows/upload-msi-to-release.yaml new file mode 100644 index 000000000..fdb26c42f --- /dev/null +++ b/.github/workflows/upload-msi-to-release.yaml @@ -0,0 +1,60 @@ +name: Upload installer +on: + workflow_dispatch: # Trigger this workflow from tag + workflow_call: + inputs: + ref_name: + required: true + type: string + +permissions: + id-token: write # This is required for requesting the JWT + contents: write # This is required for uploading the release assets +jobs: + get-version-tag: + name: Get the version, tag and validate the format + runs-on: ubuntu-latest + outputs: + tag: ${{ steps.check-tag.outputs.tag }} + version: ${{ steps.check-tag.outputs.version }} + steps: + - name: Check tag from workflow input and github ref + id: check-tag + run: | + if [ -n "${{ inputs.ref_name }}" ]; then + tag=${{ inputs.ref_name }} + else + tag=${{ github.ref_name }} + fi + echo "tag=$tag" >> ${GITHUB_OUTPUT} + + version=${tag#v} + if [[ $version =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "Version matches format: $version" + else + echo "Error: Version $version doesn't match format." + exit 1 + fi + echo "version=$version" >> ${GITHUB_OUTPUT} + + upload-windows-msi: + needs: get-version-tag + runs-on: ubuntu-latest + steps: + - name: configure aws credentials + uses: aws-actions/configure-aws-credentials@010d0da01d0b5a38af31e9c3470dbfdabdecca3a # v4.0.1 + with: + role-to-assume: ${{ secrets.ROLE }} + role-session-name: download-installer-session + aws-region: ${{ secrets.REGION }} + - name: Download installers and dependency source code + run: | + aws s3 cp s3://${{ secrets.INSTALLER_PRIVATE_BUCKET_NAME }}/Finch-${{ get-version-tag.outputs.tag }}.msi Finch-${{ get-version-tag.outputs.tag }}.msi + - name: Upload installers and dependency source code to release + uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v0.1.15 + with: + tag_name: ${{ get-version-tag.outputs.tag }} + files: | + Finch-${{ get-version-tag.outputs.tag }}.msi + - name: Delete installers and dependency source code + run: rm -rf Finch-${{ get-version-tag.outputs.tag }}.msi \ No newline at end of file diff --git a/.gitignore b/.gitignore index 9da28fa6f..9decec16d 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,5 @@ tmp/ .vscode/ tools_bin/ test-coverage.* +*.syso +msi-builder/build/ \ No newline at end of file diff --git a/.gitmodules b/.gitmodules index 5d08fcc22..f2ba058af 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,3 @@ -[submodule "deps/finch-core"] +[submodule "finch-core"] path = deps/finch-core url = https://github.com/runfinch/finch-core.git diff --git a/.markdownlint.yaml b/.markdownlint.yaml index 71a801501..a7f77aabe 100644 --- a/.markdownlint.yaml +++ b/.markdownlint.yaml @@ -1,2 +1,6 @@ # Modern IDEs usually automatically wrap long lines in *.md files, so this may be unnecessary. line-length: false + +MD024: + # Only check sibling headings + siblings_only: true diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 877d4753a..fa1822bc0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -140,6 +140,12 @@ If the repo is already cloned, but the submodules are not pulled yet, the follow git submodule update --init --recursive ``` +If you are building on Windows from a fork of finch, you may need to fetch upstream tags in order to build: + +```shell +git fetch --tags +``` + After cloning the repo, run the following command to make subsequent `git pull` to also update submodules to the versions specified in the upstream branch. ```shell diff --git a/Makefile b/Makefile index c14beaab1..9632afb71 100644 --- a/Makefile +++ b/Makefile @@ -23,9 +23,17 @@ SUPPORTED_ARCH = false CORE_VDE_PREFIX ?= $(OUTDIR)/dependencies/vde/opt/finch LICENSEDIR := $(OUTDIR)/license-files VERSION := $(shell git describe --match 'v[0-9]*' --dirty='.modified' --always --tags) -GITCOMMIT := $(shell git rev-parse HEAD)$(shell if ! git diff --no-ext-diff --quiet --exit-code; then echo .m; fi) +GITCOMMIT := $(shell git rev-parse HEAD)$(shell test -z "$(git status --porcelain)" || echo .m) LDFLAGS := "-X $(PACKAGE)/pkg/version.Version=$(VERSION) -X $(PACKAGE)/pkg/version.GitCommit=$(GITCOMMIT)" +GOOS ?= $(shell $(GO) env GOOS) +ifeq ($(GOOS),windows) +BINARYNAME := $(addsuffix .exe, $(BINARYNAME)) +sha = sha256sum +else +sha = shasum -a 256 +endif + .DEFAULT_GOAL := all INSTALLED ?= false @@ -42,27 +50,38 @@ else ifneq (,$(findstring x86_64,$(ARCH))) # From https://dl.fedoraproject.org/pub/fedora/linux/releases/38/Cloud/x86_64/images/ FINCH_OS_BASENAME ?= Fedora-Cloud-Base-38-1.6.x86_64-20231207190935.qcow2 LIMA_URL ?= https://deps.runfinch.com/x86-64/lima-and-qemu.macos-x86_64.1701821611.tar.gz + FINCH_ROOTFS_URL ?= https://deps.runfinch.com/common/x86-64/finch-rootfs-production-amd64-1704738038.tar.gz + FINCH_ROOTFS_BASENAME := $(notdir $(FINCH_ROOTFS_URL)) endif -FINCH_OS_HASH := `shasum -a 256 $(OUTDIR)/os/$(FINCH_OS_BASENAME) | cut -d ' ' -f 1` +FINCH_OS_HASH := `$(sha) $(OUTDIR)/os/$(FINCH_OS_BASENAME) | cut -d ' ' -f 1` FINCH_OS_DIGEST := "sha256:$(FINCH_OS_HASH)" FINCH_OS_IMAGE_LOCATION_ROOT ?= $(DEST) FINCH_OS_IMAGE_LOCATION ?= $(FINCH_OS_IMAGE_LOCATION_ROOT)/os/$(FINCH_OS_BASENAME) +# TODO: Windows PoC extracting rootfs... +FINCH_ROOTFS_HASH := `$(sha) $(OUTDIR)/os/$(FINCH_ROOTFS_BASENAME) | cut -d ' ' -f 1` +FINCH_ROOTFS_DIGEST := "sha256:$(FINCH_ROOTFS_HASH)" +FINCH_ROOTFS_LOCATION_ROOT ?= $(DEST)/ +FINCH_ROOTFS_LOCATION ?= $(FINCH_ROOTFS_LOCATION_ROOT)os/$(FINCH_ROOTFS_BASENAME) + .PHONY: arch-test arch-test: @if [ $(SUPPORTED_ARCH) != "true" ]; then echo "Unsupported architecture: $(ARCH)"; exit "1"; fi .PHONY: all +ifeq ($(GOOS),windows) +all: arch-test finch finch-core-local finch.windows.yaml networks.yaml config.yaml +else all: arch-test finch finch-core finch.yaml networks.yaml config.yaml lima-and-qemu +endif .PHONY: all-local -all-local: arch-test finch networks.yaml config.yaml lima-and-qemu local-core finch.yaml +all-local: arch-test networks.yaml config.yaml lima-and-qemu local-core finch.yaml .PHONY: finch-core finch-core: cd deps/finch-core && \ - FINCH_OS_x86_URL="$(FINCH_OS_x86_URL)" \ FINCH_OS_AARCH64_URL="$(FINCH_OS_AARCH64_URL)" \ VDE_TEMP_PREFIX=$(CORE_VDE_PREFIX) \ "$(MAKE)" @@ -71,6 +90,18 @@ finch-core: cd deps/finch-core/_output && tar -cf - * | tar -xvf - -C $(OUTDIR) rm -rf $(OUTDIR)/lima-template +.PHONY: finch-core-local +finch-core-local: + cd deps/finch-core && \ + FINCH_OS_x86_URL="$(FINCH_OS_x86_URL)" \ + FINCH_OS_AARCH64_URL="$(FINCH_OS_AARCH64_URL)" \ + VDE_TEMP_PREFIX=$(CORE_VDE_PREFIX) \ + "$(MAKE)" all lima + + mkdir -p _output + cd deps/finch-core/_output && tar -cf - * | tar -xvf - -C $(OUTDIR) + rm -rf $(OUTDIR)/lima-template + .PHONY: local-core local-core: cd deps/finch-core && \ @@ -81,6 +112,8 @@ local-core: mkdir -p _output cd deps/finch-core/_output && tar -cf - * | tar -xvf - -C $(OUTDIR) + cd deps/finch-core/src/lima/_output && tar -cf - * | tar -xvf - -C $(OUTDIR)/lima + cd deps/finch-core/_output && tar -cf - * | tar -xvf - -C $(OUTDIR) cd deps/finch-core/src/lima/_output && tar -cf - * | tar -xvf - -C $(OUTDIR)/lima rm -rf $(OUTDIR)/lima-template @@ -97,16 +130,36 @@ lima-and-qemu: networks.yaml rm -rf $(OUTDIR)/downloads +FINCH_IMAGE_LOCATION ?= +FINCH_IMAGE_DIGEST ?= +ifeq ($(GOOS),windows) + # Because the path in windows /C:/ is not an Absolute path, prefix with file:/ which is handled by lima https://github.com/lima-vm/lima/blob/da1260dc87fb30345c3ee7bfb131c29646e26d10/pkg/downloader/downloader.go#L266 + FINCH_IMAGE_LOCATION := "file:/$(FINCH_ROOTFS_LOCATION)" + FINCH_IMAGE_DIGEST := $(FINCH_ROOTFS_DIGEST) +else + FINCH_IMAGE_LOCATION := $(FINCH_OS_IMAGE_LOCATION) + FINCH_IMAGE_DIGEST := $(FINCH_OS_DIGEST) +endif .PHONY: finch.yaml finch.yaml: finch-core mkdir -p $(OUTDIR)/os cp finch.yaml $(OUTDIR)/os # using -i.bak is very intentional, it allows the following commands to succeed for both GNU / BSD sed # this sed command uses the alternative separator of "|" because the image location uses "/" - sed -i.bak -e "s||$(FINCH_OS_IMAGE_LOCATION)|g" $(OUTDIR)/os/finch.yaml + sed -i.bak -e "s||$(FINCH_IMAGE_LOCATION)|g" $(OUTDIR)/os/finch.yaml + sed -i.bak -e "s//$(LIMA_ARCH)/g" $(OUTDIR)/os/finch.yaml + sed -i.bak -e "s//$(FINCH_IMAGE_DIGEST)/g" $(OUTDIR)/os/finch.yaml + +# TODO: Windows PoC - clean this up / consolidate +.PHONY: finch.yaml +finch.windows.yaml: finch-core-local + mkdir -p $(OUTDIR)/os + cp finch.windows.yaml $(OUTDIR)/os/finch.yaml + # using -i.bak is very intentional, it allows the following commands to succeed for both GNU / BSD sed + # this sed command uses the alternative separator of "|" because the image location uses "/" + sed -i.bak -e "s||$(FINCH_IMAGE_LOCATION)|g" $(OUTDIR)/os/finch.yaml sed -i.bak -e "s//$(LIMA_ARCH)/g" $(OUTDIR)/os/finch.yaml - sed -i.bak -e "s//$(FINCH_OS_DIGEST)/g" $(OUTDIR)/os/finch.yaml - rm $(OUTDIR)/os/*.yaml.bak + sed -i.bak -e "s//$(FINCH_IMAGE_DIGEST)/g" $(OUTDIR)/os/finch.yaml .PHONY: networks.yaml networks.yaml: @@ -144,7 +197,19 @@ uninstall.vde: uninstall: uninstall.finch .PHONY: finch -finch: +ifeq ($(GOOS),windows) +finch: finch-windows finch-general +else +finch: finch-unix +endif + +finch-windows: + GOBIN=$(GOBIN) go install github.com/tc-hib/go-winres + $(GO) generate cmd/finch/main_windows.go + +finch-unix: finch-general + +finch-general: $(GO) build -ldflags $(LDFLAGS) -o $(OUTDIR)/bin/$(BINARYNAME) $(PACKAGE)/cmd/finch .PHONY: release @@ -256,7 +321,7 @@ check-licenses: .PHONY: test-unit test-unit: - go test $(shell go list ./... | grep -v e2e) -shuffle on -race + go test $(shell go list ./... | grep -v e2e) -shuffle on # test-e2e assumes the VM instance doesn't exist, please make sure to remove it before running. # @@ -267,15 +332,15 @@ test-e2e: test-e2e-vm-serial .PHONY: test-e2e-vm-serial test-e2e-vm-serial: test-e2e-container - go test -ldflags $(LDFLAGS) -timeout 45m ./e2e/vm -test.v -ginkgo.v --installed="$(INSTALLED)" + go test -ldflags $(LDFLAGS) -timeout 2h ./e2e/vm -test.v -ginkgo.v -ginkgo.timeout=2h --installed="$(INSTALLED)" .PHONY: test-e2e-container test-e2e-container: - go test -ldflags $(LDFLAGS) -timeout 30m ./e2e/container -test.v -ginkgo.v --installed="$(INSTALLED)" + go test -ldflags $(LDFLAGS) -timeout 2h ./e2e/container -test.v -ginkgo.v -ginkgo.timeout=2h --installed="$(INSTALLED)" .PHONY: test-e2e-vm test-e2e-vm: - go test -ldflags $(LDFLAGS) -timeout 45m ./e2e/vm -test.v -ginkgo.v --installed="$(INSTALLED)" --registry="$(REGISTRY)" + go test -ldflags $(LDFLAGS) -timeout 2h ./e2e/vm -test.v -ginkgo.v -ginkgo.timeout=2h --installed="$(INSTALLED)" --registry="$(REGISTRY)" .PHONY: test-benchmark test-benchmark: @@ -298,13 +363,18 @@ gen-code: GOBIN = $(CURDIR)/tools_bin gen-code: GOBIN=$(GOBIN) go install github.com/golang/mock/mockgen GOBIN=$(GOBIN) go install golang.org/x/tools/cmd/stringer - # Make sure that we are using the tool binaries which are just built to generate code. + # Make sure that we are using the tool binaries which are just built to generate code. +ifeq ($(GOOS),windows) + powershell ./scripts/gen-code-windows.ps1 +else PATH=$(GOBIN):$(PATH) go generate ./... +endif .PHONY: lint # To run golangci-lint locally: https://golangci-lint.run/usage/install/#local-installation lint: - golangci-lint run + env GOOS=windows golangci-lint run + env GOOS=darwin golangci-lint run .PHONY: mdlint # Install it locally: https://github.com/igorshubovych/markdownlint-cli#installation @@ -315,9 +385,17 @@ mdlint: .PHONY: mdlint-ctr # If markdownlint is not installed, you can run markdownlint within a container. mdlint-ctr: - finch run --rm -v "$(shell pwd):/repo:ro" -w /repo avtodev/markdown-lint:v1 --ignore CHANGELOG.md '**/*.md' + $(BINARYNAME) run --rm -v "$(shell pwd):/repo:ro" -w /repo avtodev/markdown-lint:v1 --ignore CHANGELOG.md '**/*.md' .PHONY: clean +ifeq ($(GOOS),windows) +clean: + -@rm -rf $(OUTDIR) 2>/dev/null || true + -@rm -rf ./deps/finch-core/_output || true + -@rm ./*.tar.gz 2>/dev/null || true + -@rm ./*.qcow2 2>/dev/null || true + -@rm ./test-coverage.* 2>/dev/null || true +else clean: -sudo pkill '^socket_vmnet' -sudo pkill '^qemu-system-' @@ -331,3 +409,4 @@ clean: -@rm ./*.tar.gz 2>/dev/null || true -@rm ./*.qcow2 2>/dev/null || true -@rm ./test-coverage.* 2>/dev/null || true +endif diff --git a/README.md b/README.md index d87f43dc2..fe806a192 100644 --- a/README.md +++ b/README.md @@ -21,12 +21,14 @@ Finch provides a simple client which is integrated with [nerdctl](https://github With Finch, you can leverage these existing projects without chasing down all the details. Just install and start running and building your containers! -## Getting Started with Finch on macOS +## Getting Started with Finch The project will in the near future have a more full set of documentation and tutorials. For now let's get started here. As mentioned above, `finch` integrates with `nerdctl`. While Finch doesn't implement 100% of the upstream commands, the most common commands are in place and working. The [nerdctl Command Reference](https://github.com/containerd/nerdctl#command-reference) can be relied upon as a starting point for documentation. ### Installing Finch +#### macOS + To get started with Finch on macOS, the prerequisites are: * macOS catalina (10.15) or higher, newer versions are tested on a best-effort basis @@ -35,12 +37,22 @@ To get started with Finch on macOS, the prerequisites are: Download a release package for your architecture from the [project's GitHub releases](https://github.com/runfinch/finch/releases) page, and once downloaded double click and follow the directions. -#### Installing Finch via [brew](https://brew.sh/) +##### Installing Finch via [brew](https://brew.sh/) ```sh brew install --cask finch ``` +#### Windows + +To get started with Finch on Windows, the prerequisites are: + +* Windows 10 version 2004 and higher (Build 19041 and higher) +* AMD64 based Windows system +* WSL 2 installed (`wsl --install`) + +Download an MSI installer from the [project's GitHub releases](https://github.com/runfinch/finch/releases) page, and once downloaded double click and follow the directions. + Once the installation is complete, `finch vm init` is required once to set up the underlying system. This initial setup usually takes about a minute. ```sh @@ -95,7 +107,11 @@ The installer will install Finch and its dependencies in its own area of your sy ### Configuration -Finch has a simple and extensible configuration. A configuration file at `${HOME}/.finch/finch.yaml` will be generated on first run. Currently, this config file has options for system resource limits for the underlying virtual machine. These default limits are generated dynamically based on the resources available on the host system, but can be changed by manually editing the config file. +Finch has a simple and extensible configuration. + +#### macOS + +A configuration file at `${HOME}/.finch/finch.yaml` will be generated on first run. Currently, this config file has options for system resource limits for the underlying virtual machine. These default limits are generated dynamically based on the resources available on the host system, but can be changed by manually editing the config file. For a full list of configuration options, check [the struct here](https://github.com/runfinch/finch/blob/main/pkg/config/config.go#L34). @@ -147,16 +163,60 @@ vmType: "qemu" rosetta: false ``` +#### Windows + +A configuration file at `$env:LOCALAPPDATA\.finch\finch.yaml` will be generated on first run. Currently, this config file does not have options for system resource [limits due to limitations in WSL](https://github.com/microsoft/WSL/issues/8570). + +For a full list of configuration options, check [the struct here](pkg/config/config.go#L30). + +An example `finch.yaml` looks like this: + +```yaml +# snapshotters: the snapshotters a user wants to use (the first snapshotter will be set as the default snapshotter) +# Supported Snapshotters List: +# - soci https://github.com/awslabs/soci-snapshotter/tree/main +# Once the option has been set the snapshotters will be installed on either finch vm init or finch vm start. +# The snapshotters binary will be downloaded on the virtual machine and will be configured and ready for use. +# To change your default snpahotter back to overlayfs, simply remove the snapshotters value from finch.yaml or set snapshotters to `overlayfs` +# To completely remove the snapshotters' binaries, shell into your VM and remove /usr/local/bin/{snapshotter binary} +# and remove the snapshotter configuration in the containerd config file found at /etc/containerd/config.toml +snapshotters: + - soci +# creds_helpers: a list of credential helpers that will be installed and configured automatically. +# Supported Credential Helpers List: +# - ecr-login https://github.com/awslabs/amazon-ecr-credential-helper +# Once the option has been set the credential helper will be installed on either finch vm init or finch vm start. +# The binary will be downloaded on the host machine and a config.json will be created and populated inside the ~/.finch/ folder +# if it doesn't already exist. If it already exists, the value of credsStore will be overwritten. +# To opt out of using the credential helper, remove the value from the credsStore parameter of config.json +# and remove the creds_helper value from finch.yaml. +# To completely remove the credential helper, either remove the binary from $env:LOCALAPPDATA\.finch\creds-helpers or remove the creds-helpers +# folder entirely. (optional) +creds_helpers: + - ecr-login + +# sets wsl2 Hypervisor to use to launch the VM. (optional) +vmType: "wsl2" +``` + ### FAQ This section contains frequently-asked questions regarding working with Finch. #### How to shell into the VM? +##### macOS + ```sh LIMA_HOME=/Applications/Finch/lima/data /Applications/Finch/lima/bin/limactl shell finch ``` +##### Windows + +```sh +wsl -d lima-finch +``` + ## What's next? We are excited to start this project in the open, and we'd love to hear from you. If you have ideas or find bugs please open an issue. Please feel free to start a discussion if you have something you'd like to propose or brainstorm. Pull requests are welcome, as well! See the [CONTRIBUTING](CONTRIBUTING.md) doc for more info on contributing, and the path to reviewer and maintainer roles for those interested. @@ -164,7 +224,6 @@ We are excited to start this project in the open, and we'd love to hear from you As the project gets a bit of momentum, maintainers will start creating milestones and look to establish a regular release cadence. In time, we'll also start to curate a public roadmap from the community ideas and issues that roll in. We already have some ideas, including: * More minimal guest OS footprint -* Windows client support * Linux client support * Formal extensibility * Continued performance improvement, ongoing diff --git a/benchmark/suite.go b/benchmark/suite.go index 1a49d8176..ec9b0c7da 100644 --- a/benchmark/suite.go +++ b/benchmark/suite.go @@ -99,7 +99,7 @@ func (suite *Suite) BenchmarkImageBuild(b *testing.B) { dockerFilePath := filepath.Join(tempDir, "Dockerfile") err = os.WriteFile(dockerFilePath, []byte(fmt.Sprintf(`FROM %s CMD ["echo", "finch-test-dummy-output"] - `, alpineImage)), 0o644) + `, alpineImage)), 0o600) assert.NoError(b, err) buildContext := filepath.Dir(dockerFilePath) defer os.RemoveAll(buildContext) //nolint:errcheck // testing only diff --git a/cmd/finch/lima_args_darwin.go b/cmd/finch/lima_args_darwin.go new file mode 100644 index 000000000..f041fc20d --- /dev/null +++ b/cmd/finch/lima_args_darwin.go @@ -0,0 +1,10 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +//go:build darwin + +package main + +func (nc *nerdctlCommand) GetLimaArgs() []string { + return []string{"shell", limaInstanceName, "sudo", "-E"} +} diff --git a/cmd/finch/lima_args_windows.go b/cmd/finch/lima_args_windows.go new file mode 100644 index 000000000..34e7a13ab --- /dev/null +++ b/cmd/finch/lima_args_windows.go @@ -0,0 +1,20 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +//go:build windows + +package main + +func (nc *nerdctlCommand) GetLimaArgs() []string { + wd, err := nc.systemDeps.GetWd() + if err != nil { + nc.logger.Warnln("failed to get working directory, will default to user home with error %s", err.Error()) + return []string{"shell", limaInstanceName, "sudo", "-E"} + } + wslPath, err := convertToWSLPath(nc.systemDeps, wd) + if err != nil { + nc.logger.Warnln("failed to convert to WSL path, will default to user home with error %s", err.Error()) + return []string{"shell", limaInstanceName, "sudo", "-E"} + } + return []string{"shell", "--workdir", wslPath, limaInstanceName, "sudo", "-E"} +} diff --git a/cmd/finch/main.go b/cmd/finch/main.go index 631e16614..90b520a96 100644 --- a/cmd/finch/main.go +++ b/cmd/finch/main.go @@ -14,13 +14,8 @@ import ( "github.com/runfinch/finch/pkg/command" "github.com/runfinch/finch/pkg/config" - "github.com/runfinch/finch/pkg/dependency" - "github.com/runfinch/finch/pkg/dependency/credhelper" - "github.com/runfinch/finch/pkg/dependency/vmnet" - "github.com/runfinch/finch/pkg/disk" "github.com/runfinch/finch/pkg/flog" "github.com/runfinch/finch/pkg/fmemory" - "github.com/runfinch/finch/pkg/fssh" "github.com/runfinch/finch/pkg/lima/wrapper" "github.com/runfinch/finch/pkg/path" "github.com/runfinch/finch/pkg/support" @@ -53,15 +48,31 @@ func xmain(logger flog.Logger, return fmt.Errorf("failed to find the installation path of Finch: %w", err) } - fc, err := config.Load(fs, fp.ConfigFilePath(ffd.Env("HOME")), logger, loadCfgDeps, mem) + home, err := ffd.GetUserHome() + if err != nil { + return fmt.Errorf("failed to get user home directory: %w", err) + } + finchRootPath, err := fp.FinchRootDir(ffd) + if err != nil { + return fmt.Errorf("failed to get finch root path: %w", err) + } + fc, err := config.Load(fs, fp.ConfigFilePath(finchRootPath), logger, loadCfgDeps, mem) if err != nil { return fmt.Errorf("failed to load config: %w", err) } - return newApp(logger, fp, fs, fc, stdOut).Execute() + return newApp(logger, fp, fs, fc, stdOut, home, finchRootPath).Execute() } -var newApp = func(logger flog.Logger, fp path.Finch, fs afero.Fs, fc *config.Finch, stdOut io.Writer) *cobra.Command { +var newApp = func( + logger flog.Logger, + fp path.Finch, + fs afero.Fs, + fc *config.Finch, + stdOut io.Writer, + home, + finchRootPath string, +) *cobra.Command { usage := fmt.Sprintf("%v ", finchRootCmd) rootCmd := &cobra.Command{ Use: usage, @@ -94,7 +105,7 @@ var newApp = func(logger flog.Logger, fp path.Finch, fs afero.Fs, fc *config.Fin supportBundleBuilder := support.NewBundleBuilder( logger, fs, - support.NewBundleConfig(fp, system.NewStdLib().Env("HOME")), + support.NewBundleConfig(fp, finchRootPath), fp, ecc, lcc, @@ -106,7 +117,7 @@ var newApp = func(logger flog.Logger, fp path.Finch, fs afero.Fs, fc *config.Fin // append finch specific commands allCommands = append(allCommands, newVersionCommand(lcc, logger, stdOut), - virtualMachineCommands(logger, fp, lcc, ecc, fs, fc, lima), + virtualMachineCommands(logger, fp, lcc, ecc, fs, fc, home, finchRootPath), newSupportBundleCommand(logger, supportBundleBuilder, lcc), newGenDocsCommand(rootCmd, logger, fs, system.NewStdLib()), ) @@ -116,32 +127,6 @@ var newApp = func(logger flog.Logger, fp path.Finch, fs afero.Fs, fc *config.Fin return rootCmd } -func virtualMachineCommands( - logger flog.Logger, - fp path.Finch, - lcc command.LimaCmdCreator, - ecc *command.ExecCmdCreator, - fs afero.Fs, - fc *config.Finch, - lima wrapper.LimaWrapper, -) *cobra.Command { - optionalDepGroups := []*dependency.Group{ - vmnet.NewDependencyGroup(ecc, lcc, fs, fp, logger), - credhelper.NewDependencyGroup(ecc, fs, fp, logger, fc, system.NewStdLib().Env("USER"), - system.NewStdLib().Arch()), - } - return newVirtualMachineCommand( - lcc, - logger, - optionalDepGroups, - config.NewLimaApplier(fc, ecc, fs, fp.LimaOverrideConfigPath(), system.NewStdLib()), - config.NewNerdctlApplier(fssh.NewDialer(), fs, fp.LimaSSHPrivateKeyPath(), system.NewStdLib().Env("USER"), lima), - fp, - fs, - disk.NewUserDataDiskManager(lcc, ecc, &afero.OsFs{}, fp, system.NewStdLib().Env("HOME"), fc), - ) -} - func initializeNerdctlCommands( lcc command.LimaCmdCreator, ecc *command.ExecCmdCreator, diff --git a/cmd/finch/main_darwin.go b/cmd/finch/main_darwin.go new file mode 100644 index 000000000..3decb1fe3 --- /dev/null +++ b/cmd/finch/main_darwin.go @@ -0,0 +1,42 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +//go:build darwin + +package main + +import ( + "github.com/spf13/afero" + + "github.com/runfinch/finch/pkg/command" + "github.com/runfinch/finch/pkg/config" + "github.com/runfinch/finch/pkg/dependency" + "github.com/runfinch/finch/pkg/dependency/credhelper" + "github.com/runfinch/finch/pkg/dependency/vmnet" + "github.com/runfinch/finch/pkg/flog" + "github.com/runfinch/finch/pkg/path" + "github.com/runfinch/finch/pkg/system" +) + +func dependencies( + ecc *command.ExecCmdCreator, + fc *config.Finch, + fp path.Finch, + fs afero.Fs, + lcc command.LimaCmdCreator, + logger flog.Logger, + finchRootPath string, +) []*dependency.Group { + return []*dependency.Group{ + credhelper.NewDependencyGroup( + ecc, + fs, + fp, + logger, + fc, + finchRootPath, + system.NewStdLib().Arch(), + ), + vmnet.NewDependencyGroup(ecc, lcc, fs, fp, logger), + } +} diff --git a/cmd/finch/main_test.go b/cmd/finch/main_test.go index 0b03c5e9e..c2cbff461 100644 --- a/cmd/finch/main_test.go +++ b/cmd/finch/main_test.go @@ -7,6 +7,7 @@ import ( "errors" "fmt" "os" + "runtime" "testing" "gopkg.in/yaml.v3" @@ -41,6 +42,28 @@ func TestXmain(t *testing.T) { name string mockSvc func(*mocks.Logger, *mocks.FinchFinderDeps, afero.Fs, *mocks.LoadSystemDeps, *mocks.Memory) wantErr error + }{ + { + name: "failed to find the finch path from path.FindFinch", + wantErr: fmt.Errorf("failed to find the installation path of Finch: %w", + fmt.Errorf("failed to locate the executable that starts this process: %w", errors.New("failed to find executable path")), + ), + mockSvc: func( + _ *mocks.Logger, + ffd *mocks.FinchFinderDeps, + _ afero.Fs, + _ *mocks.LoadSystemDeps, + _ *mocks.Memory, + ) { + ffd.EXPECT().Executable().Return("", errors.New("failed to find executable path")) + }, + }, + } + + darwinTestCases := []struct { + name string + mockSvc func(*mocks.Logger, *mocks.FinchFinderDeps, afero.Fs, *mocks.LoadSystemDeps, *mocks.Memory) + wantErr error }{ { name: "happy path", @@ -54,28 +77,63 @@ func TestXmain(t *testing.T) { ) { require.NoError(t, afero.WriteFile(fs, "/home/.finch/finch.yaml", []byte(configStr), 0o600)) - ffd.EXPECT().Env("HOME").Return("/home") + // called additionally in FinchRootDir + ffd.EXPECT().GetUserHome().Return("/home", nil).Times(2) ffd.EXPECT().Executable().Return("/bin/path", nil) ffd.EXPECT().EvalSymlinks("/bin/path").Return("/real/bin/path", nil) - ffd.EXPECT().FilePathJoin("/real/bin/path", "../../").Return("/real") + ffd.EXPECT().FilePathJoin("/real/bin/path", "..", "..").Return("/real") loadCfgDeps.EXPECT().NumCPU().Return(16) // 12_884_901_888 == 12GiB mem.EXPECT().TotalMemory().Return(uint64(12_884_901_888)) }, }, { - name: "failed to find the finch path from path.FindFinch", - wantErr: fmt.Errorf("failed to find the installation path of Finch: %w", - fmt.Errorf("failed to locate the executable that starts this process: %w", errors.New("failed to find executable path")), + name: "failed to load finch config because of invalid YAML", + wantErr: fmt.Errorf("failed to load config: %w", + fmt.Errorf("failed to unmarshal config file: %w", + &yaml.TypeError{Errors: []string{"line 1: cannot unmarshal !!str `this is...` into config.Finch"}}, + ), ), mockSvc: func( _ *mocks.Logger, ffd *mocks.FinchFinderDeps, - _ afero.Fs, + fs afero.Fs, _ *mocks.LoadSystemDeps, _ *mocks.Memory, ) { - ffd.EXPECT().Executable().Return("", errors.New("failed to find executable path")) + require.NoError(t, afero.WriteFile(fs, "/home/.finch/finch.yaml", []byte("this isn't YAML"), 0o600)) + + // called additionally in FinchRootDir + ffd.EXPECT().GetUserHome().Return("/home", nil).Times(2) + ffd.EXPECT().Executable().Return("/bin/path", nil) + ffd.EXPECT().EvalSymlinks("/bin/path").Return("/real/bin/path", nil) + ffd.EXPECT().FilePathJoin("/real/bin/path", "..", "..").Return("/real") + }, + }, + } + + windowsTestCases := []struct { + name string + mockSvc func(*mocks.Logger, *mocks.FinchFinderDeps, afero.Fs, *mocks.LoadSystemDeps, *mocks.Memory) + wantErr error + }{ + { + name: "happy path", + wantErr: nil, + mockSvc: func( + logger *mocks.Logger, + ffd *mocks.FinchFinderDeps, + fs afero.Fs, + loadCfgDeps *mocks.LoadSystemDeps, + mem *mocks.Memory, + ) { + require.NoError(t, afero.WriteFile(fs, "/home/.finch/finch.yaml", []byte(configStr), 0o600)) + + ffd.EXPECT().GetUserHome().Return("/home", nil) + ffd.EXPECT().Env("LOCALAPPDATA").Return("/home/") + ffd.EXPECT().Executable().Return("/bin/path", nil) + ffd.EXPECT().EvalSymlinks("/bin/path").Return("/real/bin/path", nil) + ffd.EXPECT().FilePathJoin("/real/bin/path", "..", "..").Return("/real") }, }, { @@ -94,14 +152,20 @@ func TestXmain(t *testing.T) { ) { require.NoError(t, afero.WriteFile(fs, "/home/.finch/finch.yaml", []byte("this isn't YAML"), 0o600)) - ffd.EXPECT().Env("HOME").Return("/home") + ffd.EXPECT().GetUserHome().Return("/home", nil) + ffd.EXPECT().Env("LOCALAPPDATA").Return("/home/") ffd.EXPECT().Executable().Return("/bin/path", nil) ffd.EXPECT().EvalSymlinks("/bin/path").Return("/real/bin/path", nil) - ffd.EXPECT().FilePathJoin("/real/bin/path", "../../").Return("/real") + ffd.EXPECT().FilePathJoin("/real/bin/path", "..", "..").Return("/real") }, }, } + if runtime.GOOS == "windows" { + testCases = append(testCases, windowsTestCases...) + } else { + testCases = append(testCases, darwinTestCases...) + } for _, tc := range testCases { tc := tc t.Run(tc.name, func(t *testing.T) { @@ -132,7 +196,7 @@ func TestNewApp(t *testing.T) { require.NoError(t, afero.WriteFile(fs, "/real/config.yaml", []byte(configStr), 0o600)) - cmd := newApp(l, fp, fs, &config.Finch{}, stdOut) + cmd := newApp(l, fp, fs, &config.Finch{}, stdOut, "", "") assert.Equal(t, cmd.Name(), finchRootCmd) assert.Equal(t, cmd.Version, version.Version) diff --git a/cmd/finch/main_windows.go b/cmd/finch/main_windows.go new file mode 100644 index 000000000..d5351c045 --- /dev/null +++ b/cmd/finch/main_windows.go @@ -0,0 +1,42 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +//go:build windows + +//go:generate go-winres make --file-version=git-tag --product-version=git-tag --arch amd64 --in ../../winres/winres.json + +package main + +import ( + "github.com/spf13/afero" + + "github.com/runfinch/finch/pkg/command" + "github.com/runfinch/finch/pkg/config" + "github.com/runfinch/finch/pkg/dependency" + "github.com/runfinch/finch/pkg/dependency/credhelper" + "github.com/runfinch/finch/pkg/flog" + "github.com/runfinch/finch/pkg/path" + "github.com/runfinch/finch/pkg/system" +) + +func dependencies( + ecc *command.ExecCmdCreator, + fc *config.Finch, + fp path.Finch, + fs afero.Fs, + _ command.LimaCmdCreator, + logger flog.Logger, + finchDir string, +) []*dependency.Group { + return []*dependency.Group{ + credhelper.NewDependencyGroup( + ecc, + fs, + fp, + logger, + fc, + finchDir, + system.NewStdLib().Arch(), + ), + } +} diff --git a/cmd/finch/nerdctl.go b/cmd/finch/nerdctl.go index 468af0bed..8e11073a9 100644 --- a/cmd/finch/nerdctl.go +++ b/cmd/finch/nerdctl.go @@ -10,8 +10,6 @@ import ( "path/filepath" "strings" - dockerops "github.com/docker/docker/opts" - "github.com/lima-vm/lima/pkg/networks" "golang.org/x/exp/slices" "github.com/aws/aws-sdk-go-v2/aws" @@ -34,6 +32,10 @@ const nerdctlCmdName = "nerdctl" //go:generate mockgen -copyright_file=../../copyright_header -destination=../../pkg/mocks/nerdctl_cmd_system_deps.go -package=mocks -mock_names NerdctlCommandSystemDeps=NerdctlCommandSystemDeps -source=nerdctl.go NerdctlCommandSystemDeps type NerdctlCommandSystemDeps interface { system.EnvChecker + system.WorkingDirectory + system.FilePathJoiner + system.AbsFilePath + system.FilePathToSlash } type nerdctlCommandCreator struct { @@ -45,6 +47,11 @@ type nerdctlCommandCreator struct { fc *config.Finch } +type ( + argHandler func(systemDeps NerdctlCommandSystemDeps, args []string, index int) error + commandHandler func(systemDeps NerdctlCommandSystemDeps, args []string) error +) + func newNerdctlCommandCreator( lcc command.LimaCmdCreator, ecc command.Creator, @@ -100,11 +107,53 @@ func (nc *nerdctlCommand) run(cmdName string, args []string) error { return err } var ( - nerdctlArgs, envs, fileEnvs []string - skip bool + nerdctlArgs, envs, fileEnvs []string + skip, hasCmdHander, hasArgHandler bool + cmdHandler commandHandler + aMap map[string]argHandler ) + alias, hasAlias := aliasMap[cmdName] + if hasAlias { + cmdName = alias + cmdHandler, hasCmdHander = commandHandlerMap[alias] + aMap, hasArgHandler = argHandlerMap[alias] + } else { + // Check if the command has a handler + cmdHandler, hasCmdHander = commandHandlerMap[cmdName] + aMap, hasArgHandler = argHandlerMap[cmdName] + + if !hasCmdHander && !hasArgHandler && len(args) > 0 { + // for commands like image build, container run + key := fmt.Sprintf("%s %s", cmdName, args[0]) + cmdHandler, hasCmdHander = commandHandlerMap[key] + aMap, hasArgHandler = argHandlerMap[key] + } + } + + // First check if the command has command handler + if hasCmdHander { + err := cmdHandler(nc.systemDeps, args) + if err != nil { + return err + } + } + for i, arg := range args { + // Check if command requires arg handling + if hasArgHandler { + // Check if argument for the command needs handling, sometimes it can be --file= + b, _, _ := strings.Cut(arg, "=") + h, ok := aMap[b] + if ok { + err = h(nc.systemDeps, args, i) + if err != nil { + return err + } + // This is required when the positional argument at i is mutated by argHandler, eg -v=C:\Users:/tmp:ro + arg = args[i] + } + } // parsing environment values from the command line may pre-fetch and // consume the next argument; this loop variable will skip these pre-consumed // entries from the command line @@ -132,12 +181,21 @@ func (nc *nerdctlCommand) run(cmdName string, args []string) error { case strings.HasPrefix(arg, "--add-host"): switch arg { case "--add-host": - args[i+1] = resolveIP(args[i+1], nc.logger) + args[i+1], err = resolveIP(args[i+1], nc.logger, nc.ecc) + if err != nil { + return err + } default: - resolvedIP := resolveIP(arg[11:], nc.logger) + resolvedIP, err := resolveIP(arg[11:], nc.logger, nc.ecc) + if err != nil { + return err + } arg = fmt.Sprintf("%s%s", arg[0:11], resolvedIP) } nerdctlArgs = append(nerdctlArgs, arg) + if err != nil { + return err + } default: nerdctlArgs = append(nerdctlArgs, arg) } @@ -187,9 +245,9 @@ func (nc *nerdctlCommand) run(cmdName string, args []string) error { // Add -E to sudo command in order to preserve existing environment variables, more info: // https://stackoverflow.com/questions/8633461/how-to-keep-environment-variables-when-using-sudo/8633575#8633575 - limaArgs := append([]string{"shell", limaInstanceName, "sudo", "-E"}, append(additionalEnv, passedEnvArgs...)...) + limaArgs := append(nc.GetLimaArgs(), append(additionalEnv, passedEnvArgs...)...) - limaArgs = append(limaArgs, []string{nerdctlCmdName, cmdName}...) + limaArgs = append(limaArgs, append([]string{nerdctlCmdName}, strings.Fields(cmdName)...)...) var finalArgs []string for key, val := range envVars { @@ -336,19 +394,6 @@ func handleEnvFile(fs afero.Fs, systemDeps NerdctlCommandSystemDeps, arg, arg2 s return skip, envs, nil } -func resolveIP(host string, logger flog.Logger) string { - parts := strings.SplitN(host, ":", 2) - // If the IP Address is a string called "host-gateway", replace this value with the IP address that can be used to - // access host from the containers. - // TODO: make the host gateway ip configurable. - if parts[1] == dockerops.HostGatewayName { - resolvedIP := networks.SlirpGateway - logger.Debugf(`Resolving special IP "host-gateway" to %q for host %q`, resolvedIP, parts[0]) - return fmt.Sprintf("%s:%s", parts[0], resolvedIP) - } - return host -} - // ensureRemoteCredentials is called before any actions that may require remote resources, in order // to ensure that fresh credentials are available inside the VM. // For more details on how `aws configure export-credentials` works, checks the docs. diff --git a/cmd/finch/nerdctl_darwin.go b/cmd/finch/nerdctl_darwin.go new file mode 100644 index 000000000..e7f9979c9 --- /dev/null +++ b/cmd/finch/nerdctl_darwin.go @@ -0,0 +1,38 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +//go:build darwin + +package main + +import ( + "fmt" + "strings" + + dockerops "github.com/docker/docker/opts" + "github.com/lima-vm/lima/pkg/networks" + + "github.com/runfinch/finch/pkg/command" + "github.com/runfinch/finch/pkg/flog" +) + +var aliasMap = map[string]string{} + +var argHandlerMap = map[string]map[string]argHandler{} + +var commandHandlerMap = map[string]commandHandler{} + +func resolveIP(host string, logger flog.Logger, _ command.Creator) (string, error) { + parts := strings.SplitN(host, ":", 2) + // If the IP Address is a string called "host-gateway", replace this value with the IP address that can be used to + // access host from the containers. + // TODO: make the host gateway ip configurable. + var resolvedIP string + if parts[1] == dockerops.HostGatewayName { + resolvedIP = networks.SlirpGateway + + logger.Debugf(`Resolving special IP "host-gateway" to %q for host %q`, resolvedIP, parts[0]) + return fmt.Sprintf("%s:%s", parts[0], resolvedIP), nil + } + return host, nil +} diff --git a/cmd/finch/nerdctl_test.go b/cmd/finch/nerdctl_darwin_test.go similarity index 87% rename from cmd/finch/nerdctl_test.go rename to cmd/finch/nerdctl_darwin_test.go index 1a9301506..fb57f8bdc 100644 --- a/cmd/finch/nerdctl_test.go +++ b/cmd/finch/nerdctl_darwin_test.go @@ -1,12 +1,15 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 +//go:build darwin + package main import ( "errors" "fmt" "os" + "path/filepath" "testing" "github.com/golang/mock/gomock" @@ -18,22 +21,9 @@ import ( "github.com/runfinch/finch/pkg/config" "github.com/runfinch/finch/pkg/flog" - "github.com/runfinch/finch/pkg/command" "github.com/runfinch/finch/pkg/mocks" ) -var testStdoutRs = []command.Replacement{ - {Source: "nerdctl", Target: "finch"}, -} - -func TestNerdctlCommandCreator_create(t *testing.T) { - t.Parallel() - - cmd := newNerdctlCommandCreator(nil, nil, nil, nil, nil, nil).create("build", "build description") - assert.Equal(t, cmd.Name(), "build") - assert.Equal(t, cmd.DisableFlagParsing, true) -} - func TestNerdctlCommand_runAdaptor(t *testing.T) { t.Parallel() @@ -84,7 +74,7 @@ func TestNerdctlCommand_runAdaptor(t *testing.T) { func TestNerdctlCommand_run(t *testing.T) { t.Parallel() - + envFilePath := filepath.Join(string(filepath.Separator), "env-file") testCases := []struct { name string cmdName string @@ -129,92 +119,6 @@ func TestNerdctlCommand_run(t *testing.T) { c.EXPECT().Run() }, }, - { - name: "stopped VM", - cmdName: "build", - fc: &config.Finch{}, - args: []string{"-t", "demo", "."}, - wantErr: fmt.Errorf("instance %q is stopped, run `finch %s start` to start the instance", - limaInstanceName, virtualMachineRootCmd), - mockSvc: func( - t *testing.T, - lcc *mocks.LimaCmdCreator, - ecc *mocks.CommandCreator, - ncsd *mocks.NerdctlCommandSystemDeps, - logger *mocks.Logger, - ctrl *gomock.Controller, - fs afero.Fs, - ) { - getVMStatusC := mocks.NewCommand(ctrl) - lcc.EXPECT().CreateWithoutStdio("ls", "-f", "{{.Status}}", limaInstanceName).Return(getVMStatusC) - getVMStatusC.EXPECT().Output().Return([]byte("Stopped"), nil) - logger.EXPECT().Debugf("Status of virtual machine: %s", "Stopped") - }, - }, - { - name: "nonexistent VM", - cmdName: "build", - fc: &config.Finch{}, - args: []string{"-t", "demo", "."}, - wantErr: fmt.Errorf( - "instance %q does not exist, run `finch %s init` to create a new instance", - limaInstanceName, virtualMachineRootCmd), - mockSvc: func( - t *testing.T, - lcc *mocks.LimaCmdCreator, - ecc *mocks.CommandCreator, - ncsd *mocks.NerdctlCommandSystemDeps, - logger *mocks.Logger, - ctrl *gomock.Controller, - fs afero.Fs, - ) { - getVMStatusC := mocks.NewCommand(ctrl) - lcc.EXPECT().CreateWithoutStdio("ls", "-f", "{{.Status}}", limaInstanceName).Return(getVMStatusC) - getVMStatusC.EXPECT().Output().Return([]byte(""), nil) - logger.EXPECT().Debugf("Status of virtual machine: %s", "") - }, - }, - { - name: "unknown VM status", - cmdName: "build", - fc: &config.Finch{}, - args: []string{"-t", "demo", "."}, - wantErr: errors.New("unrecognized system status"), - mockSvc: func( - t *testing.T, - lcc *mocks.LimaCmdCreator, - ecc *mocks.CommandCreator, - ncsd *mocks.NerdctlCommandSystemDeps, - logger *mocks.Logger, - ctrl *gomock.Controller, - fs afero.Fs, - ) { - getVMStatusC := mocks.NewCommand(ctrl) - lcc.EXPECT().CreateWithoutStdio("ls", "-f", "{{.Status}}", limaInstanceName).Return(getVMStatusC) - getVMStatusC.EXPECT().Output().Return([]byte("Broken"), nil) - logger.EXPECT().Debugf("Status of virtual machine: %s", "Broken") - }, - }, - { - name: "status command returns an error", - cmdName: "build", - fc: &config.Finch{}, - args: []string{"-t", "demo", "."}, - wantErr: errors.New("get status error"), - mockSvc: func( - t *testing.T, - lcc *mocks.LimaCmdCreator, - ecc *mocks.CommandCreator, - ncsd *mocks.NerdctlCommandSystemDeps, - logger *mocks.Logger, - ctrl *gomock.Controller, - fs afero.Fs, - ) { - getVMStatusC := mocks.NewCommand(ctrl) - lcc.EXPECT().CreateWithoutStdio("ls", "-f", "{{.Status}}", limaInstanceName).Return(getVMStatusC) - getVMStatusC.EXPECT().Output().Return([]byte("Broken"), errors.New("get status error")) - }, - }, { name: "with --debug flag", cmdName: "pull", @@ -323,7 +227,7 @@ func TestNerdctlCommand_run(t *testing.T) { fs afero.Fs, ) { envFileStr := "# a comment\nARG1=val1\n ARG2\n\n # a 2nd comment\nNOTSETARG\n " - require.NoError(t, afero.WriteFile(fs, "/env-file", []byte(envFileStr), 0o600)) + require.NoError(t, afero.WriteFile(fs, envFilePath, []byte(envFileStr), 0o600)) getVMStatusC := mocks.NewCommand(ctrl) lcc.EXPECT().CreateWithoutStdio("ls", "-f", "{{.Status}}", limaInstanceName).Return(getVMStatusC) @@ -346,7 +250,7 @@ func TestNerdctlCommand_run(t *testing.T) { name: "with --env-file flag replacement and existing env value", cmdName: "run", fc: &config.Finch{}, - args: []string{"--rm", "--env-file", "/env-file", "alpine:latest", "env"}, + args: []string{"--rm", "--env-file", envFilePath, "alpine:latest", "env"}, wantErr: nil, mockSvc: func( t *testing.T, @@ -358,7 +262,7 @@ func TestNerdctlCommand_run(t *testing.T) { fs afero.Fs, ) { envFileStr := "# a comment\n ARG2\n\n # a 2nd comment\nNOTSETARG\n " - require.NoError(t, afero.WriteFile(fs, "/env-file", []byte(envFileStr), 0o600)) + require.NoError(t, afero.WriteFile(fs, envFilePath, []byte(envFileStr), 0o600)) getVMStatusC := mocks.NewCommand(ctrl) lcc.EXPECT().CreateWithoutStdio("ls", "-f", "{{.Status}}", limaInstanceName).Return(getVMStatusC) @@ -380,9 +284,8 @@ func TestNerdctlCommand_run(t *testing.T) { { name: "with --env-file flag, but the specified file does not exist", cmdName: "run", - fc: &config.Finch{}, - args: []string{"--rm", "--env-file", "/env-file", "alpine:latest", "env"}, - wantErr: &os.PathError{Op: "open", Path: "/env-file", Err: afero.ErrFileNotFound}, + args: []string{"--rm", "--env-file", envFilePath, "alpine:latest", "env"}, + wantErr: &os.PathError{Op: "open", Path: envFilePath, Err: afero.ErrFileNotFound}, mockSvc: func( t *testing.T, lcc *mocks.LimaCmdCreator, @@ -993,67 +896,3 @@ func TestNerdctlCommand_run(t *testing.T) { }) } } - -func TestNerdctlCommand_shouldReplaceForHelp(t *testing.T) { - t.Parallel() - - testCases := []struct { - name string - cmdName string - args []string - mockSvc func(*mocks.LimaCmdCreator, *mocks.Logger, *gomock.Controller) - }{ - { - name: "with --help flag", - cmdName: "pull", - args: []string{"test:tag", "--help"}, - }, - { - name: "with -h", - cmdName: "pull", - args: []string{"test:tag", "-h"}, - }, - { - name: "system returns help", - cmdName: "system", - }, - { - name: "builder returns help", - cmdName: "builder", - }, - { - name: "container returns help", - cmdName: "container", - }, - { - name: "image returns help", - cmdName: "image", - }, - { - name: "network returns help", - cmdName: "network", - }, - { - name: "volume returns help", - cmdName: "volume", - }, - { - name: "compose returns help", - cmdName: "compose", - }, - } - - for _, tc := range testCases { - tc := tc - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - - ctrl := gomock.NewController(t) - lcc := mocks.NewLimaCmdCreator(ctrl) - ecc := mocks.NewCommandCreator(ctrl) - ncsd := mocks.NewNerdctlCommandSystemDeps(ctrl) - logger := mocks.NewLogger(ctrl) - assert.True(t, newNerdctlCommand(lcc, ecc, ncsd, logger, nil, &config.Finch{}).shouldReplaceForHelp(tc.cmdName, tc.args)) - }) - } -} diff --git a/cmd/finch/nerdctl_shared_test.go b/cmd/finch/nerdctl_shared_test.go new file mode 100644 index 000000000..309fca593 --- /dev/null +++ b/cmd/finch/nerdctl_shared_test.go @@ -0,0 +1,210 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "errors" + "fmt" + "testing" + + "github.com/golang/mock/gomock" + "github.com/spf13/afero" + "github.com/stretchr/testify/assert" + + "github.com/runfinch/finch/pkg/command" + "github.com/runfinch/finch/pkg/config" + "github.com/runfinch/finch/pkg/mocks" +) + +var testStdoutRs = []command.Replacement{ + {Source: "nerdctl", Target: "finch"}, +} + +func TestNerdctlCommandCreator_create(t *testing.T) { + t.Parallel() + + cmd := newNerdctlCommandCreator(nil, nil, nil, nil, nil, nil).create("build", "build description") + assert.Equal(t, cmd.Name(), "build") + assert.Equal(t, cmd.DisableFlagParsing, true) +} + +func TestNerdctlCommand_shouldReplaceForHelp(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + cmdName string + args []string + mockSvc func(*mocks.LimaCmdCreator, *mocks.Logger, *gomock.Controller) + }{ + { + name: "with --help flag", + cmdName: "pull", + args: []string{"test:tag", "--help"}, + }, + { + name: "with -h", + cmdName: "pull", + args: []string{"test:tag", "-h"}, + }, + { + name: "system returns help", + cmdName: "system", + }, + { + name: "builder returns help", + cmdName: "builder", + }, + { + name: "container returns help", + cmdName: "container", + }, + { + name: "image returns help", + cmdName: "image", + }, + { + name: "network returns help", + cmdName: "network", + }, + { + name: "volume returns help", + cmdName: "volume", + }, + { + name: "compose returns help", + cmdName: "compose", + }, + } + + for _, tc := range testCases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + lcc := mocks.NewLimaCmdCreator(ctrl) + ecc := mocks.NewCommandCreator(ctrl) + ncsd := mocks.NewNerdctlCommandSystemDeps(ctrl) + logger := mocks.NewLogger(ctrl) + assert.True(t, newNerdctlCommand(lcc, ecc, ncsd, logger, nil, &config.Finch{}).shouldReplaceForHelp(tc.cmdName, tc.args)) + }) + } +} + +func TestNerdctlCommand_withVMErrors(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + cmdName string + fc *config.Finch + args []string + wantErr error + mockSvc func(*testing.T, *mocks.LimaCmdCreator, *mocks.CommandCreator, *mocks.NerdctlCommandSystemDeps, *mocks.Logger, + *gomock.Controller, afero.Fs) + }{ + { + name: "stopped VM", + cmdName: "build", + fc: &config.Finch{}, + args: []string{"-t", "demo", "."}, + wantErr: fmt.Errorf("instance %q is stopped, run `finch %s start` to start the instance", + limaInstanceName, virtualMachineRootCmd), + mockSvc: func( + t *testing.T, + lcc *mocks.LimaCmdCreator, + ecc *mocks.CommandCreator, + ncsd *mocks.NerdctlCommandSystemDeps, + logger *mocks.Logger, + ctrl *gomock.Controller, + fs afero.Fs, + ) { + getVMStatusC := mocks.NewCommand(ctrl) + lcc.EXPECT().CreateWithoutStdio("ls", "-f", "{{.Status}}", limaInstanceName).Return(getVMStatusC) + getVMStatusC.EXPECT().Output().Return([]byte("Stopped"), nil) + logger.EXPECT().Debugf("Status of virtual machine: %s", "Stopped") + }, + }, + { + name: "nonexistent VM", + cmdName: "build", + fc: &config.Finch{}, + args: []string{"-t", "demo", "."}, + wantErr: fmt.Errorf( + "instance %q does not exist, run `finch %s init` to create a new instance", + limaInstanceName, virtualMachineRootCmd), + mockSvc: func( + t *testing.T, + lcc *mocks.LimaCmdCreator, + ecc *mocks.CommandCreator, + ncsd *mocks.NerdctlCommandSystemDeps, + logger *mocks.Logger, + ctrl *gomock.Controller, + fs afero.Fs, + ) { + getVMStatusC := mocks.NewCommand(ctrl) + lcc.EXPECT().CreateWithoutStdio("ls", "-f", "{{.Status}}", limaInstanceName).Return(getVMStatusC) + getVMStatusC.EXPECT().Output().Return([]byte(""), nil) + logger.EXPECT().Debugf("Status of virtual machine: %s", "") + }, + }, + { + name: "unknown VM status", + cmdName: "build", + fc: &config.Finch{}, + args: []string{"-t", "demo", "."}, + wantErr: errors.New("unrecognized system status"), + mockSvc: func( + t *testing.T, + lcc *mocks.LimaCmdCreator, + ecc *mocks.CommandCreator, + ncsd *mocks.NerdctlCommandSystemDeps, + logger *mocks.Logger, + ctrl *gomock.Controller, + fs afero.Fs, + ) { + getVMStatusC := mocks.NewCommand(ctrl) + lcc.EXPECT().CreateWithoutStdio("ls", "-f", "{{.Status}}", limaInstanceName).Return(getVMStatusC) + getVMStatusC.EXPECT().Output().Return([]byte("Broken"), nil) + logger.EXPECT().Debugf("Status of virtual machine: %s", "Broken") + }, + }, + { + name: "status command returns an error", + cmdName: "build", + fc: &config.Finch{}, + args: []string{"-t", "demo", "."}, + wantErr: errors.New("get status error"), + mockSvc: func( + t *testing.T, + lcc *mocks.LimaCmdCreator, + ecc *mocks.CommandCreator, + ncsd *mocks.NerdctlCommandSystemDeps, + logger *mocks.Logger, + ctrl *gomock.Controller, + fs afero.Fs, + ) { + getVMStatusC := mocks.NewCommand(ctrl) + lcc.EXPECT().CreateWithoutStdio("ls", "-f", "{{.Status}}", limaInstanceName).Return(getVMStatusC) + getVMStatusC.EXPECT().Output().Return([]byte("Broken"), errors.New("get status error")) + }, + }, + } + for _, tc := range testCases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + ecc := mocks.NewCommandCreator(ctrl) + lcc := mocks.NewLimaCmdCreator(ctrl) + ncsd := mocks.NewNerdctlCommandSystemDeps(ctrl) + logger := mocks.NewLogger(ctrl) + fs := afero.NewMemMapFs() + tc.mockSvc(t, lcc, ecc, ncsd, logger, ctrl, fs) + assert.Equal(t, tc.wantErr, newNerdctlCommand(lcc, ecc, ncsd, logger, fs, tc.fc).run(tc.cmdName, tc.args)) + }) + } +} diff --git a/cmd/finch/nerdctl_windows.go b/cmd/finch/nerdctl_windows.go new file mode 100644 index 000000000..53bfc8ea5 --- /dev/null +++ b/cmd/finch/nerdctl_windows.go @@ -0,0 +1,397 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "fmt" + "path/filepath" + "regexp" + "strings" + + dockerops "github.com/docker/docker/opts" + + "github.com/runfinch/finch/pkg/command" + "github.com/runfinch/finch/pkg/flog" +) + +func convertToWSLPath(systemDeps NerdctlCommandSystemDeps, winPath string) (string, error) { + path, err := systemDeps.FilePathAbs(filepath.Clean(winPath)) + if err != nil { + return "", err + } + if len(path) >= 2 && path[1] == ':' { + drive := strings.ToLower(string(path[0])) + remainingPath := "" + if len(path) > 3 { + remainingPath = path[3:] + } + return systemDeps.FilePathToSlash(systemDeps.FilePathJoin(string(filepath.Separator), "mnt", drive, remainingPath)), nil + } + return path, nil +} + +// substitutes wsl path for the provided option in place for nerdctl args. +func handleFilePath(systemDeps NerdctlCommandSystemDeps, nerdctlCmdArgs []string, index int) error { + prefix := nerdctlCmdArgs[index] + + // If --filename=" then we need to cut and convert that to wsl path + if strings.Contains(nerdctlCmdArgs[index], "=") { + before, after, _ := strings.Cut(prefix, "=") + wslPath, err := convertToWSLPath(systemDeps, after) + if err != nil { + return err + } + nerdctlCmdArgs[index] = fmt.Sprintf("%s=%s", before, wslPath) + } else { + if (index + 1) < len(nerdctlCmdArgs) { + wslPath, err := convertToWSLPath(systemDeps, nerdctlCmdArgs[index+1]) + if err != nil { + return err + } + nerdctlCmdArgs[index+1] = wslPath + } else { + return fmt.Errorf("invalid positional parameter for %s", prefix) + } + } + return nil +} + +// hanldes -v/--volumes option. For anonymous volumes and named volumes this is no-op. For bind mounts path is converted to wsl path. +func handleVolume(systemDeps NerdctlCommandSystemDeps, nerdctlCmdArgs []string, index int) error { + prefix := nerdctlCmdArgs[index] + v := "" + found := false + before := "" + if strings.Contains(nerdctlCmdArgs[index], "=") { + before, v, found = strings.Cut(prefix, "=") + } else { + if (index + 1) < len(nerdctlCmdArgs) { + v = nerdctlCmdArgs[index+1] + } else { + return fmt.Errorf("invalid positional parameter for %s", prefix) + } + } + cleanArg := v + readWrite := "" + if strings.HasSuffix(v, ":ro") || strings.HasSuffix(v, ":rw") { + readWrite = v[len(v)-3:] + cleanArg = v[:len(v)-3] + } else if strings.HasSuffix(v, ":rro") { + readWrite = v[len(v)-4:] + cleanArg = v[:len(v)-4] + } + + colonIndex := strings.LastIndex(cleanArg, ":") + if colonIndex < 0 { + return nil + } + hostPath := cleanArg[:colonIndex] + // This is a named volume, or an anonymous volume from https://github.com/containerd/nerdctl/blob/main/pkg/mountutil/mountutil.go#L76 + if !strings.Contains(hostPath, "\\") || len(hostPath) == 0 { + return nil + } + + hostPath, err := systemDeps.FilePathAbs(hostPath) + // If it's an anonymous volume, then the path won't exist + if err != nil { + return err + } + + containerPath := cleanArg[colonIndex+1:] + wslHostPath, err := convertToWSLPath(systemDeps, hostPath) + if err != nil { + return fmt.Errorf("could not get volume host path for %s: %w", v, err) + } + + if found { + nerdctlCmdArgs[index] = fmt.Sprintf("%s=%s:%s%s", before, wslHostPath, containerPath, readWrite) + } else { + nerdctlCmdArgs[index+1] = fmt.Sprintf("%s:%s%s", wslHostPath, containerPath, readWrite) + } + return nil +} + +// translates source path of the bind mount to wslpath for --mount option. +func handleBindMounts(systemDeps NerdctlCommandSystemDeps, nerdctlCmdArgs []string, index int) error { + prefix := nerdctlCmdArgs[index] + v := "" + found := false + before := "" + if strings.Contains(nerdctlCmdArgs[index], "=") { + before, v, found = strings.Cut(prefix, "=") + } else { + if (index + 1) < len(nerdctlCmdArgs) { + v = nerdctlCmdArgs[index+1] + } else { + return fmt.Errorf("invalid positional parameter for %s", prefix) + } + } + + // https://docs.docker.com/storage/bind-mounts/#choose-the--v-or---mount-flag order does not matter, so convert to a map + entries := strings.Split(v, ",") + m := make(map[string]string) + ro := []string{} + for _, e := range entries { + parts := strings.Split(e, "=") + // eg --mount type=bind,source="$(pwd)"/target,target=/app,readonly + if len(parts) < 2 { + ro = append(ro, parts...) + } else { + m[strings.TrimSpace(parts[0])] = strings.TrimSpace(parts[1]) + } + } + // Check if type is bind mount, else return + if m["type"] != "bind" { + return nil + } + var k string + path, ok := m["src"] + if !ok { + path, ok = m["source"] + k = "source" + } else { + k = "src" + } + // If there is no src or source or not a windows path, do nothing, let nerdctl handle error + if !ok || !strings.Contains(path, `\`) { + return nil + } + wslPath, err := convertToWSLPath(systemDeps, path) + if err != nil { + return err + } + m[k] = wslPath + + // Convert to string representation + s := mapToString(m) + // append read-only key if present + if len(ro) > 0 { + s = s + "," + strings.Join(ro, ",") + } + if found { + nerdctlCmdArgs[index] = fmt.Sprintf("%s=%s", before, s) + } else { + nerdctlCmdArgs[index+1] = s + } + + return nil +} + +// handles --output/-o for build command. +func handleOutputOption(systemDeps NerdctlCommandSystemDeps, nerdctlCmdArgs []string, index int) error { + prefix := nerdctlCmdArgs[index] + v := "" + found := false + before := "" + if strings.Contains(nerdctlCmdArgs[index], "=") { + before, v, found = strings.Cut(prefix, "=") + } else { + if (index + 1) < len(nerdctlCmdArgs) { + v = nerdctlCmdArgs[index+1] + } else { + return fmt.Errorf("invalid positional parameter for %s", prefix) + } + } + + // https://docs.docker.com/engine/reference/commandline/build/ order does not matter, so convert to a map + entries := strings.Split(v, ",") + m := make(map[string]string) + for _, e := range entries { + parts := strings.Split(e, "=") + m[strings.TrimSpace(parts[0])] = strings.TrimSpace(parts[1]) + } + dest, ok := m["dest"] + if !ok { + return nil + } + wslPath, err := convertToWSLPath(systemDeps, dest) + if err != nil { + return err + } + m["dest"] = wslPath + + // Convert to string representation + s := mapToString(m) + + if found { + nerdctlCmdArgs[index] = fmt.Sprintf("%s=%s", before, s) + } else { + nerdctlCmdArgs[index+1] = s + } + + return nil +} + +// handles --secret option for build command. +func handleSecretOption(systemDeps NerdctlCommandSystemDeps, nerdctlCmdArgs []string, index int) error { + prefix := nerdctlCmdArgs[index] + v := "" + found := false + before := "" + if strings.Contains(nerdctlCmdArgs[index], "=") { + before, v, found = strings.Cut(prefix, "=") + } else { + if (index + 1) < len(nerdctlCmdArgs) { + v = nerdctlCmdArgs[index+1] + } else { + return fmt.Errorf("invalid positional parameter for %s", prefix) + } + } + + entries := strings.Split(v, ",") + m := make(map[string]string) + for _, e := range entries { + parts := strings.Split(e, "=") + m[strings.TrimSpace(parts[0])] = strings.TrimSpace(parts[1]) + } + sp, ok := m["src"] + if !ok { + return nil + } + wslPath, err := convertToWSLPath(systemDeps, sp) + if err != nil { + return err + } + m["src"] = wslPath + + // Convert to string representation + s := mapToString(m) + + if found { + nerdctlCmdArgs[index] = fmt.Sprintf("%s=%s", before, s) + } else { + nerdctlCmdArgs[index+1] = s + } + + return nil +} + +// cp command handler, takes command arguments and converts hostpath to wsl path in place. It ignores all other arguments. +func cpHandler(systemDeps NerdctlCommandSystemDeps, nerdctlCmdArgs []string) error { + for i, arg := range nerdctlCmdArgs { + // -L and --follow-symlink don't have to be processed + if strings.HasPrefix(arg, "-") || arg == "cp" { + continue + } + // If argument contains container path, then continue + colon := strings.Index(arg, ":") + + // this is a container path + if colon > 1 { + continue + } + wslPath, err := convertToWSLPath(systemDeps, arg) + if err != nil { + return err + } + nerdctlCmdArgs[i] = wslPath + } + return nil +} + +// this is the handler for image build command. It translates build context to wsl path. +func imageBuildHandler(systemDeps NerdctlCommandSystemDeps, nerdctlCmdArgs []string) error { + var err error + argLen := len(nerdctlCmdArgs) - 1 + // -h/--help don't have buildcontext, just return + for _, a := range nerdctlCmdArgs { + if a == "--help" || a == "-h" { + return nil + } + } + if nerdctlCmdArgs[argLen] != "--debug" { + nerdctlCmdArgs[argLen], err = convertToWSLPath(systemDeps, nerdctlCmdArgs[argLen]) + if err != nil { + return err + } + } else { + nerdctlCmdArgs[argLen-1], err = convertToWSLPath(systemDeps, nerdctlCmdArgs[argLen-1]) + if err != nil { + return err + } + } + return nil +} + +var aliasMap = map[string]string{ + "build": "image build", + "save": "image save", + "load": "image load", + "cp": "container cp", + "run": "container run", +} + +var argHandlerMap = map[string]map[string]argHandler{ + "image build": { + "-f": handleFilePath, + "--file": handleFilePath, + "--iidfile": handleFilePath, + "-o": handleOutputOption, + "--output": handleOutputOption, + "--secret": handleSecretOption, + }, + "image save": { + "-o": handleFilePath, + "--output": handleFilePath, + }, + "image load": { + "-i": handleFilePath, + "--input": handleFilePath, + }, + "container run": { + "--label-file": handleFilePath, + "--cosign-key": handleFilePath, + "--cidfile": handleFilePath, + "-v": handleVolume, + "--volume": handleVolume, + "--mount": handleBindMounts, + }, + "compose": { + "--file": handleFilePath, + }, +} + +var commandHandlerMap = map[string]commandHandler{ + "container cp": cpHandler, + "image build": imageBuildHandler, +} + +func mapToString(m map[string]string) string { + var parts []string + for k, v := range m { + part := fmt.Sprintf("%s=%s", k, v) + parts = append(parts, part) + } + return strings.Join(parts, ",") +} + +func resolveIP(host string, logger flog.Logger, ecc command.Creator) (string, error) { + parts := strings.SplitN(host, ":", 2) + // If the IP Address is a string called "host-gateway", replace this value with the IP address that can be used to + // access host from the containers. + var resolvedIP string + if parts[1] == dockerops.HostGatewayName { + // get ip address for adapter vEthernet (WSL) to reach host from wsl + // https://learn.microsoft.com/en-us/windows/wsl/networking#accessing-windows-networking-apps-from-linux-host-ip + out, err := ecc.Create("cmd", "/C", "netsh", "interface", "ipv4", "show", "addresses", "vEthernet (WSL)").Output() + if err != nil { + return "", err + } + resolvedIP = extractIPAddress(string(out)) + + logger.Debugf(`Resolving special IP "host-gateway" to %q for host %q`, resolvedIP, parts[0]) + return fmt.Sprintf("%s:%s", parts[0], resolvedIP), nil + } + return host, nil +} + +func extractIPAddress(data string) string { + re := regexp.MustCompile(`IP Address:\s+(\d+\.\d+\.\d+\.\d+)`) + match := re.FindStringSubmatch(data) + + if match != nil { + return match[1] + } + return "" +} diff --git a/cmd/finch/nerdctl_windows_test.go b/cmd/finch/nerdctl_windows_test.go new file mode 100644 index 000000000..4cd7913d0 --- /dev/null +++ b/cmd/finch/nerdctl_windows_test.go @@ -0,0 +1,1214 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +//go:build windows + +package main + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/golang/mock/gomock" + "github.com/spf13/afero" + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/runfinch/finch/pkg/config" + "github.com/runfinch/finch/pkg/flog" + "github.com/runfinch/finch/pkg/mocks" +) + +var ( + augmentedPath = filepath.Join(string(filepath.Separator), "mnt", "c", "workdir") + wslPath = filepath.ToSlash(augmentedPath) +) + +func TestNerdctlCommand_runAdaptor(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + cmd *cobra.Command + args []string + mockSvc func(*mocks.CommandCreator, *mocks.LimaCmdCreator, *mocks.Logger, *gomock.Controller, *mocks.NerdctlCommandSystemDeps) + }{ + { + name: "happy path", + cmd: &cobra.Command{ + Use: "info", + }, + args: []string{}, + mockSvc: func(ecc *mocks.CommandCreator, lcc *mocks.LimaCmdCreator, logger *mocks.Logger, + ctrl *gomock.Controller, ncsd *mocks.NerdctlCommandSystemDeps, + ) { + getVMStatusC := mocks.NewCommand(ctrl) + lcc.EXPECT().CreateWithoutStdio("ls", "-f", "{{.Status}}", limaInstanceName).Return(getVMStatusC) + getVMStatusC.EXPECT().Output().Return([]byte("Running"), nil) + logger.EXPECT().Debugf("Status of virtual machine: %s", "Running") + ncsd.EXPECT().LookupEnv("AWS_ACCESS_KEY_ID").Return("", false) + ncsd.EXPECT().LookupEnv("AWS_SECRET_ACCESS_KEY").Return("", false) + ncsd.EXPECT().LookupEnv("AWS_SESSION_TOKEN").Return("", false) + ncsd.EXPECT().LookupEnv("COSIGN_PASSWORD").Return("", false) + ncsd.EXPECT().GetWd().Return("C:\\workdir", nil) + ncsd.EXPECT().FilePathAbs("C:\\workdir").Return("C:\\workdir", nil) + ncsd.EXPECT().FilePathJoin(string(filepath.Separator), "mnt", "c", "workdir").Return(augmentedPath) + ncsd.EXPECT().FilePathToSlash(augmentedPath).Return(wslPath) + c := mocks.NewCommand(ctrl) + lcc.EXPECT().Create("shell", "--workdir", wslPath, limaInstanceName, "sudo", "-E", nerdctlCmdName, "info").Return(c) + c.EXPECT().Run() + }, + }, + } + + for _, tc := range testCases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + ecc := mocks.NewCommandCreator(ctrl) + lcc := mocks.NewLimaCmdCreator(ctrl) + ncsd := mocks.NewNerdctlCommandSystemDeps(ctrl) + logger := mocks.NewLogger(ctrl) + tc.mockSvc(ecc, lcc, logger, ctrl, ncsd) + + assert.NoError(t, newNerdctlCommand(lcc, ecc, ncsd, logger, nil, &config.Finch{}).runAdapter(tc.cmd, tc.args)) + }) + } +} + +func TestNerdctlCommand_run(t *testing.T) { + t.Parallel() + envFilePath := filepath.Join(string(filepath.Separator), "env-file") + testCases := []struct { + name string + cmdName string + fc *config.Finch + args []string + wantErr error + mockSvc func(*testing.T, *mocks.CommandCreator, *mocks.LimaCmdCreator, *mocks.Command, *mocks.NerdctlCommandSystemDeps, + *mocks.Logger, *gomock.Controller, afero.Fs) + }{ + { + name: "happy path", + cmdName: "build", + fc: &config.Finch{}, + args: []string{"-t", "demo", "C:\\Users"}, + wantErr: nil, + mockSvc: func( + t *testing.T, + ecc *mocks.CommandCreator, + lcc *mocks.LimaCmdCreator, + cmd *mocks.Command, + ncsd *mocks.NerdctlCommandSystemDeps, + logger *mocks.Logger, + ctrl *gomock.Controller, + fs afero.Fs, + ) { + getVMStatusC := mocks.NewCommand(ctrl) + lcc.EXPECT().CreateWithoutStdio("ls", "-f", "{{.Status}}", limaInstanceName).Return(getVMStatusC) + getVMStatusC.EXPECT().Output().Return([]byte("Running"), nil) + logger.EXPECT().Debugf("Status of virtual machine: %s", "Running") + ncsd.EXPECT().LookupEnv("AWS_ACCESS_KEY_ID").Return("", false) + ncsd.EXPECT().LookupEnv("AWS_SECRET_ACCESS_KEY").Return("", false) + ncsd.EXPECT().LookupEnv("AWS_SESSION_TOKEN").Return("", false) + ncsd.EXPECT().LookupEnv("COSIGN_PASSWORD").Return("", false) + ncsd.EXPECT().GetWd().Return("C:\\workdir", nil) + ncsd.EXPECT().FilePathAbs("C:\\workdir").Return("C:\\workdir", nil) + ncsd.EXPECT().FilePathAbs("C:\\Users").Return("C:\\Users", nil) + ncsd.EXPECT().FilePathJoin(string(filepath.Separator), "mnt", "c", "workdir").Return(augmentedPath) + ncsd.EXPECT().FilePathJoin(string(filepath.Separator), "mnt", "c", "Users").Return("\\mnt\\c\\Users") + ncsd.EXPECT().FilePathToSlash(augmentedPath).Return(wslPath) + ncsd.EXPECT().FilePathToSlash("\\mnt\\c\\Users").Return("/mnt/c/Users") + + c := mocks.NewCommand(ctrl) + // alias substitution, build => image build + lcc.EXPECT().Create("shell", "--workdir", wslPath, limaInstanceName, + "sudo", "-E", nerdctlCmdName, "image", "build", "-t", "demo", "/mnt/c/Users").Return(c) + c.EXPECT().Run() + }, + }, + { + name: "with --debug flag", + cmdName: "pull", + fc: &config.Finch{}, + args: []string{"test:tag", "--debug"}, + wantErr: nil, + mockSvc: func( + t *testing.T, + ecc *mocks.CommandCreator, + lcc *mocks.LimaCmdCreator, + cmd *mocks.Command, + ncsd *mocks.NerdctlCommandSystemDeps, + logger *mocks.Logger, + ctrl *gomock.Controller, + fs afero.Fs, + ) { + getVMStatusC := mocks.NewCommand(ctrl) + lcc.EXPECT().CreateWithoutStdio("ls", "-f", "{{.Status}}", limaInstanceName).Return(getVMStatusC) + getVMStatusC.EXPECT().Output().Return([]byte("Running"), nil) + logger.EXPECT().Debugf("Status of virtual machine: %s", "Running") + ncsd.EXPECT().LookupEnv("AWS_ACCESS_KEY_ID").Return("", false) + ncsd.EXPECT().LookupEnv("AWS_SECRET_ACCESS_KEY").Return("", false) + ncsd.EXPECT().LookupEnv("AWS_SESSION_TOKEN").Return("", false) + logger.EXPECT().SetLevel(flog.Debug) + ncsd.EXPECT().LookupEnv("COSIGN_PASSWORD").Return("", false) + ncsd.EXPECT().GetWd().Return("C:\\workdir", nil) + ncsd.EXPECT().FilePathAbs("C:\\workdir").Return("C:\\workdir", nil) + ncsd.EXPECT().FilePathJoin(string(filepath.Separator), "mnt", "c", "workdir").Return(augmentedPath) + ncsd.EXPECT().FilePathToSlash(augmentedPath).Return(wslPath) + + c := mocks.NewCommand(ctrl) + lcc.EXPECT().Create("shell", "--workdir", wslPath, limaInstanceName, "sudo", "-E", nerdctlCmdName, "pull", "test:tag").Return(c) + c.EXPECT().Run() + }, + }, + { + name: "with environment flags parsing and env value doesn't exist", + cmdName: "run", + fc: &config.Finch{}, + args: []string{"--rm", "-e", "ARG1=val1", "--env=ARG2", "-eARG3", "alpine:latest", "env"}, + wantErr: nil, + mockSvc: func( + t *testing.T, + ecc *mocks.CommandCreator, + lcc *mocks.LimaCmdCreator, + cmd *mocks.Command, + ncsd *mocks.NerdctlCommandSystemDeps, + logger *mocks.Logger, + ctrl *gomock.Controller, + fs afero.Fs, + ) { + getVMStatusC := mocks.NewCommand(ctrl) + lcc.EXPECT().CreateWithoutStdio("ls", "-f", "{{.Status}}", limaInstanceName).Return(getVMStatusC) + getVMStatusC.EXPECT().Output().Return([]byte("Running"), nil) + logger.EXPECT().Debugf("Status of virtual machine: %s", "Running") + ncsd.EXPECT().LookupEnv("AWS_ACCESS_KEY_ID").Return("", false) + ncsd.EXPECT().LookupEnv("AWS_SECRET_ACCESS_KEY").Return("", false) + ncsd.EXPECT().LookupEnv("AWS_SESSION_TOKEN").Return("", false) + c := mocks.NewCommand(ctrl) + ncsd.EXPECT().LookupEnv("ARG2") + ncsd.EXPECT().LookupEnv("ARG3") + ncsd.EXPECT().LookupEnv("COSIGN_PASSWORD").Return("", false) + ncsd.EXPECT().GetWd().Return("C:\\workdir", nil) + ncsd.EXPECT().FilePathAbs("C:\\workdir").Return("C:\\workdir", nil) + ncsd.EXPECT().FilePathJoin(string(filepath.Separator), "mnt", "c", "workdir").Return(augmentedPath) + ncsd.EXPECT().FilePathToSlash(augmentedPath).Return(wslPath) + + lcc.EXPECT().Create("shell", "--workdir", wslPath, limaInstanceName, "sudo", "-E", nerdctlCmdName, "container", "run", + "-e", "ARG1=val1", "--rm", "alpine:latest", "env").Return(c) + c.EXPECT().Run() + }, + }, + { + name: "with environment flags parsing and env value exists", + cmdName: "run", + fc: &config.Finch{}, + args: []string{"--rm", "--env=ARG2", "-eARG3", "alpine:latest", "env"}, + wantErr: nil, + mockSvc: func( + t *testing.T, + ecc *mocks.CommandCreator, + lcc *mocks.LimaCmdCreator, + cmd *mocks.Command, + ncsd *mocks.NerdctlCommandSystemDeps, + logger *mocks.Logger, + ctrl *gomock.Controller, + fs afero.Fs, + ) { + getVMStatusC := mocks.NewCommand(ctrl) + lcc.EXPECT().CreateWithoutStdio("ls", "-f", "{{.Status}}", limaInstanceName).Return(getVMStatusC) + getVMStatusC.EXPECT().Output().Return([]byte("Running"), nil) + logger.EXPECT().Debugf("Status of virtual machine: %s", "Running") + ncsd.EXPECT().LookupEnv("AWS_ACCESS_KEY_ID").Return("", false) + ncsd.EXPECT().LookupEnv("AWS_SECRET_ACCESS_KEY").Return("", false) + ncsd.EXPECT().LookupEnv("AWS_SESSION_TOKEN").Return("", false) + c := mocks.NewCommand(ctrl) + ncsd.EXPECT().LookupEnv("ARG2") + ncsd.EXPECT().LookupEnv("ARG3").Return("val3", true) + ncsd.EXPECT().LookupEnv("COSIGN_PASSWORD").Return("", false) + ncsd.EXPECT().GetWd().Return("C:\\workdir", nil) + ncsd.EXPECT().FilePathAbs("C:\\workdir").Return("C:\\workdir", nil) + ncsd.EXPECT().FilePathJoin(string(filepath.Separator), "mnt", "c", "workdir").Return(augmentedPath) + ncsd.EXPECT().FilePathToSlash(augmentedPath).Return(wslPath) + // alias substitution run=>container run + lcc.EXPECT().Create("shell", "--workdir", wslPath, limaInstanceName, "sudo", "-E", nerdctlCmdName, "container", "run", + "-e", "ARG3=val3", "--rm", "alpine:latest", "env").Return(c) + c.EXPECT().Run() + }, + }, + { + name: "with --env-file flag replacement", + cmdName: "run", + fc: &config.Finch{}, + args: []string{"--rm", "--env-file=" + envFilePath, "alpine:latest", "env"}, + wantErr: nil, + mockSvc: func( + t *testing.T, + ecc *mocks.CommandCreator, + lcc *mocks.LimaCmdCreator, + cmd *mocks.Command, + ncsd *mocks.NerdctlCommandSystemDeps, + logger *mocks.Logger, + ctrl *gomock.Controller, + fs afero.Fs, + ) { + envFileStr := "# a comment\nARG1=val1\n ARG2\n\n # a 2nd comment\nNOTSETARG\n " + require.NoError(t, afero.WriteFile(fs, envFilePath, []byte(envFileStr), 0o600)) + + getVMStatusC := mocks.NewCommand(ctrl) + lcc.EXPECT().CreateWithoutStdio("ls", "-f", "{{.Status}}", limaInstanceName).Return(getVMStatusC) + getVMStatusC.EXPECT().Output().Return([]byte("Running"), nil) + logger.EXPECT().Debugf("Status of virtual machine: %s", "Running") + ncsd.EXPECT().LookupEnv("AWS_ACCESS_KEY_ID").Return("", false) + ncsd.EXPECT().LookupEnv("AWS_SECRET_ACCESS_KEY").Return("", false) + ncsd.EXPECT().LookupEnv("AWS_SESSION_TOKEN").Return("", false) + c := mocks.NewCommand(ctrl) + ncsd.EXPECT().LookupEnv("ARG2") + ncsd.EXPECT().LookupEnv("NOTSETARG") + ncsd.EXPECT().LookupEnv("COSIGN_PASSWORD").Return("", false) + ncsd.EXPECT().GetWd().Return("C:\\workdir", nil) + ncsd.EXPECT().FilePathAbs("C:\\workdir").Return("C:\\workdir", nil) + ncsd.EXPECT().FilePathJoin(string(filepath.Separator), "mnt", "c", "workdir").Return(augmentedPath) + ncsd.EXPECT().FilePathToSlash(augmentedPath).Return(wslPath) + lcc.EXPECT().Create("shell", "--workdir", wslPath, limaInstanceName, + "sudo", "-E", nerdctlCmdName, "container", "run", "-e", "ARG1=val1", "--rm", "alpine:latest", "env").Return(c) + c.EXPECT().Run() + }, + }, + { + name: "with --env-file flag replacement and existing env value", + cmdName: "run", + fc: &config.Finch{}, + args: []string{"--rm", "--env-file", envFilePath, "alpine:latest", "env"}, + wantErr: nil, + mockSvc: func( + t *testing.T, + ecc *mocks.CommandCreator, + lcc *mocks.LimaCmdCreator, + cmd *mocks.Command, + ncsd *mocks.NerdctlCommandSystemDeps, + logger *mocks.Logger, + ctrl *gomock.Controller, + fs afero.Fs, + ) { + envFileStr := "# a comment\n ARG2\n\n # a 2nd comment\nNOTSETARG\n " + require.NoError(t, afero.WriteFile(fs, envFilePath, []byte(envFileStr), 0o600)) + + getVMStatusC := mocks.NewCommand(ctrl) + lcc.EXPECT().CreateWithoutStdio("ls", "-f", "{{.Status}}", limaInstanceName).Return(getVMStatusC) + getVMStatusC.EXPECT().Output().Return([]byte("Running"), nil) + logger.EXPECT().Debugf("Status of virtual machine: %s", "Running") + ncsd.EXPECT().LookupEnv("AWS_ACCESS_KEY_ID").Return("", false) + ncsd.EXPECT().LookupEnv("AWS_SECRET_ACCESS_KEY").Return("", false) + ncsd.EXPECT().LookupEnv("AWS_SESSION_TOKEN").Return("", false) + c := mocks.NewCommand(ctrl) + ncsd.EXPECT().LookupEnv("ARG2").Return("val2", true) + ncsd.EXPECT().LookupEnv("NOTSETARG") + ncsd.EXPECT().LookupEnv("COSIGN_PASSWORD").Return("", false) + ncsd.EXPECT().GetWd().Return("C:\\workdir", nil) + ncsd.EXPECT().FilePathAbs("C:\\workdir").Return("C:\\workdir", nil) + ncsd.EXPECT().FilePathJoin(string(filepath.Separator), "mnt", "c", "workdir").Return(augmentedPath) + ncsd.EXPECT().FilePathToSlash(augmentedPath).Return(wslPath) + lcc.EXPECT().Create("shell", "--workdir", wslPath, limaInstanceName, + "sudo", "-E", nerdctlCmdName, "container", "run", "-e", "ARG2=val2", "--rm", "alpine:latest", "env").Return(c) + c.EXPECT().Run() + }, + }, + { + name: "with --env-file flag, but the specified file does not exist", + cmdName: "run", + fc: &config.Finch{}, + args: []string{"--rm", "--env-file", envFilePath, "alpine:latest", "env"}, + wantErr: &os.PathError{Op: "open", Path: envFilePath, Err: afero.ErrFileNotFound}, + mockSvc: func( + t *testing.T, + ecc *mocks.CommandCreator, + lcc *mocks.LimaCmdCreator, + cmd *mocks.Command, + ncsd *mocks.NerdctlCommandSystemDeps, + logger *mocks.Logger, + ctrl *gomock.Controller, + fs afero.Fs, + ) { + getVMStatusC := mocks.NewCommand(ctrl) + lcc.EXPECT().CreateWithoutStdio("ls", "-f", "{{.Status}}", limaInstanceName).Return(getVMStatusC) + getVMStatusC.EXPECT().Output().Return([]byte("Running"), nil) + logger.EXPECT().Debugf("Status of virtual machine: %s", "Running") + }, + }, + { + name: "with --add-host flag and special IP by space", + cmdName: "run", + fc: &config.Finch{}, + args: []string{"--rm", "--add-host", "name:host-gateway", "alpine:latest"}, + wantErr: nil, + mockSvc: func( + t *testing.T, + ecc *mocks.CommandCreator, + lcc *mocks.LimaCmdCreator, + cmd *mocks.Command, + ncsd *mocks.NerdctlCommandSystemDeps, + logger *mocks.Logger, + ctrl *gomock.Controller, + fs afero.Fs, + ) { + getVMStatusC := mocks.NewCommand(ctrl) + ecc.EXPECT().Create("cmd", "/C", "netsh", "interface", "ipv4", "show", "addresses", "vEthernet (WSL)").Return(cmd) + cmd.EXPECT().Output().Return([]byte("IP Address: 192.168.5.2"), nil) + lcc.EXPECT().CreateWithoutStdio("ls", "-f", "{{.Status}}", limaInstanceName).Return(getVMStatusC) + getVMStatusC.EXPECT().Output().Return([]byte("Running"), nil) + logger.EXPECT().Debugf("Status of virtual machine: %s", "Running") + ncsd.EXPECT().LookupEnv("AWS_ACCESS_KEY_ID").Return("", false) + ncsd.EXPECT().LookupEnv("AWS_SECRET_ACCESS_KEY").Return("", false) + ncsd.EXPECT().LookupEnv("AWS_SESSION_TOKEN").Return("", false) + logger.EXPECT().Debugf(`Resolving special IP "host-gateway" to %q for host %q`, "192.168.5.2", "name") + ncsd.EXPECT().LookupEnv("COSIGN_PASSWORD").Return("", false) + ncsd.EXPECT().GetWd().Return("C:\\workdir", nil) + ncsd.EXPECT().FilePathAbs("C:\\workdir").Return("C:\\workdir", nil) + ncsd.EXPECT().FilePathJoin(string(filepath.Separator), "mnt", "c", "workdir").Return(augmentedPath) + ncsd.EXPECT().FilePathToSlash(augmentedPath).Return(wslPath) + + c := mocks.NewCommand(ctrl) + lcc.EXPECT().Create("shell", "--workdir", wslPath, limaInstanceName, "sudo", "-E", nerdctlCmdName, "container", "run", + "--rm", "--add-host", "name:192.168.5.2", "alpine:latest").Return(c) + c.EXPECT().Run() + }, + }, + { + name: "with --add-host flag but without using special IP by space", + cmdName: "run", + fc: &config.Finch{}, + args: []string{"--rm", "--add-host", "name:0.0.0.0", "alpine:latest"}, + wantErr: nil, + mockSvc: func( + t *testing.T, + ecc *mocks.CommandCreator, + lcc *mocks.LimaCmdCreator, + cmd *mocks.Command, + ncsd *mocks.NerdctlCommandSystemDeps, + logger *mocks.Logger, + ctrl *gomock.Controller, + fs afero.Fs, + ) { + getVMStatusC := mocks.NewCommand(ctrl) + lcc.EXPECT().CreateWithoutStdio("ls", "-f", "{{.Status}}", limaInstanceName).Return(getVMStatusC) + getVMStatusC.EXPECT().Output().Return([]byte("Running"), nil) + logger.EXPECT().Debugf("Status of virtual machine: %s", "Running") + ncsd.EXPECT().LookupEnv("AWS_ACCESS_KEY_ID").Return("", false) + ncsd.EXPECT().LookupEnv("AWS_SECRET_ACCESS_KEY").Return("", false) + ncsd.EXPECT().LookupEnv("AWS_SESSION_TOKEN").Return("", false) + ncsd.EXPECT().LookupEnv("COSIGN_PASSWORD").Return("", false) + ncsd.EXPECT().GetWd().Return("C:\\workdir", nil) + ncsd.EXPECT().FilePathAbs("C:\\workdir").Return("C:\\workdir", nil) + ncsd.EXPECT().FilePathJoin(string(filepath.Separator), "mnt", "c", "workdir").Return(augmentedPath) + ncsd.EXPECT().FilePathToSlash(augmentedPath).Return(wslPath) + c := mocks.NewCommand(ctrl) + lcc.EXPECT().Create("shell", "--workdir", wslPath, limaInstanceName, "sudo", "-E", nerdctlCmdName, "container", "run", + "--rm", "--add-host", "name:0.0.0.0", "alpine:latest").Return(c) + c.EXPECT().Run() + }, + }, + { + name: "with --add-host flag but without subsequent arg", + cmdName: "run", + fc: &config.Finch{}, + args: []string{"--rm", "--add-host", "alpine:latest"}, + wantErr: errors.New("run cmd error"), + mockSvc: func( + t *testing.T, + ecc *mocks.CommandCreator, + lcc *mocks.LimaCmdCreator, + cmd *mocks.Command, + ncsd *mocks.NerdctlCommandSystemDeps, + logger *mocks.Logger, + ctrl *gomock.Controller, + fs afero.Fs, + ) { + getVMStatusC := mocks.NewCommand(ctrl) + lcc.EXPECT().CreateWithoutStdio("ls", "-f", "{{.Status}}", limaInstanceName).Return(getVMStatusC) + getVMStatusC.EXPECT().Output().Return([]byte("Running"), nil) + logger.EXPECT().Debugf("Status of virtual machine: %s", "Running") + ncsd.EXPECT().LookupEnv("AWS_ACCESS_KEY_ID").Return("", false) + ncsd.EXPECT().LookupEnv("AWS_SECRET_ACCESS_KEY").Return("", false) + ncsd.EXPECT().LookupEnv("AWS_SESSION_TOKEN").Return("", false) + ncsd.EXPECT().LookupEnv("COSIGN_PASSWORD").Return("", false) + ncsd.EXPECT().GetWd().Return("C:\\workdir", nil) + ncsd.EXPECT().FilePathAbs("C:\\workdir").Return("C:\\workdir", nil) + ncsd.EXPECT().FilePathJoin(string(filepath.Separator), "mnt", "c", "workdir").Return(augmentedPath) + ncsd.EXPECT().FilePathToSlash(augmentedPath).Return(wslPath) + c := mocks.NewCommand(ctrl) + lcc.EXPECT().Create("shell", "--workdir", wslPath, limaInstanceName, "sudo", "-E", nerdctlCmdName, "container", "run", + "--rm", "--add-host", "alpine:latest").Return(c) + c.EXPECT().Run().Return(errors.New("run cmd error")) + }, + }, + { + name: "with --add-host flag and special IP by equal", + cmdName: "run", + fc: &config.Finch{}, + args: []string{"--rm", "--add-host=name:host-gateway", "alpine:latest"}, + wantErr: nil, + mockSvc: func( + t *testing.T, + ecc *mocks.CommandCreator, + lcc *mocks.LimaCmdCreator, + cmd *mocks.Command, + ncsd *mocks.NerdctlCommandSystemDeps, + logger *mocks.Logger, + ctrl *gomock.Controller, + fs afero.Fs, + ) { + getVMStatusC := mocks.NewCommand(ctrl) + ecc.EXPECT().Create("cmd", "/C", "netsh", "interface", "ipv4", "show", "addresses", "vEthernet (WSL)").Return(cmd) + cmd.EXPECT().Output().Return([]byte("IP Address: 192.168.5.2"), nil) + lcc.EXPECT().CreateWithoutStdio("ls", "-f", "{{.Status}}", limaInstanceName).Return(getVMStatusC) + getVMStatusC.EXPECT().Output().Return([]byte("Running"), nil) + logger.EXPECT().Debugf("Status of virtual machine: %s", "Running") + ncsd.EXPECT().LookupEnv("AWS_ACCESS_KEY_ID").Return("", false) + ncsd.EXPECT().LookupEnv("AWS_SECRET_ACCESS_KEY").Return("", false) + ncsd.EXPECT().LookupEnv("AWS_SESSION_TOKEN").Return("", false) + logger.EXPECT().Debugf(`Resolving special IP "host-gateway" to %q for host %q`, "192.168.5.2", "name") + ncsd.EXPECT().LookupEnv("COSIGN_PASSWORD").Return("", false) + ncsd.EXPECT().GetWd().Return("C:\\workdir", nil) + ncsd.EXPECT().FilePathAbs("C:\\workdir").Return("C:\\workdir", nil) + ncsd.EXPECT().FilePathJoin(string(filepath.Separator), "mnt", "c", "workdir").Return(augmentedPath) + ncsd.EXPECT().FilePathToSlash(augmentedPath).Return(wslPath) + c := mocks.NewCommand(ctrl) + lcc.EXPECT().Create("shell", "--workdir", wslPath, limaInstanceName, "sudo", "-E", nerdctlCmdName, "container", "run", + "--rm", "--add-host=name:192.168.5.2", "alpine:latest").Return(c) + c.EXPECT().Run() + }, + }, + { + name: "with --add-host flag but without using special IP by equal", + cmdName: "run", + fc: &config.Finch{}, + args: []string{"--rm", "--add-host=name:0.0.0.0", "alpine:latest"}, + wantErr: nil, + mockSvc: func( + t *testing.T, + ecc *mocks.CommandCreator, + lcc *mocks.LimaCmdCreator, + cmd *mocks.Command, + ncsd *mocks.NerdctlCommandSystemDeps, + logger *mocks.Logger, + ctrl *gomock.Controller, + fs afero.Fs, + ) { + getVMStatusC := mocks.NewCommand(ctrl) + lcc.EXPECT().CreateWithoutStdio("ls", "-f", "{{.Status}}", limaInstanceName).Return(getVMStatusC) + getVMStatusC.EXPECT().Output().Return([]byte("Running"), nil) + logger.EXPECT().Debugf("Status of virtual machine: %s", "Running") + ncsd.EXPECT().LookupEnv("AWS_ACCESS_KEY_ID").Return("", false) + ncsd.EXPECT().LookupEnv("AWS_SECRET_ACCESS_KEY").Return("", false) + ncsd.EXPECT().LookupEnv("AWS_SESSION_TOKEN").Return("", false) + ncsd.EXPECT().LookupEnv("COSIGN_PASSWORD").Return("", false) + ncsd.EXPECT().GetWd().Return("C:\\workdir", nil) + ncsd.EXPECT().FilePathAbs("C:\\workdir").Return("C:\\workdir", nil) + ncsd.EXPECT().FilePathJoin(string(filepath.Separator), "mnt", "c", "workdir").Return(augmentedPath) + ncsd.EXPECT().FilePathToSlash(augmentedPath).Return(wslPath) + c := mocks.NewCommand(ctrl) + lcc.EXPECT().Create("shell", "--workdir", wslPath, limaInstanceName, "sudo", "-E", nerdctlCmdName, "container", "run", + "--rm", "--add-host=name:0.0.0.0", "alpine:latest").Return(c) + c.EXPECT().Run() + }, + }, + { + name: "with multiple nested volumes", + cmdName: "run", + fc: &config.Finch{}, + args: []string{ + "--rm", "-v", "C:\\workdir:/tmp1/tmp2:rro", "-v=C:\\workdir:/tmp1/tmp2/tmp3/tmp4:rro", + "-v", "volume", "alpine:latest", + }, + wantErr: nil, + mockSvc: func( + t *testing.T, + ecc *mocks.CommandCreator, + lcc *mocks.LimaCmdCreator, + cmd *mocks.Command, + ncsd *mocks.NerdctlCommandSystemDeps, + logger *mocks.Logger, + ctrl *gomock.Controller, + fs afero.Fs, + ) { + getVMStatusC := mocks.NewCommand(ctrl) + lcc.EXPECT().CreateWithoutStdio("ls", "-f", "{{.Status}}", limaInstanceName).Return(getVMStatusC) + getVMStatusC.EXPECT().Output().Return([]byte("Running"), nil) + logger.EXPECT().Debugf("Status of virtual machine: %s", "Running") + ncsd.EXPECT().LookupEnv("AWS_ACCESS_KEY_ID").Return("", false) + ncsd.EXPECT().LookupEnv("AWS_SECRET_ACCESS_KEY").Return("", false) + ncsd.EXPECT().LookupEnv("AWS_SESSION_TOKEN").Return("", false) + ncsd.EXPECT().LookupEnv("COSIGN_PASSWORD").Return("", false) + ncsd.EXPECT().GetWd().Return("C:\\workdir", nil).Times(1) + ncsd.EXPECT().FilePathAbs("C:\\workdir").Return("C:\\workdir", nil).Times(5) + ncsd.EXPECT().FilePathJoin(string(filepath.Separator), "mnt", "c", "workdir").Return(augmentedPath).Times(3) + ncsd.EXPECT().FilePathToSlash(augmentedPath).Return(wslPath).Times(3) + c := mocks.NewCommand(ctrl) + lcc.EXPECT().Create("shell", "--workdir", wslPath, limaInstanceName, "sudo", "-E", nerdctlCmdName, "container", "run", + "--rm", "-v", "/mnt/c/workdir:/tmp1/tmp2:rro", "-v=/mnt/c/workdir:/tmp1/tmp2/tmp3/tmp4:rro", + "-v", "volume", "alpine:latest").Return(c) + c.EXPECT().Run() + }, + }, + { + name: "with --help flag", + cmdName: "pull", + fc: &config.Finch{}, + args: []string{"test:tag", "--help"}, + wantErr: nil, + mockSvc: func( + t *testing.T, + ecc *mocks.CommandCreator, + lcc *mocks.LimaCmdCreator, + cmd *mocks.Command, + ncsd *mocks.NerdctlCommandSystemDeps, + logger *mocks.Logger, + ctrl *gomock.Controller, + fs afero.Fs, + ) { + getVMStatusC := mocks.NewCommand(ctrl) + lcc.EXPECT().CreateWithoutStdio("ls", "-f", "{{.Status}}", limaInstanceName).Return(getVMStatusC) + getVMStatusC.EXPECT().Output().Return([]byte("Running"), nil) + logger.EXPECT().Debugf("Status of virtual machine: %s", "Running") + ncsd.EXPECT().LookupEnv("AWS_ACCESS_KEY_ID").Return("", false) + ncsd.EXPECT().LookupEnv("AWS_SECRET_ACCESS_KEY").Return("", false) + ncsd.EXPECT().LookupEnv("AWS_SESSION_TOKEN").Return("", false) + ncsd.EXPECT().LookupEnv("COSIGN_PASSWORD").Return("", false) + ncsd.EXPECT().GetWd().Return("C:\\workdir", nil) + ncsd.EXPECT().FilePathAbs("C:\\workdir").Return("C:\\workdir", nil) + ncsd.EXPECT().FilePathJoin(string(filepath.Separator), "mnt", "c", "workdir").Return(augmentedPath) + ncsd.EXPECT().FilePathToSlash(augmentedPath).Return(wslPath) + lcc.EXPECT().RunWithReplacingStdout( + testStdoutRs, "shell", "--workdir", wslPath, limaInstanceName, + "sudo", "-E", nerdctlCmdName, "pull", "test:tag", "--help").Return(nil) + }, + }, + { + name: "with --help flag but replacing returns error", + cmdName: "pull", + fc: &config.Finch{}, + args: []string{"test:tag", "--help"}, + wantErr: fmt.Errorf("failed to replace"), + mockSvc: func( + t *testing.T, + ecc *mocks.CommandCreator, + lcc *mocks.LimaCmdCreator, + cmd *mocks.Command, + ncsd *mocks.NerdctlCommandSystemDeps, + logger *mocks.Logger, + ctrl *gomock.Controller, + fs afero.Fs, + ) { + getVMStatusC := mocks.NewCommand(ctrl) + lcc.EXPECT().CreateWithoutStdio("ls", "-f", "{{.Status}}", limaInstanceName).Return(getVMStatusC) + getVMStatusC.EXPECT().Output().Return([]byte("Running"), nil) + logger.EXPECT().Debugf("Status of virtual machine: %s", "Running") + ncsd.EXPECT().LookupEnv("AWS_ACCESS_KEY_ID").Return("", false) + ncsd.EXPECT().LookupEnv("AWS_SECRET_ACCESS_KEY").Return("", false) + ncsd.EXPECT().LookupEnv("AWS_SESSION_TOKEN").Return("", false) + ncsd.EXPECT().LookupEnv("COSIGN_PASSWORD").Return("", false) + ncsd.EXPECT().GetWd().Return("C:\\workdir", nil) + ncsd.EXPECT().FilePathAbs("C:\\workdir").Return("C:\\workdir", nil) + ncsd.EXPECT().FilePathJoin(string(filepath.Separator), "mnt", "c", "workdir").Return(augmentedPath) + ncsd.EXPECT().FilePathToSlash(augmentedPath).Return(wslPath) + lcc.EXPECT().RunWithReplacingStdout( + testStdoutRs, "shell", "--workdir", wslPath, limaInstanceName, "sudo", "-E", nerdctlCmdName, "pull", "test:tag", "--help"). + Return(fmt.Errorf("failed to replace")) + }, + }, + { + name: "with COSIGN_PASSWORD env var and --sign=cosign", + cmdName: "push", + fc: &config.Finch{}, + args: []string{"--sign=cosign", "test:tag"}, + wantErr: nil, + mockSvc: func( + t *testing.T, + ecc *mocks.CommandCreator, + lcc *mocks.LimaCmdCreator, + cmd *mocks.Command, + ncsd *mocks.NerdctlCommandSystemDeps, + logger *mocks.Logger, + ctrl *gomock.Controller, + fs afero.Fs, + ) { + getVMStatusC := mocks.NewCommand(ctrl) + lcc.EXPECT().CreateWithoutStdio("ls", "-f", "{{.Status}}", limaInstanceName).Return(getVMStatusC) + getVMStatusC.EXPECT().Output().Return([]byte("Running"), nil) + logger.EXPECT().Debugf("Status of virtual machine: %s", "Running") + ncsd.EXPECT().LookupEnv("AWS_ACCESS_KEY_ID").Return("", false) + ncsd.EXPECT().LookupEnv("AWS_SECRET_ACCESS_KEY").Return("", false) + ncsd.EXPECT().LookupEnv("AWS_SESSION_TOKEN").Return("", false) + ncsd.EXPECT().LookupEnv("COSIGN_PASSWORD").Return("test", true) + ncsd.EXPECT().GetWd().Return("C:\\workdir", nil) + ncsd.EXPECT().FilePathAbs("C:\\workdir").Return("C:\\workdir", nil) + ncsd.EXPECT().FilePathJoin(string(filepath.Separator), "mnt", "c", "workdir").Return(augmentedPath) + ncsd.EXPECT().FilePathToSlash(augmentedPath).Return(wslPath) + c := mocks.NewCommand(ctrl) + lcc.EXPECT().Create("shell", "--workdir", wslPath, limaInstanceName, "sudo", "-E", "COSIGN_PASSWORD=test", nerdctlCmdName, + "push", "--sign=cosign", "test:tag").Return(c) + c.EXPECT().Run() + }, + }, + { + name: "with COSIGN_PASSWORD env var and --verify=cosign", + cmdName: "pull", + fc: &config.Finch{}, + args: []string{"--verify=cosign", "test:tag"}, + wantErr: nil, + mockSvc: func( + t *testing.T, + ecc *mocks.CommandCreator, + lcc *mocks.LimaCmdCreator, + cmd *mocks.Command, + ncsd *mocks.NerdctlCommandSystemDeps, + logger *mocks.Logger, + ctrl *gomock.Controller, + fs afero.Fs, + ) { + getVMStatusC := mocks.NewCommand(ctrl) + lcc.EXPECT().CreateWithoutStdio("ls", "-f", "{{.Status}}", limaInstanceName).Return(getVMStatusC) + getVMStatusC.EXPECT().Output().Return([]byte("Running"), nil) + logger.EXPECT().Debugf("Status of virtual machine: %s", "Running") + ncsd.EXPECT().LookupEnv("AWS_ACCESS_KEY_ID").Return("", false) + ncsd.EXPECT().LookupEnv("AWS_SECRET_ACCESS_KEY").Return("", false) + ncsd.EXPECT().LookupEnv("AWS_SESSION_TOKEN").Return("", false) + ncsd.EXPECT().LookupEnv("COSIGN_PASSWORD").Return("test", true) + ncsd.EXPECT().GetWd().Return("C:\\workdir", nil) + ncsd.EXPECT().FilePathAbs("C:\\workdir").Return("C:\\workdir", nil) + ncsd.EXPECT().FilePathJoin(string(filepath.Separator), "mnt", "c", "workdir").Return(augmentedPath) + ncsd.EXPECT().FilePathToSlash(augmentedPath).Return(wslPath) + c := mocks.NewCommand(ctrl) + lcc.EXPECT().Create("shell", "--workdir", wslPath, limaInstanceName, "sudo", "-E", "COSIGN_PASSWORD=test", nerdctlCmdName, + "pull", "--verify=cosign", "test:tag").Return(c) + c.EXPECT().Run() + }, + }, + { + name: "with COSIGN_PASSWORD env var without cosign arg", + cmdName: "pull", + fc: &config.Finch{}, + args: []string{"test:tag"}, + wantErr: nil, + mockSvc: func( + t *testing.T, + ecc *mocks.CommandCreator, + lcc *mocks.LimaCmdCreator, + cmd *mocks.Command, + ncsd *mocks.NerdctlCommandSystemDeps, + logger *mocks.Logger, + ctrl *gomock.Controller, + fs afero.Fs, + ) { + getVMStatusC := mocks.NewCommand(ctrl) + lcc.EXPECT().CreateWithoutStdio("ls", "-f", "{{.Status}}", limaInstanceName).Return(getVMStatusC) + getVMStatusC.EXPECT().Output().Return([]byte("Running"), nil) + logger.EXPECT().Debugf("Status of virtual machine: %s", "Running") + ncsd.EXPECT().LookupEnv("AWS_ACCESS_KEY_ID").Return("", false) + ncsd.EXPECT().LookupEnv("AWS_SECRET_ACCESS_KEY").Return("", false) + ncsd.EXPECT().LookupEnv("AWS_SESSION_TOKEN").Return("", false) + ncsd.EXPECT().LookupEnv("COSIGN_PASSWORD").Return("test", true) + ncsd.EXPECT().GetWd().Return("C:\\workdir", nil) + ncsd.EXPECT().FilePathAbs("C:\\workdir").Return("C:\\workdir", nil) + ncsd.EXPECT().FilePathJoin(string(filepath.Separator), "mnt", "c", "workdir").Return(augmentedPath) + ncsd.EXPECT().FilePathToSlash(augmentedPath).Return(wslPath) + c := mocks.NewCommand(ctrl) + lcc.EXPECT().Create("shell", "--workdir", wslPath, limaInstanceName, "sudo", "-E", "COSIGN_PASSWORD=test", + nerdctlCmdName, "pull", "test:tag").Return(c) + c.EXPECT().Run() + }, + }, + } + + for _, tc := range testCases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + ecc := mocks.NewCommandCreator(ctrl) + lcc := mocks.NewLimaCmdCreator(ctrl) + cmd := mocks.NewCommand(ctrl) + ncsd := mocks.NewNerdctlCommandSystemDeps(ctrl) + logger := mocks.NewLogger(ctrl) + fs := afero.NewMemMapFs() + tc.mockSvc(t, ecc, lcc, cmd, ncsd, logger, ctrl, fs) + assert.Equal(t, tc.wantErr, newNerdctlCommand(lcc, ecc, ncsd, logger, fs, tc.fc).run(tc.cmdName, tc.args)) + }) + } +} + +type ContainsSubstring struct { + substr string +} + +func (m *ContainsSubstring) Matches(x interface{}) bool { + s, ok := x.(string) + if !ok { + return false + } + return strings.Contains(s, m.substr) +} + +func (m *ContainsSubstring) String() string { + return fmt.Sprintf("contains substring %q", m.substr) +} + +func ContainsStr(substr string) gomock.Matcher { + return &ContainsSubstring{substr: substr} +} + +func TestNerdctlCommand_Run_withBindMounts(t *testing.T) { + t.Parallel() + testCases := []struct { + name string + cmdName string + args []string + wantErr error + mockSvc func(*testing.T, *mocks.CommandCreator, *mocks.LimaCmdCreator, *mocks.NerdctlCommandSystemDeps, + *mocks.Logger, *gomock.Controller, afero.Fs) + }{ + { + name: "mount type is bind and src", + cmdName: "run", + args: []string{"--mount", "type=bind,src=C:\\workdir,target=/app", "alpine:latest"}, + wantErr: nil, + mockSvc: func( + t *testing.T, + ecc *mocks.CommandCreator, + lcc *mocks.LimaCmdCreator, + ncsd *mocks.NerdctlCommandSystemDeps, + logger *mocks.Logger, + ctrl *gomock.Controller, + fs afero.Fs, + ) { + getVMStatusC := mocks.NewCommand(ctrl) + lcc.EXPECT().CreateWithoutStdio("ls", "-f", "{{.Status}}", limaInstanceName).Return(getVMStatusC) + getVMStatusC.EXPECT().Output().Return([]byte("Running"), nil) + logger.EXPECT().Debugf("Status of virtual machine: %s", "Running") + ncsd.EXPECT().LookupEnv("AWS_ACCESS_KEY_ID").Return("", false) + ncsd.EXPECT().LookupEnv("AWS_SECRET_ACCESS_KEY").Return("", false) + ncsd.EXPECT().LookupEnv("AWS_SESSION_TOKEN").Return("", false) + ncsd.EXPECT().LookupEnv("COSIGN_PASSWORD").Return("", false) + ncsd.EXPECT().GetWd().Return("C:\\workdir", nil) + ncsd.EXPECT().FilePathAbs("C:\\workdir").Return("C:\\workdir", nil).Times(2) + ncsd.EXPECT().FilePathJoin(string(filepath.Separator), "mnt", "c", "workdir").Return(augmentedPath).Times(2) + ncsd.EXPECT().FilePathToSlash(augmentedPath).Return(wslPath).Times(2) + + c := mocks.NewCommand(ctrl) + // alias substitution, run => container run + lcc.EXPECT().Create("shell", "--workdir", wslPath, limaInstanceName, + "sudo", "-E", nerdctlCmdName, "container", "run", "--mount", + ContainsStr("src=/mnt/c/workdir"), "alpine:latest").Return(c) + c.EXPECT().Run() + }, + }, + { + name: "mount type is bind and source", + cmdName: "run", + args: []string{"--mount", "type=bind,source=C:\\workdir,target=/app", "alpine:latest"}, + wantErr: nil, + mockSvc: func( + t *testing.T, + ecc *mocks.CommandCreator, + lcc *mocks.LimaCmdCreator, + ncsd *mocks.NerdctlCommandSystemDeps, + logger *mocks.Logger, + ctrl *gomock.Controller, + fs afero.Fs, + ) { + getVMStatusC := mocks.NewCommand(ctrl) + lcc.EXPECT().CreateWithoutStdio("ls", "-f", "{{.Status}}", limaInstanceName).Return(getVMStatusC) + getVMStatusC.EXPECT().Output().Return([]byte("Running"), nil) + logger.EXPECT().Debugf("Status of virtual machine: %s", "Running") + ncsd.EXPECT().LookupEnv("AWS_ACCESS_KEY_ID").Return("", false) + ncsd.EXPECT().LookupEnv("AWS_SECRET_ACCESS_KEY").Return("", false) + ncsd.EXPECT().LookupEnv("AWS_SESSION_TOKEN").Return("", false) + ncsd.EXPECT().LookupEnv("COSIGN_PASSWORD").Return("", false) + ncsd.EXPECT().GetWd().Return("C:\\workdir", nil) + ncsd.EXPECT().FilePathAbs("C:\\workdir").Return("C:\\workdir", nil).Times(2) + ncsd.EXPECT().FilePathJoin(string(filepath.Separator), "mnt", "c", "workdir").Return(augmentedPath).Times(2) + ncsd.EXPECT().FilePathToSlash(augmentedPath).Return(wslPath).Times(2) + + c := mocks.NewCommand(ctrl) + // alias substitution, run => container run + lcc.EXPECT().Create("shell", "--workdir", wslPath, limaInstanceName, + "sudo", "-E", nerdctlCmdName, "container", "run", "--mount", + ContainsStr("source=/mnt/c/workdir"), "alpine:latest").Return(c) + c.EXPECT().Run() + }, + }, + { + name: "mount type is bind and is concatenated to --mount option", + cmdName: "run", + args: []string{"--mount=type=bind,source=C:\\workdir,target=/app", "alpine:latest"}, + wantErr: nil, + mockSvc: func( + t *testing.T, + ecc *mocks.CommandCreator, + lcc *mocks.LimaCmdCreator, + ncsd *mocks.NerdctlCommandSystemDeps, + logger *mocks.Logger, + ctrl *gomock.Controller, + fs afero.Fs, + ) { + getVMStatusC := mocks.NewCommand(ctrl) + lcc.EXPECT().CreateWithoutStdio("ls", "-f", "{{.Status}}", limaInstanceName).Return(getVMStatusC) + getVMStatusC.EXPECT().Output().Return([]byte("Running"), nil) + logger.EXPECT().Debugf("Status of virtual machine: %s", "Running") + ncsd.EXPECT().LookupEnv("AWS_ACCESS_KEY_ID").Return("", false) + ncsd.EXPECT().LookupEnv("AWS_SECRET_ACCESS_KEY").Return("", false) + ncsd.EXPECT().LookupEnv("AWS_SESSION_TOKEN").Return("", false) + ncsd.EXPECT().LookupEnv("COSIGN_PASSWORD").Return("", false) + ncsd.EXPECT().GetWd().Return("C:\\workdir", nil) + ncsd.EXPECT().FilePathAbs("C:\\workdir").Return("C:\\workdir", nil).Times(2) + ncsd.EXPECT().FilePathJoin(string(filepath.Separator), "mnt", "c", "workdir").Return(augmentedPath).Times(2) + ncsd.EXPECT().FilePathToSlash(augmentedPath).Return(wslPath).Times(2) + + c := mocks.NewCommand(ctrl) + // alias substitution, run => container run + lcc.EXPECT().Create("shell", "--workdir", wslPath, limaInstanceName, + "sudo", "-E", nerdctlCmdName, "container", "run", + ContainsStr("source=/mnt/c/workdir"), "alpine:latest").Return(c) + c.EXPECT().Run() + }, + }, + { + name: "mount type is bind and source is not a windows directory", + cmdName: "run", + args: []string{"--mount", "type=bind,source=something,target=/app", "alpine:latest"}, + wantErr: nil, + mockSvc: func( + t *testing.T, + ecc *mocks.CommandCreator, + lcc *mocks.LimaCmdCreator, + ncsd *mocks.NerdctlCommandSystemDeps, + logger *mocks.Logger, + ctrl *gomock.Controller, + fs afero.Fs, + ) { + getVMStatusC := mocks.NewCommand(ctrl) + lcc.EXPECT().CreateWithoutStdio("ls", "-f", "{{.Status}}", limaInstanceName).Return(getVMStatusC) + getVMStatusC.EXPECT().Output().Return([]byte("Running"), nil) + logger.EXPECT().Debugf("Status of virtual machine: %s", "Running") + ncsd.EXPECT().LookupEnv("AWS_ACCESS_KEY_ID").Return("", false) + ncsd.EXPECT().LookupEnv("AWS_SECRET_ACCESS_KEY").Return("", false) + ncsd.EXPECT().LookupEnv("AWS_SESSION_TOKEN").Return("", false) + ncsd.EXPECT().LookupEnv("COSIGN_PASSWORD").Return("", false) + ncsd.EXPECT().GetWd().Return("C:\\workdir", nil) + ncsd.EXPECT().FilePathAbs("C:\\workdir").Return("C:\\workdir", nil).Times(1) + ncsd.EXPECT().FilePathJoin(string(filepath.Separator), "mnt", "c", "workdir").Return(augmentedPath).Times(1) + ncsd.EXPECT().FilePathToSlash(augmentedPath).Return(wslPath).Times(1) + + c := mocks.NewCommand(ctrl) + // alias substitution, run => container run + lcc.EXPECT().Create("shell", "--workdir", wslPath, limaInstanceName, + "sudo", "-E", nerdctlCmdName, "container", "run", "--mount", + ContainsStr("source=something"), "alpine:latest").Return(c) + c.EXPECT().Run() + }, + }, + { + name: "mount type is not bind", + cmdName: "run", + args: []string{"--mount", "type=notbind,source=C:/workdir,target=/app", "alpine:latest"}, + wantErr: nil, + mockSvc: func( + t *testing.T, + ecc *mocks.CommandCreator, + lcc *mocks.LimaCmdCreator, + ncsd *mocks.NerdctlCommandSystemDeps, + logger *mocks.Logger, + ctrl *gomock.Controller, + fs afero.Fs, + ) { + getVMStatusC := mocks.NewCommand(ctrl) + lcc.EXPECT().CreateWithoutStdio("ls", "-f", "{{.Status}}", limaInstanceName).Return(getVMStatusC) + getVMStatusC.EXPECT().Output().Return([]byte("Running"), nil) + logger.EXPECT().Debugf("Status of virtual machine: %s", "Running") + ncsd.EXPECT().LookupEnv("AWS_ACCESS_KEY_ID").Return("", false) + ncsd.EXPECT().LookupEnv("AWS_SECRET_ACCESS_KEY").Return("", false) + ncsd.EXPECT().LookupEnv("AWS_SESSION_TOKEN").Return("", false) + ncsd.EXPECT().LookupEnv("COSIGN_PASSWORD").Return("", false) + ncsd.EXPECT().GetWd().Return("C:\\workdir", nil) + ncsd.EXPECT().FilePathAbs("C:\\workdir").Return("C:\\workdir", nil).Times(1) + ncsd.EXPECT().FilePathJoin(string(filepath.Separator), "mnt", "c", "workdir").Return(augmentedPath).Times(1) + ncsd.EXPECT().FilePathToSlash(augmentedPath).Return(wslPath).Times(1) + + c := mocks.NewCommand(ctrl) + // alias substitution, run => container run + lcc.EXPECT().Create("shell", "--workdir", wslPath, limaInstanceName, + "sudo", "-E", nerdctlCmdName, "container", "run", "--mount", + "type=notbind,source=C:/workdir,target=/app", "alpine:latest").Return(c) + c.EXPECT().Run() + }, + }, + } + for _, tc := range testCases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + ecc := mocks.NewCommandCreator(ctrl) + lcc := mocks.NewLimaCmdCreator(ctrl) + ncsd := mocks.NewNerdctlCommandSystemDeps(ctrl) + logger := mocks.NewLogger(ctrl) + fs := afero.NewMemMapFs() + tc.mockSvc(t, ecc, lcc, ncsd, logger, ctrl, fs) + assert.Equal(t, tc.wantErr, newNerdctlCommand(lcc, ecc, ncsd, logger, fs, &config.Finch{}).run(tc.cmdName, tc.args)) + }) + } +} + +func TestNerdctlCommand_run_CpCommand(t *testing.T) { + t.Parallel() + var ( + hostcopyPath = filepath.Join(string(filepath.Separator), "mnt", "c", "workdir", "test") + wslcopyPath = filepath.ToSlash(hostcopyPath) + ) + + testCases := []struct { + name string + cmdName string + args []string + mockSvc func(*mocks.CommandCreator, *mocks.LimaCmdCreator, *mocks.Logger, *gomock.Controller, *mocks.NerdctlCommandSystemDeps) + }{ + { + name: "copy into container", + cmdName: "cp", + args: []string{"C:\\workdir\\test", "somecontainer:/tmp"}, + mockSvc: func(ecc *mocks.CommandCreator, lcc *mocks.LimaCmdCreator, logger *mocks.Logger, + ctrl *gomock.Controller, ncsd *mocks.NerdctlCommandSystemDeps, + ) { + getVMStatusC := mocks.NewCommand(ctrl) + lcc.EXPECT().CreateWithoutStdio("ls", "-f", "{{.Status}}", limaInstanceName).Return(getVMStatusC) + getVMStatusC.EXPECT().Output().Return([]byte("Running"), nil) + logger.EXPECT().Debugf("Status of virtual machine: %s", "Running") + ncsd.EXPECT().LookupEnv("AWS_ACCESS_KEY_ID").Return("", false) + ncsd.EXPECT().LookupEnv("AWS_SECRET_ACCESS_KEY").Return("", false) + ncsd.EXPECT().LookupEnv("AWS_SESSION_TOKEN").Return("", false) + ncsd.EXPECT().LookupEnv("COSIGN_PASSWORD").Return("", false) + ncsd.EXPECT().GetWd().Return("C:\\workdir", nil) + ncsd.EXPECT().FilePathAbs("C:\\workdir").Return("C:\\workdir", nil) + ncsd.EXPECT().FilePathJoin(string(filepath.Separator), "mnt", "c", "workdir").Return(augmentedPath) + ncsd.EXPECT().FilePathToSlash(augmentedPath).Return(wslPath) + + ncsd.EXPECT().FilePathAbs("C:\\workdir\\test").Return("C:\\workdir\\test", nil) + ncsd.EXPECT().FilePathJoin(string(filepath.Separator), "mnt", "c", "workdir\\test").Return(hostcopyPath) + ncsd.EXPECT().FilePathToSlash(hostcopyPath).Return(wslcopyPath) + c := mocks.NewCommand(ctrl) + lcc.EXPECT().Create("shell", "--workdir", wslPath, limaInstanceName, + "sudo", "-E", nerdctlCmdName, "container", "cp", wslcopyPath, "somecontainer:/tmp").Return(c) + c.EXPECT().Run() + }, + }, + { + name: "copy out of container", + cmdName: "cp", + args: []string{"somecontainer:/tmp/test", "C:\\workdir\\test"}, + mockSvc: func(ecc *mocks.CommandCreator, lcc *mocks.LimaCmdCreator, logger *mocks.Logger, + ctrl *gomock.Controller, ncsd *mocks.NerdctlCommandSystemDeps, + ) { + getVMStatusC := mocks.NewCommand(ctrl) + lcc.EXPECT().CreateWithoutStdio("ls", "-f", "{{.Status}}", limaInstanceName).Return(getVMStatusC) + getVMStatusC.EXPECT().Output().Return([]byte("Running"), nil) + logger.EXPECT().Debugf("Status of virtual machine: %s", "Running") + ncsd.EXPECT().LookupEnv("AWS_ACCESS_KEY_ID").Return("", false) + ncsd.EXPECT().LookupEnv("AWS_SECRET_ACCESS_KEY").Return("", false) + ncsd.EXPECT().LookupEnv("AWS_SESSION_TOKEN").Return("", false) + ncsd.EXPECT().LookupEnv("COSIGN_PASSWORD").Return("", false) + ncsd.EXPECT().GetWd().Return("C:\\workdir", nil) + ncsd.EXPECT().FilePathAbs("C:\\workdir").Return("C:\\workdir", nil) + ncsd.EXPECT().FilePathJoin(string(filepath.Separator), "mnt", "c", "workdir").Return(augmentedPath) + ncsd.EXPECT().FilePathToSlash(augmentedPath).Return(wslPath) + + ncsd.EXPECT().FilePathAbs("C:\\workdir\\test").Return("C:\\workdir\\test", nil) + ncsd.EXPECT().FilePathJoin(string(filepath.Separator), "mnt", "c", "workdir\\test").Return(hostcopyPath) + ncsd.EXPECT().FilePathToSlash(hostcopyPath).Return(wslcopyPath) + c := mocks.NewCommand(ctrl) + lcc.EXPECT().Create("shell", "--workdir", wslPath, limaInstanceName, + "sudo", "-E", nerdctlCmdName, "container", "cp", "somecontainer:/tmp/test", wslcopyPath).Return(c) + c.EXPECT().Run() + }, + }, + { + name: "copy with options", + cmdName: "cp", + args: []string{"-L", "somecontainer:/tmp/test", "C:\\workdir\\test"}, + mockSvc: func(ecc *mocks.CommandCreator, lcc *mocks.LimaCmdCreator, logger *mocks.Logger, + ctrl *gomock.Controller, ncsd *mocks.NerdctlCommandSystemDeps, + ) { + getVMStatusC := mocks.NewCommand(ctrl) + lcc.EXPECT().CreateWithoutStdio("ls", "-f", "{{.Status}}", limaInstanceName).Return(getVMStatusC) + getVMStatusC.EXPECT().Output().Return([]byte("Running"), nil) + logger.EXPECT().Debugf("Status of virtual machine: %s", "Running") + ncsd.EXPECT().LookupEnv("AWS_ACCESS_KEY_ID").Return("", false) + ncsd.EXPECT().LookupEnv("AWS_SECRET_ACCESS_KEY").Return("", false) + ncsd.EXPECT().LookupEnv("AWS_SESSION_TOKEN").Return("", false) + ncsd.EXPECT().LookupEnv("COSIGN_PASSWORD").Return("", false) + ncsd.EXPECT().GetWd().Return("C:\\workdir", nil) + ncsd.EXPECT().FilePathAbs("C:\\workdir").Return("C:\\workdir", nil) + ncsd.EXPECT().FilePathJoin(string(filepath.Separator), "mnt", "c", "workdir").Return(augmentedPath) + ncsd.EXPECT().FilePathToSlash(augmentedPath).Return(wslPath) + + ncsd.EXPECT().FilePathAbs("C:\\workdir\\test").Return("C:\\workdir\\test", nil) + ncsd.EXPECT().FilePathJoin(string(filepath.Separator), "mnt", "c", "workdir\\test").Return(hostcopyPath) + ncsd.EXPECT().FilePathToSlash(hostcopyPath).Return(wslcopyPath) + c := mocks.NewCommand(ctrl) + lcc.EXPECT().Create("shell", "--workdir", wslPath, limaInstanceName, + "sudo", "-E", nerdctlCmdName, "container", "cp", "-L", "somecontainer:/tmp/test", wslcopyPath).Return(c) + c.EXPECT().Run() + }, + }, + } + + for _, tc := range testCases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + ecc := mocks.NewCommandCreator(ctrl) + lcc := mocks.NewLimaCmdCreator(ctrl) + ncsd := mocks.NewNerdctlCommandSystemDeps(ctrl) + logger := mocks.NewLogger(ctrl) + tc.mockSvc(ecc, lcc, logger, ctrl, ncsd) + + assert.NoError(t, newNerdctlCommand(lcc, ecc, ncsd, logger, nil, &config.Finch{}).run(tc.cmdName, tc.args)) + }) + } +} + +func TestNerdctlCommand_run_BuildCommand(t *testing.T) { + t.Parallel() + var ( + buildContext = filepath.Join(string(filepath.Separator), "mnt", "c", "workdir", "buildcontext") + wslBuildContextPath = filepath.ToSlash(buildContext) + secretPath = filepath.Join(string(filepath.Separator), "mnt", "c", "workdir", "secret") + wslSecretPath = filepath.ToSlash(secretPath) + ) + + testCases := []struct { + name string + cmdName string + args []string + mockSvc func(*mocks.CommandCreator, *mocks.LimaCmdCreator, *mocks.Logger, *gomock.Controller, *mocks.NerdctlCommandSystemDeps) + }{ + { + name: "build without options", + cmdName: "build", + args: []string{"C:\\workdir\\buildcontext"}, + mockSvc: func(ecc *mocks.CommandCreator, lcc *mocks.LimaCmdCreator, logger *mocks.Logger, + ctrl *gomock.Controller, ncsd *mocks.NerdctlCommandSystemDeps, + ) { + getVMStatusC := mocks.NewCommand(ctrl) + lcc.EXPECT().CreateWithoutStdio("ls", "-f", "{{.Status}}", limaInstanceName).Return(getVMStatusC) + getVMStatusC.EXPECT().Output().Return([]byte("Running"), nil) + logger.EXPECT().Debugf("Status of virtual machine: %s", "Running") + ncsd.EXPECT().LookupEnv("AWS_ACCESS_KEY_ID").Return("", false) + ncsd.EXPECT().LookupEnv("AWS_SECRET_ACCESS_KEY").Return("", false) + ncsd.EXPECT().LookupEnv("AWS_SESSION_TOKEN").Return("", false) + ncsd.EXPECT().LookupEnv("COSIGN_PASSWORD").Return("", false) + ncsd.EXPECT().GetWd().Return("C:\\workdir", nil) + ncsd.EXPECT().FilePathAbs("C:\\workdir").Return("C:\\workdir", nil) + ncsd.EXPECT().FilePathJoin(string(filepath.Separator), "mnt", "c", "workdir").Return(augmentedPath) + ncsd.EXPECT().FilePathToSlash(augmentedPath).Return(wslPath) + + ncsd.EXPECT().FilePathAbs("C:\\workdir\\buildcontext").Return("C:\\workdir\\buildcontext", nil) + ncsd.EXPECT().FilePathJoin(string(filepath.Separator), "mnt", "c", "workdir\\buildcontext").Return(buildContext) + ncsd.EXPECT().FilePathToSlash(buildContext).Return(wslBuildContextPath) + c := mocks.NewCommand(ctrl) + lcc.EXPECT().Create("shell", "--workdir", wslPath, limaInstanceName, + "sudo", "-E", nerdctlCmdName, "image", "build", wslBuildContextPath).Return(c) + c.EXPECT().Run() + }, + }, + { + name: "build with file option", + cmdName: "build", + args: []string{"-f", "C:\\workdir\\buildcontext", "C:\\workdir\\buildcontext"}, + mockSvc: func(ecc *mocks.CommandCreator, lcc *mocks.LimaCmdCreator, logger *mocks.Logger, + ctrl *gomock.Controller, ncsd *mocks.NerdctlCommandSystemDeps, + ) { + getVMStatusC := mocks.NewCommand(ctrl) + lcc.EXPECT().CreateWithoutStdio("ls", "-f", "{{.Status}}", limaInstanceName).Return(getVMStatusC) + getVMStatusC.EXPECT().Output().Return([]byte("Running"), nil) + logger.EXPECT().Debugf("Status of virtual machine: %s", "Running") + ncsd.EXPECT().LookupEnv("AWS_ACCESS_KEY_ID").Return("", false) + ncsd.EXPECT().LookupEnv("AWS_SECRET_ACCESS_KEY").Return("", false) + ncsd.EXPECT().LookupEnv("AWS_SESSION_TOKEN").Return("", false) + ncsd.EXPECT().LookupEnv("COSIGN_PASSWORD").Return("", false) + ncsd.EXPECT().GetWd().Return("C:\\workdir", nil) + ncsd.EXPECT().FilePathAbs("C:\\workdir").Return("C:\\workdir", nil) + ncsd.EXPECT().FilePathJoin(string(filepath.Separator), "mnt", "c", "workdir").Return(augmentedPath) + ncsd.EXPECT().FilePathToSlash(augmentedPath).Return(wslPath) + + ncsd.EXPECT().FilePathAbs("C:\\workdir\\buildcontext").Return("C:\\workdir\\buildcontext", nil).Times(2) + ncsd.EXPECT().FilePathJoin(string(filepath.Separator), "mnt", "c", "workdir\\buildcontext").Return(buildContext).Times(2) + ncsd.EXPECT().FilePathToSlash(buildContext).Return(wslBuildContextPath).Times(2) + c := mocks.NewCommand(ctrl) + lcc.EXPECT().Create("shell", "--workdir", wslPath, limaInstanceName, + "sudo", "-E", nerdctlCmdName, "image", "build", "-f", wslBuildContextPath, wslBuildContextPath).Return(c) + c.EXPECT().Run() + }, + }, + { + name: "build with secret option", + cmdName: "build", + args: []string{"--secret", "src=C:\\workdir\\secret", "C:\\workdir\\buildcontext"}, + mockSvc: func(ecc *mocks.CommandCreator, lcc *mocks.LimaCmdCreator, logger *mocks.Logger, + ctrl *gomock.Controller, ncsd *mocks.NerdctlCommandSystemDeps, + ) { + getVMStatusC := mocks.NewCommand(ctrl) + lcc.EXPECT().CreateWithoutStdio("ls", "-f", "{{.Status}}", limaInstanceName).Return(getVMStatusC) + getVMStatusC.EXPECT().Output().Return([]byte("Running"), nil) + logger.EXPECT().Debugf("Status of virtual machine: %s", "Running") + ncsd.EXPECT().LookupEnv("AWS_ACCESS_KEY_ID").Return("", false) + ncsd.EXPECT().LookupEnv("AWS_SECRET_ACCESS_KEY").Return("", false) + ncsd.EXPECT().LookupEnv("AWS_SESSION_TOKEN").Return("", false) + ncsd.EXPECT().LookupEnv("COSIGN_PASSWORD").Return("", false) + ncsd.EXPECT().GetWd().Return("C:\\workdir", nil) + ncsd.EXPECT().FilePathAbs("C:\\workdir").Return("C:\\workdir", nil) + ncsd.EXPECT().FilePathJoin(string(filepath.Separator), "mnt", "c", "workdir").Return(augmentedPath) + ncsd.EXPECT().FilePathToSlash(augmentedPath).Return(wslPath) + + ncsd.EXPECT().FilePathAbs("C:\\workdir\\buildcontext").Return("C:\\workdir\\buildcontext", nil) + ncsd.EXPECT().FilePathJoin(string(filepath.Separator), "mnt", "c", "workdir\\buildcontext").Return(buildContext) + ncsd.EXPECT().FilePathToSlash(buildContext).Return(wslBuildContextPath) + + ncsd.EXPECT().FilePathAbs("C:\\workdir\\secret").Return("C:\\workdir\\secret", nil) + ncsd.EXPECT().FilePathJoin(string(filepath.Separator), "mnt", "c", "workdir\\secret").Return(secretPath) + ncsd.EXPECT().FilePathToSlash(secretPath).Return(wslSecretPath) + c := mocks.NewCommand(ctrl) + lcc.EXPECT().Create("shell", "--workdir", wslPath, limaInstanceName, + "sudo", "-E", nerdctlCmdName, "image", "build", "--secret", + fmt.Sprintf("src=%s", wslSecretPath), wslBuildContextPath).Return(c) + c.EXPECT().Run() + }, + }, + } + + for _, tc := range testCases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + ecc := mocks.NewCommandCreator(ctrl) + lcc := mocks.NewLimaCmdCreator(ctrl) + ncsd := mocks.NewNerdctlCommandSystemDeps(ctrl) + logger := mocks.NewLogger(ctrl) + tc.mockSvc(ecc, lcc, logger, ctrl, ncsd) + + assert.NoError(t, newNerdctlCommand(lcc, ecc, ncsd, logger, nil, &config.Finch{}).run(tc.cmdName, tc.args)) + }) + } +} diff --git a/cmd/finch/virtual_machine.go b/cmd/finch/virtual_machine.go index 21eecb2b7..ad2c08091 100644 --- a/cmd/finch/virtual_machine.go +++ b/cmd/finch/virtual_machine.go @@ -9,6 +9,8 @@ import ( "strings" "github.com/runfinch/finch/pkg/disk" + "github.com/runfinch/finch/pkg/fssh" + "github.com/runfinch/finch/pkg/system" "github.com/runfinch/finch/pkg/command" "github.com/runfinch/finch/pkg/config" @@ -42,8 +44,8 @@ func newVirtualMachineCommand( virtualMachineCommand.AddCommand( newStartVMCommand(limaCmdCreator, logger, optionalDepGroups, lca, nca, fs, fp.LimaSSHPrivateKeyPath(), diskManager), - newStopVMCommand(limaCmdCreator, logger), - newRemoveVMCommand(limaCmdCreator, logger), + newStopVMCommand(limaCmdCreator, diskManager, logger), + newRemoveVMCommand(limaCmdCreator, diskManager, logger), newStatusVMCommand(limaCmdCreator, logger, os.Stdout), newInitVMCommand(limaCmdCreator, logger, optionalDepGroups, lca, nca, fp.BaseYamlFilePath(), fs, fp.LimaSSHPrivateKeyPath(), diskManager), @@ -92,3 +94,33 @@ func (p *postVMStartInitAction) run() error { } return p.nca.Apply(fmt.Sprintf("127.0.0.1:%v", portString)) } + +func virtualMachineCommands( + logger flog.Logger, + fp path.Finch, + lcc command.LimaCmdCreator, + ecc *command.ExecCmdCreator, + fs afero.Fs, + fc *config.Finch, + home string, + finchRootPath string, +) *cobra.Command { + return newVirtualMachineCommand( + lcc, + logger, + dependencies(ecc, fc, fp, fs, lcc, logger, fp.FinchDir(finchRootPath)), + config.NewLimaApplier(fc, ecc, fs, fp.LimaOverrideConfigPath(), system.NewStdLib()), + config.NewNerdctlApplier( + fssh.NewDialer(), + fs, + fp.LimaSSHPrivateKeyPath(), + fp.FinchDir(finchRootPath), + home, + fp.LimaInstancePath(), + fc, + ), + fp, + fs, + disk.NewUserDataDiskManager(lcc, ecc, &afero.OsFs{}, fp, finchRootPath, fc, logger), + ) +} diff --git a/cmd/finch/virtual_machine_init.go b/cmd/finch/virtual_machine_init.go index 049d150ca..e1527c0c3 100644 --- a/cmd/finch/virtual_machine_init.go +++ b/cmd/finch/virtual_machine_init.go @@ -86,6 +86,9 @@ func (iva *initVMAction) run() error { return err } + // ignore error, this is to ensure that the disk is only mounted once + _ = iva.diskManager.DetachUserDataDisk() + err = iva.diskManager.EnsureUserDataDisk() if err != nil { return err @@ -93,10 +96,13 @@ func (iva *initVMAction) run() error { instanceName := fmt.Sprintf("--name=%v", limaInstanceName) limaCmd := iva.creator.CreateWithoutStdio("start", instanceName, iva.baseYamlFilePath, "--tty=false") + iva.logger.Info("Initializing and starting Finch virtual machine...") logs, err := limaCmd.CombinedOutput() if err != nil { iva.logger.Errorf("Finch virtual machine failed to start, debug logs:\n%s", logs) + // ignore error, this is to ensure that the disk mount doesn't linger after the VM fails to start + _ = iva.diskManager.DetachUserDataDisk() return err } iva.logger.Info("Finch virtual machine started successfully") diff --git a/cmd/finch/virtual_machine_init_test.go b/cmd/finch/virtual_machine_init_test.go index 38a060ab3..e6eed2234 100644 --- a/cmd/finch/virtual_machine_init_test.go +++ b/cmd/finch/virtual_machine_init_test.go @@ -37,7 +37,7 @@ func TestInitVMAction_runAdapter(t *testing.T) { *mocks.LimaCmdCreator, *mocks.Logger, *mocks.LimaConfigApplier, - *mocks.MockUserDataDiskManager, + *mocks.UserDataDiskManager, *gomock.Controller, ) }{ @@ -62,7 +62,7 @@ func TestInitVMAction_runAdapter(t *testing.T) { lcc *mocks.LimaCmdCreator, logger *mocks.Logger, lca *mocks.LimaConfigApplier, - dm *mocks.MockUserDataDiskManager, + dm *mocks.UserDataDiskManager, ctrl *gomock.Controller, ) { getVMStatusC := mocks.NewCommand(ctrl) @@ -72,6 +72,7 @@ func TestInitVMAction_runAdapter(t *testing.T) { command := mocks.NewCommand(ctrl) lca.EXPECT().Apply(true).Return(nil) + dm.EXPECT().DetachUserDataDisk().Return(nil) dm.EXPECT().EnsureUserDataDisk().Return(nil) lcc.EXPECT().CreateWithoutStdio("start", fmt.Sprintf("--name=%s", limaInstanceName), mockBaseYamlFilePath, "--tty=false").Return(command) @@ -92,7 +93,7 @@ func TestInitVMAction_runAdapter(t *testing.T) { logger := mocks.NewLogger(ctrl) lcc := mocks.NewLimaCmdCreator(ctrl) lca := mocks.NewLimaConfigApplier(ctrl) - dm := mocks.NewMockUserDataDiskManager(ctrl) + dm := mocks.NewUserDataDiskManager(ctrl) groups := tc.groups(ctrl) tc.mockSvc(lcc, logger, lca, dm, ctrl) @@ -113,7 +114,7 @@ func TestInitVMAction_run(t *testing.T) { *mocks.LimaCmdCreator, *mocks.Logger, *mocks.LimaConfigApplier, - *mocks.MockUserDataDiskManager, + *mocks.UserDataDiskManager, *gomock.Controller, ) }{ @@ -127,7 +128,7 @@ func TestInitVMAction_run(t *testing.T) { lcc *mocks.LimaCmdCreator, logger *mocks.Logger, lca *mocks.LimaConfigApplier, - dm *mocks.MockUserDataDiskManager, + dm *mocks.UserDataDiskManager, ctrl *gomock.Controller, ) { getVMStatusC := mocks.NewCommand(ctrl) @@ -136,6 +137,7 @@ func TestInitVMAction_run(t *testing.T) { logger.EXPECT().Debugf("Status of virtual machine: %s", "") lca.EXPECT().Apply(true).Return(nil) + dm.EXPECT().DetachUserDataDisk().Return(nil) dm.EXPECT().EnsureUserDataDisk().Return(nil) command := mocks.NewCommand(ctrl) @@ -157,7 +159,7 @@ func TestInitVMAction_run(t *testing.T) { lcc *mocks.LimaCmdCreator, logger *mocks.Logger, lca *mocks.LimaConfigApplier, - dm *mocks.MockUserDataDiskManager, + dm *mocks.UserDataDiskManager, ctrl *gomock.Controller, ) { getVMStatusC := mocks.NewCommand(ctrl) @@ -178,7 +180,7 @@ func TestInitVMAction_run(t *testing.T) { lcc *mocks.LimaCmdCreator, logger *mocks.Logger, lca *mocks.LimaConfigApplier, - dm *mocks.MockUserDataDiskManager, + dm *mocks.UserDataDiskManager, ctrl *gomock.Controller, ) { getVMStatusC := mocks.NewCommand(ctrl) @@ -197,7 +199,7 @@ func TestInitVMAction_run(t *testing.T) { lcc *mocks.LimaCmdCreator, logger *mocks.Logger, lca *mocks.LimaConfigApplier, - dm *mocks.MockUserDataDiskManager, + dm *mocks.UserDataDiskManager, ctrl *gomock.Controller, ) { getVMStatusC := mocks.NewCommand(ctrl) @@ -216,7 +218,7 @@ func TestInitVMAction_run(t *testing.T) { lcc *mocks.LimaCmdCreator, logger *mocks.Logger, lca *mocks.LimaConfigApplier, - dm *mocks.MockUserDataDiskManager, + dm *mocks.UserDataDiskManager, ctrl *gomock.Controller, ) { getVMStatusC := mocks.NewCommand(ctrl) @@ -245,7 +247,7 @@ func TestInitVMAction_run(t *testing.T) { lcc *mocks.LimaCmdCreator, logger *mocks.Logger, lca *mocks.LimaConfigApplier, - dm *mocks.MockUserDataDiskManager, + dm *mocks.UserDataDiskManager, ctrl *gomock.Controller, ) { getVMStatusC := mocks.NewCommand(ctrl) @@ -271,7 +273,7 @@ func TestInitVMAction_run(t *testing.T) { lcc *mocks.LimaCmdCreator, logger *mocks.Logger, lca *mocks.LimaConfigApplier, - dm *mocks.MockUserDataDiskManager, + dm *mocks.UserDataDiskManager, ctrl *gomock.Controller, ) { getVMStatusC := mocks.NewCommand(ctrl) @@ -280,6 +282,7 @@ func TestInitVMAction_run(t *testing.T) { logger.EXPECT().Debugf("Status of virtual machine: %s", "") lca.EXPECT().Apply(true).Return(nil) + dm.EXPECT().DetachUserDataDisk().Return(nil) dm.EXPECT().EnsureUserDataDisk().Return(nil) logs := []byte("stdout + stderr") @@ -290,6 +293,7 @@ func TestInitVMAction_run(t *testing.T) { logger.EXPECT().Info("Initializing and starting Finch virtual machine...") logger.EXPECT().Errorf("Finch virtual machine failed to start, debug logs:\n%s", logs) + dm.EXPECT().DetachUserDataDisk().Return(nil) }, }, } @@ -303,7 +307,7 @@ func TestInitVMAction_run(t *testing.T) { logger := mocks.NewLogger(ctrl) lcc := mocks.NewLimaCmdCreator(ctrl) lca := mocks.NewLimaConfigApplier(ctrl) - dm := mocks.NewMockUserDataDiskManager(ctrl) + dm := mocks.NewUserDataDiskManager(ctrl) groups := tc.groups(ctrl) tc.mockSvc(lcc, logger, lca, dm, ctrl) diff --git a/cmd/finch/virtual_machine_remove.go b/cmd/finch/virtual_machine_remove.go index 914a717be..38a43df41 100644 --- a/cmd/finch/virtual_machine_remove.go +++ b/cmd/finch/virtual_machine_remove.go @@ -9,16 +9,17 @@ import ( "github.com/spf13/cobra" "github.com/runfinch/finch/pkg/command" + "github.com/runfinch/finch/pkg/disk" "github.com/runfinch/finch/pkg/lima" "github.com/runfinch/finch/pkg/flog" ) -func newRemoveVMCommand(limaCmdCreator command.LimaCmdCreator, logger flog.Logger) *cobra.Command { +func newRemoveVMCommand(limaCmdCreator command.LimaCmdCreator, diskManager disk.UserDataDiskManager, logger flog.Logger) *cobra.Command { removeVMCommand := &cobra.Command{ Use: "remove", Short: "Remove the virtual machine instance", - RunE: newRemoveVMAction(limaCmdCreator, logger).runAdapter, + RunE: newRemoveVMAction(limaCmdCreator, diskManager, logger).runAdapter, } removeVMCommand.Flags().BoolP("force", "f", false, "forcibly remove finch VM") @@ -27,12 +28,13 @@ func newRemoveVMCommand(limaCmdCreator command.LimaCmdCreator, logger flog.Logge } type removeVMAction struct { - creator command.LimaCmdCreator - logger flog.Logger + creator command.LimaCmdCreator + logger flog.Logger + diskManager disk.UserDataDiskManager } -func newRemoveVMAction(creator command.LimaCmdCreator, logger flog.Logger) *removeVMAction { - return &removeVMAction{creator: creator, logger: logger} +func newRemoveVMAction(creator command.LimaCmdCreator, diskManager disk.UserDataDiskManager, logger flog.Logger) *removeVMAction { + return &removeVMAction{creator: creator, logger: logger, diskManager: diskManager} } func (rva *removeVMAction) runAdapter(cmd *cobra.Command, _ []string) error { @@ -73,6 +75,7 @@ func (rva *removeVMAction) assertVMIsStopped(creator command.LimaCmdCreator, log } func (rva *removeVMAction) removeVM(force bool) error { + _ = rva.diskManager.DetachUserDataDisk() limaCmd := rva.createVMRemoveCommand(force) if force { rva.logger.Info("Forcibly removing Finch virtual machine...") diff --git a/cmd/finch/virtual_machine_remove_test.go b/cmd/finch/virtual_machine_remove_test.go index 8b43bd5f2..49b509fc8 100644 --- a/cmd/finch/virtual_machine_remove_test.go +++ b/cmd/finch/virtual_machine_remove_test.go @@ -17,7 +17,7 @@ import ( func TestNewRemoveVMCommand(t *testing.T) { t.Parallel() - cmd := newRemoveVMCommand(nil, nil) + cmd := newRemoveVMCommand(nil, nil, nil) assert.Equal(t, cmd.Name(), "remove") } @@ -26,18 +26,18 @@ func TestRemoveVMAction_runAdapter(t *testing.T) { testCases := []struct { name string - mockSvc func(*mocks.Logger, *mocks.LimaCmdCreator, *gomock.Controller) + mockSvc func(logger *mocks.Logger, creator *mocks.LimaCmdCreator, dm *mocks.UserDataDiskManager, ctrl *gomock.Controller) args []string }{ { name: "should remove the instance", args: []string{}, - mockSvc: func(logger *mocks.Logger, creator *mocks.LimaCmdCreator, ctrl *gomock.Controller) { + mockSvc: func(logger *mocks.Logger, creator *mocks.LimaCmdCreator, dm *mocks.UserDataDiskManager, ctrl *gomock.Controller) { getVMStatusC := mocks.NewCommand(ctrl) creator.EXPECT().CreateWithoutStdio("ls", "-f", "{{.Status}}", limaInstanceName).Return(getVMStatusC) getVMStatusC.EXPECT().Output().Return([]byte("Stopped"), nil) logger.EXPECT().Debugf("Status of virtual machine: %s", "Stopped") - + dm.EXPECT().DetachUserDataDisk().Return(nil) command := mocks.NewCommand(ctrl) creator.EXPECT().CreateWithoutStdio("remove", limaInstanceName).Return(command) command.EXPECT().CombinedOutput() @@ -49,8 +49,9 @@ func TestRemoveVMAction_runAdapter(t *testing.T) { args: []string{ "--force", }, - mockSvc: func(logger *mocks.Logger, creator *mocks.LimaCmdCreator, ctrl *gomock.Controller) { + mockSvc: func(logger *mocks.Logger, creator *mocks.LimaCmdCreator, dm *mocks.UserDataDiskManager, ctrl *gomock.Controller) { command := mocks.NewCommand(ctrl) + dm.EXPECT().DetachUserDataDisk().Return(nil) creator.EXPECT().CreateWithoutStdio("remove", "--force", limaInstanceName).Return(command) command.EXPECT().CombinedOutput() logger.EXPECT().Info(gomock.Any()).AnyTimes() @@ -64,11 +65,12 @@ func TestRemoveVMAction_runAdapter(t *testing.T) { t.Parallel() ctrl := gomock.NewController(t) + dm := mocks.NewUserDataDiskManager(ctrl) logger := mocks.NewLogger(ctrl) lcc := mocks.NewLimaCmdCreator(ctrl) - tc.mockSvc(logger, lcc, ctrl) + tc.mockSvc(logger, lcc, dm, ctrl) - cmd := newRemoveVMCommand(lcc, logger) + cmd := newRemoveVMCommand(lcc, dm, logger) cmd.SetArgs(tc.args) assert.NoError(t, cmd.Execute()) }) @@ -81,17 +83,18 @@ func TestRemoveVMAction_run(t *testing.T) { testCases := []struct { name string wantErr error - mockSvc func(*mocks.Logger, *mocks.LimaCmdCreator, *gomock.Controller) + mockSvc func(*mocks.Logger, *mocks.LimaCmdCreator, *mocks.UserDataDiskManager, *gomock.Controller) force bool }{ { name: "should remove the instance", wantErr: nil, - mockSvc: func(logger *mocks.Logger, creator *mocks.LimaCmdCreator, ctrl *gomock.Controller) { + mockSvc: func(logger *mocks.Logger, creator *mocks.LimaCmdCreator, dm *mocks.UserDataDiskManager, ctrl *gomock.Controller) { getVMStatusC := mocks.NewCommand(ctrl) creator.EXPECT().CreateWithoutStdio("ls", "-f", "{{.Status}}", limaInstanceName).Return(getVMStatusC) getVMStatusC.EXPECT().Output().Return([]byte("Stopped"), nil) logger.EXPECT().Debugf("Status of virtual machine: %s", "Stopped") + dm.EXPECT().DetachUserDataDisk().Return(nil) command := mocks.NewCommand(ctrl) creator.EXPECT().CreateWithoutStdio("remove", limaInstanceName).Return(command) @@ -105,7 +108,7 @@ func TestRemoveVMAction_run(t *testing.T) { name: "running VM", wantErr: fmt.Errorf("the instance %q is running, run `finch %s stop` to stop the instance first", limaInstanceName, virtualMachineRootCmd), - mockSvc: func(logger *mocks.Logger, creator *mocks.LimaCmdCreator, ctrl *gomock.Controller) { + mockSvc: func(logger *mocks.Logger, creator *mocks.LimaCmdCreator, dm *mocks.UserDataDiskManager, ctrl *gomock.Controller) { getVMStatusC := mocks.NewCommand(ctrl) creator.EXPECT().CreateWithoutStdio("ls", "-f", "{{.Status}}", limaInstanceName).Return(getVMStatusC) getVMStatusC.EXPECT().Output().Return([]byte("Running"), nil) @@ -116,7 +119,7 @@ func TestRemoveVMAction_run(t *testing.T) { { name: "nonexistent VM", wantErr: fmt.Errorf("the instance %q does not exist", limaInstanceName), - mockSvc: func(logger *mocks.Logger, creator *mocks.LimaCmdCreator, ctrl *gomock.Controller) { + mockSvc: func(logger *mocks.Logger, creator *mocks.LimaCmdCreator, dm *mocks.UserDataDiskManager, ctrl *gomock.Controller) { getVMStatusC := mocks.NewCommand(ctrl) creator.EXPECT().CreateWithoutStdio("ls", "-f", "{{.Status}}", limaInstanceName).Return(getVMStatusC) getVMStatusC.EXPECT().Output().Return([]byte(""), nil) @@ -127,7 +130,7 @@ func TestRemoveVMAction_run(t *testing.T) { { name: "unknown VM status", wantErr: errors.New("unrecognized system status"), - mockSvc: func(logger *mocks.Logger, creator *mocks.LimaCmdCreator, ctrl *gomock.Controller) { + mockSvc: func(logger *mocks.Logger, creator *mocks.LimaCmdCreator, dm *mocks.UserDataDiskManager, ctrl *gomock.Controller) { getVMStatusC := mocks.NewCommand(ctrl) creator.EXPECT().CreateWithoutStdio("ls", "-f", "{{.Status}}", limaInstanceName).Return(getVMStatusC) getVMStatusC.EXPECT().Output().Return([]byte("Broken"), nil) @@ -138,7 +141,7 @@ func TestRemoveVMAction_run(t *testing.T) { { name: "status command returns an error", wantErr: errors.New("get status error"), - mockSvc: func(logger *mocks.Logger, creator *mocks.LimaCmdCreator, ctrl *gomock.Controller) { + mockSvc: func(logger *mocks.Logger, creator *mocks.LimaCmdCreator, dm *mocks.UserDataDiskManager, ctrl *gomock.Controller) { getVMStatusC := mocks.NewCommand(ctrl) creator.EXPECT().CreateWithoutStdio("ls", "-f", "{{.Status}}", limaInstanceName).Return(getVMStatusC) getVMStatusC.EXPECT().Output().Return([]byte("Broken"), errors.New("get status error")) @@ -148,11 +151,12 @@ func TestRemoveVMAction_run(t *testing.T) { { name: "should print error if virtual machine failed to remove", wantErr: errors.New("failed to remove instance"), - mockSvc: func(logger *mocks.Logger, creator *mocks.LimaCmdCreator, ctrl *gomock.Controller) { + mockSvc: func(logger *mocks.Logger, creator *mocks.LimaCmdCreator, dm *mocks.UserDataDiskManager, ctrl *gomock.Controller) { getVMStatusC := mocks.NewCommand(ctrl) creator.EXPECT().CreateWithoutStdio("ls", "-f", "{{.Status}}", limaInstanceName).Return(getVMStatusC) getVMStatusC.EXPECT().Output().Return([]byte("Stopped"), nil) logger.EXPECT().Debugf("Status of virtual machine: %s", "Stopped") + dm.EXPECT().DetachUserDataDisk().Return(nil) logs := []byte("stdout + stderr") command := mocks.NewCommand(ctrl) @@ -166,10 +170,11 @@ func TestRemoveVMAction_run(t *testing.T) { { name: "should forcibly remove the instance", wantErr: nil, - mockSvc: func(logger *mocks.Logger, creator *mocks.LimaCmdCreator, ctrl *gomock.Controller) { + mockSvc: func(logger *mocks.Logger, creator *mocks.LimaCmdCreator, dm *mocks.UserDataDiskManager, ctrl *gomock.Controller) { command := mocks.NewCommand(ctrl) creator.EXPECT().CreateWithoutStdio("remove", "--force", limaInstanceName).Return(command) command.EXPECT().CombinedOutput() + dm.EXPECT().DetachUserDataDisk().Return(nil) logger.EXPECT().Info("Forcibly removing Finch virtual machine...") logger.EXPECT().Info("Finch virtual machine removed successfully") }, @@ -183,11 +188,12 @@ func TestRemoveVMAction_run(t *testing.T) { t.Parallel() ctrl := gomock.NewController(t) + dm := mocks.NewUserDataDiskManager(ctrl) logger := mocks.NewLogger(ctrl) lcc := mocks.NewLimaCmdCreator(ctrl) - tc.mockSvc(logger, lcc, ctrl) - err := newRemoveVMAction(lcc, logger).run(tc.force) + tc.mockSvc(logger, lcc, dm, ctrl) + err := newRemoveVMAction(lcc, dm, logger).run(tc.force) assert.Equal(t, tc.wantErr, err) }) } diff --git a/cmd/finch/virtual_machine_start.go b/cmd/finch/virtual_machine_start.go index 6778e60d5..1e3be6a6b 100644 --- a/cmd/finch/virtual_machine_start.go +++ b/cmd/finch/virtual_machine_start.go @@ -79,6 +79,7 @@ func (sva *startVMAction) run() error { return err } + // TODO: don't run this on Windows err = sva.userDataDiskManager.EnsureUserDataDisk() if err != nil { return err diff --git a/cmd/finch/virtual_machine_start_test.go b/cmd/finch/virtual_machine_start_test.go index 7bdb39691..19ce2b245 100644 --- a/cmd/finch/virtual_machine_start_test.go +++ b/cmd/finch/virtual_machine_start_test.go @@ -36,7 +36,7 @@ func TestStartVMAction_runAdapter(t *testing.T) { *mocks.LimaCmdCreator, *mocks.Logger, *mocks.LimaConfigApplier, - *mocks.MockUserDataDiskManager, + *mocks.UserDataDiskManager, *gomock.Controller, ) }{ @@ -62,7 +62,7 @@ func TestStartVMAction_runAdapter(t *testing.T) { lcc *mocks.LimaCmdCreator, logger *mocks.Logger, lca *mocks.LimaConfigApplier, - dm *mocks.MockUserDataDiskManager, + dm *mocks.UserDataDiskManager, ctrl *gomock.Controller, ) { getVMStatusC := mocks.NewCommand(ctrl) @@ -93,7 +93,7 @@ func TestStartVMAction_runAdapter(t *testing.T) { logger := mocks.NewLogger(ctrl) lcc := mocks.NewLimaCmdCreator(ctrl) lca := mocks.NewLimaConfigApplier(ctrl) - dm := mocks.NewMockUserDataDiskManager(ctrl) + dm := mocks.NewUserDataDiskManager(ctrl) groups := tc.groups(ctrl) tc.mockSvc(lcc, logger, lca, dm, ctrl) @@ -115,7 +115,7 @@ func TestStartVMAction_run(t *testing.T) { *mocks.LimaCmdCreator, *mocks.Logger, *mocks.LimaConfigApplier, - *mocks.MockUserDataDiskManager, + *mocks.UserDataDiskManager, *gomock.Controller, ) }{ @@ -137,7 +137,7 @@ func TestStartVMAction_run(t *testing.T) { lcc *mocks.LimaCmdCreator, logger *mocks.Logger, lca *mocks.LimaConfigApplier, - dm *mocks.MockUserDataDiskManager, + dm *mocks.UserDataDiskManager, ctrl *gomock.Controller, ) { getVMStatusC := mocks.NewCommand(ctrl) @@ -167,7 +167,7 @@ func TestStartVMAction_run(t *testing.T) { lcc *mocks.LimaCmdCreator, logger *mocks.Logger, lca *mocks.LimaConfigApplier, - dm *mocks.MockUserDataDiskManager, + dm *mocks.UserDataDiskManager, ctrl *gomock.Controller, ) { getVMStatusC := mocks.NewCommand(ctrl) @@ -187,7 +187,7 @@ func TestStartVMAction_run(t *testing.T) { lcc *mocks.LimaCmdCreator, logger *mocks.Logger, lca *mocks.LimaConfigApplier, - dm *mocks.MockUserDataDiskManager, + dm *mocks.UserDataDiskManager, ctrl *gomock.Controller, ) { getVMStatusC := mocks.NewCommand(ctrl) @@ -206,7 +206,7 @@ func TestStartVMAction_run(t *testing.T) { lcc *mocks.LimaCmdCreator, logger *mocks.Logger, lca *mocks.LimaConfigApplier, - dm *mocks.MockUserDataDiskManager, + dm *mocks.UserDataDiskManager, ctrl *gomock.Controller, ) { getVMStatusC := mocks.NewCommand(ctrl) @@ -225,7 +225,7 @@ func TestStartVMAction_run(t *testing.T) { lcc *mocks.LimaCmdCreator, logger *mocks.Logger, lca *mocks.LimaConfigApplier, - dm *mocks.MockUserDataDiskManager, + dm *mocks.UserDataDiskManager, ctrl *gomock.Controller, ) { getVMStatusC := mocks.NewCommand(ctrl) @@ -254,7 +254,7 @@ func TestStartVMAction_run(t *testing.T) { lcc *mocks.LimaCmdCreator, logger *mocks.Logger, lca *mocks.LimaConfigApplier, - dm *mocks.MockUserDataDiskManager, + dm *mocks.UserDataDiskManager, ctrl *gomock.Controller, ) { getVMStatusC := mocks.NewCommand(ctrl) @@ -287,7 +287,7 @@ func TestStartVMAction_run(t *testing.T) { lcc *mocks.LimaCmdCreator, logger *mocks.Logger, lca *mocks.LimaConfigApplier, - dm *mocks.MockUserDataDiskManager, + dm *mocks.UserDataDiskManager, ctrl *gomock.Controller, ) { getVMStatusC := mocks.NewCommand(ctrl) @@ -319,7 +319,7 @@ func TestStartVMAction_run(t *testing.T) { logger := mocks.NewLogger(ctrl) lcc := mocks.NewLimaCmdCreator(ctrl) lca := mocks.NewLimaConfigApplier(ctrl) - dm := mocks.NewMockUserDataDiskManager(ctrl) + dm := mocks.NewUserDataDiskManager(ctrl) groups := tc.groups(ctrl) tc.mockSvc(lcc, logger, lca, dm, ctrl) diff --git a/cmd/finch/virtual_machine_stop.go b/cmd/finch/virtual_machine_stop.go index 0e7de97e1..5911dc16c 100644 --- a/cmd/finch/virtual_machine_stop.go +++ b/cmd/finch/virtual_machine_stop.go @@ -7,17 +7,18 @@ import ( "fmt" "github.com/runfinch/finch/pkg/command" + "github.com/runfinch/finch/pkg/disk" "github.com/runfinch/finch/pkg/flog" "github.com/runfinch/finch/pkg/lima" "github.com/spf13/cobra" ) -func newStopVMCommand(limaCmdCreator command.LimaCmdCreator, logger flog.Logger) *cobra.Command { +func newStopVMCommand(limaCmdCreator command.LimaCmdCreator, diskManager disk.UserDataDiskManager, logger flog.Logger) *cobra.Command { stopVMCommand := &cobra.Command{ Use: "stop", Short: "Stop the virtual machine", - RunE: newStopVMAction(limaCmdCreator, logger).runAdapter, + RunE: newStopVMAction(limaCmdCreator, diskManager, logger).runAdapter, } stopVMCommand.Flags().BoolP("force", "f", false, "forcibly stop finch VM") @@ -26,12 +27,13 @@ func newStopVMCommand(limaCmdCreator command.LimaCmdCreator, logger flog.Logger) } type stopVMAction struct { - creator command.LimaCmdCreator - logger flog.Logger + creator command.LimaCmdCreator + diskManager disk.UserDataDiskManager + logger flog.Logger } -func newStopVMAction(creator command.LimaCmdCreator, logger flog.Logger) *stopVMAction { - return &stopVMAction{creator: creator, logger: logger} +func newStopVMAction(creator command.LimaCmdCreator, diskManager disk.UserDataDiskManager, logger flog.Logger) *stopVMAction { + return &stopVMAction{creator: creator, diskManager: diskManager, logger: logger} } func (sva *stopVMAction) runAdapter(cmd *cobra.Command, _ []string) error { @@ -88,6 +90,10 @@ func (sva *stopVMAction) stopVM(force bool) error { } else { sva.logger.Info("Stopping existing Finch virtual machine...") } + + // ignore error, this is to ensure that the disk mount doesn't linger after the VM stops + _ = sva.diskManager.DetachUserDataDisk() + logs, err := limaCmd.CombinedOutput() if err != nil { sva.logger.Errorf("Finch virtual machine failed to stop, debug logs:\n%s", logs) diff --git a/cmd/finch/virtual_machine_stop_test.go b/cmd/finch/virtual_machine_stop_test.go index e88de7ce0..c8af05f98 100644 --- a/cmd/finch/virtual_machine_stop_test.go +++ b/cmd/finch/virtual_machine_stop_test.go @@ -17,7 +17,7 @@ import ( func TestNewStopVMCommand(t *testing.T) { t.Parallel() - cmd := newStopVMCommand(nil, nil) + cmd := newStopVMCommand(nil, nil, nil) assert.Equal(t, cmd.Name(), "stop") } @@ -26,14 +26,14 @@ func TestStopVMAction_runAdapter(t *testing.T) { testCases := []struct { name string - mockSvc func(*mocks.Logger, *mocks.LimaCmdCreator, *gomock.Controller) + mockSvc func(logger *mocks.Logger, creator *mocks.LimaCmdCreator, ctrl *gomock.Controller, dm *mocks.UserDataDiskManager) args []string wantErr error }{ { name: "should stop the instance", args: []string{}, - mockSvc: func(logger *mocks.Logger, creator *mocks.LimaCmdCreator, ctrl *gomock.Controller) { + mockSvc: func(logger *mocks.Logger, creator *mocks.LimaCmdCreator, ctrl *gomock.Controller, dm *mocks.UserDataDiskManager) { getVMTypeC := mocks.NewCommand(ctrl) creator.EXPECT().CreateWithoutStdio("ls", "-f", "{{.VMType}}", limaInstanceName).Return(getVMTypeC) getVMTypeC.EXPECT().Output().Return([]byte("qemu"), nil) @@ -44,6 +44,8 @@ func TestStopVMAction_runAdapter(t *testing.T) { getVMStatusC.EXPECT().Output().Return([]byte("Running"), nil) logger.EXPECT().Debugf("Status of virtual machine: %s", "Running") + dm.EXPECT().DetachUserDataDisk().Return(nil) + command := mocks.NewCommand(ctrl) creator.EXPECT().CreateWithoutStdio("stop", limaInstanceName).Return(command) command.EXPECT().CombinedOutput() @@ -56,8 +58,9 @@ func TestStopVMAction_runAdapter(t *testing.T) { args: []string{ "--force", }, - mockSvc: func(logger *mocks.Logger, creator *mocks.LimaCmdCreator, ctrl *gomock.Controller) { + mockSvc: func(logger *mocks.Logger, creator *mocks.LimaCmdCreator, ctrl *gomock.Controller, dm *mocks.UserDataDiskManager) { command := mocks.NewCommand(ctrl) + dm.EXPECT().DetachUserDataDisk().Return(nil) creator.EXPECT().CreateWithoutStdio("stop", "--force", limaInstanceName).Return(command) command.EXPECT().CombinedOutput() logger.EXPECT().Info(gomock.Any()).AnyTimes() @@ -67,8 +70,9 @@ func TestStopVMAction_runAdapter(t *testing.T) { { name: "should stop the instance and use --force even when not specified if VMType == vz", args: []string{}, - mockSvc: func(logger *mocks.Logger, creator *mocks.LimaCmdCreator, ctrl *gomock.Controller) { + mockSvc: func(logger *mocks.Logger, creator *mocks.LimaCmdCreator, ctrl *gomock.Controller, dm *mocks.UserDataDiskManager) { getVMTypeC := mocks.NewCommand(ctrl) + dm.EXPECT().DetachUserDataDisk().Return(nil) creator.EXPECT().CreateWithoutStdio("ls", "-f", "{{.VMType}}", limaInstanceName).Return(getVMTypeC) getVMTypeC.EXPECT().Output().Return([]byte("vz"), nil) logger.EXPECT().Debugf("VMType of virtual machine: %s", "vz") @@ -83,7 +87,7 @@ func TestStopVMAction_runAdapter(t *testing.T) { { name: "get VMType returns an error", args: []string{}, - mockSvc: func(logger *mocks.Logger, creator *mocks.LimaCmdCreator, ctrl *gomock.Controller) { + mockSvc: func(logger *mocks.Logger, creator *mocks.LimaCmdCreator, ctrl *gomock.Controller, dm *mocks.UserDataDiskManager) { getVMTypeC := mocks.NewCommand(ctrl) creator.EXPECT().CreateWithoutStdio("ls", "-f", "{{.VMType}}", limaInstanceName).Return(getVMTypeC) getVMTypeC.EXPECT().Output().Return([]byte("unknown"), errors.New("unrecognized VMType")) @@ -98,11 +102,12 @@ func TestStopVMAction_runAdapter(t *testing.T) { t.Parallel() ctrl := gomock.NewController(t) + dm := mocks.NewUserDataDiskManager(ctrl) logger := mocks.NewLogger(ctrl) lcc := mocks.NewLimaCmdCreator(ctrl) - tc.mockSvc(logger, lcc, ctrl) + tc.mockSvc(logger, lcc, ctrl, dm) - cmd := newStopVMCommand(lcc, logger) + cmd := newStopVMCommand(lcc, dm, logger) cmd.SetArgs(tc.args) err := cmd.Execute() assert.Equal(t, tc.wantErr, err) @@ -116,17 +121,18 @@ func TestStopVMAction_run(t *testing.T) { testCases := []struct { name string wantErr error - mockSvc func(*mocks.Logger, *mocks.LimaCmdCreator, *gomock.Controller) + mockSvc func(logger *mocks.Logger, creator *mocks.LimaCmdCreator, ctrl *gomock.Controller, dm *mocks.UserDataDiskManager) force bool }{ { name: "should stop the instance", wantErr: nil, - mockSvc: func(logger *mocks.Logger, creator *mocks.LimaCmdCreator, ctrl *gomock.Controller) { + mockSvc: func(logger *mocks.Logger, creator *mocks.LimaCmdCreator, ctrl *gomock.Controller, dm *mocks.UserDataDiskManager) { getVMStatusC := mocks.NewCommand(ctrl) creator.EXPECT().CreateWithoutStdio("ls", "-f", "{{.Status}}", limaInstanceName).Return(getVMStatusC) getVMStatusC.EXPECT().Output().Return([]byte("Running"), nil) logger.EXPECT().Debugf("Status of virtual machine: %s", "Running") + dm.EXPECT().DetachUserDataDisk().Return(nil) command := mocks.NewCommand(ctrl) creator.EXPECT().CreateWithoutStdio("stop", limaInstanceName).Return(command) @@ -139,7 +145,7 @@ func TestStopVMAction_run(t *testing.T) { { name: "stopped VM", wantErr: fmt.Errorf("the instance %q is already stopped", limaInstanceName), - mockSvc: func(logger *mocks.Logger, creator *mocks.LimaCmdCreator, ctrl *gomock.Controller) { + mockSvc: func(logger *mocks.Logger, creator *mocks.LimaCmdCreator, ctrl *gomock.Controller, dm *mocks.UserDataDiskManager) { getVMStatusC := mocks.NewCommand(ctrl) creator.EXPECT().CreateWithoutStdio("ls", "-f", "{{.Status}}", limaInstanceName).Return(getVMStatusC) getVMStatusC.EXPECT().Output().Return([]byte("Stopped"), nil) @@ -150,7 +156,7 @@ func TestStopVMAction_run(t *testing.T) { { name: "nonexistent VM", wantErr: fmt.Errorf("the instance %q does not exist", limaInstanceName), - mockSvc: func(logger *mocks.Logger, creator *mocks.LimaCmdCreator, ctrl *gomock.Controller) { + mockSvc: func(logger *mocks.Logger, creator *mocks.LimaCmdCreator, ctrl *gomock.Controller, dm *mocks.UserDataDiskManager) { getVMStatusC := mocks.NewCommand(ctrl) creator.EXPECT().CreateWithoutStdio("ls", "-f", "{{.Status}}", limaInstanceName).Return(getVMStatusC) getVMStatusC.EXPECT().Output().Return([]byte(""), nil) @@ -161,7 +167,7 @@ func TestStopVMAction_run(t *testing.T) { { name: "unknown VM status", wantErr: errors.New("unrecognized system status"), - mockSvc: func(logger *mocks.Logger, creator *mocks.LimaCmdCreator, ctrl *gomock.Controller) { + mockSvc: func(logger *mocks.Logger, creator *mocks.LimaCmdCreator, ctrl *gomock.Controller, dm *mocks.UserDataDiskManager) { getVMStatusC := mocks.NewCommand(ctrl) creator.EXPECT().CreateWithoutStdio("ls", "-f", "{{.Status}}", limaInstanceName).Return(getVMStatusC) getVMStatusC.EXPECT().Output().Return([]byte("Broken"), nil) @@ -172,7 +178,7 @@ func TestStopVMAction_run(t *testing.T) { { name: "status command returns an error", wantErr: errors.New("get status error"), - mockSvc: func(logger *mocks.Logger, creator *mocks.LimaCmdCreator, ctrl *gomock.Controller) { + mockSvc: func(logger *mocks.Logger, creator *mocks.LimaCmdCreator, ctrl *gomock.Controller, dm *mocks.UserDataDiskManager) { getVMStatusC := mocks.NewCommand(ctrl) creator.EXPECT().CreateWithoutStdio("ls", "-f", "{{.Status}}", limaInstanceName).Return(getVMStatusC) getVMStatusC.EXPECT().Output().Return([]byte("Broken"), errors.New("get status error")) @@ -182,11 +188,12 @@ func TestStopVMAction_run(t *testing.T) { { name: "should print error if virtual machine failed to stop", wantErr: errors.New("error"), - mockSvc: func(logger *mocks.Logger, creator *mocks.LimaCmdCreator, ctrl *gomock.Controller) { + mockSvc: func(logger *mocks.Logger, creator *mocks.LimaCmdCreator, ctrl *gomock.Controller, dm *mocks.UserDataDiskManager) { getVMStatusC := mocks.NewCommand(ctrl) creator.EXPECT().CreateWithoutStdio("ls", "-f", "{{.Status}}", limaInstanceName).Return(getVMStatusC) getVMStatusC.EXPECT().Output().Return([]byte("Running"), nil) logger.EXPECT().Debugf("Status of virtual machine: %s", "Running") + dm.EXPECT().DetachUserDataDisk().Return(nil) logs := []byte("stdout + stderr") command := mocks.NewCommand(ctrl) @@ -200,10 +207,11 @@ func TestStopVMAction_run(t *testing.T) { { name: "should force stop virtual machine", wantErr: nil, - mockSvc: func(logger *mocks.Logger, creator *mocks.LimaCmdCreator, ctrl *gomock.Controller) { + mockSvc: func(logger *mocks.Logger, creator *mocks.LimaCmdCreator, ctrl *gomock.Controller, dm *mocks.UserDataDiskManager) { command := mocks.NewCommand(ctrl) creator.EXPECT().CreateWithoutStdio("stop", "--force", limaInstanceName).Return(command) command.EXPECT().CombinedOutput() + dm.EXPECT().DetachUserDataDisk().Return(nil) logger.EXPECT().Info("Forcibly stopping Finch virtual machine...") logger.EXPECT().Info("Finch virtual machine stopped successfully") }, @@ -217,11 +225,12 @@ func TestStopVMAction_run(t *testing.T) { t.Parallel() ctrl := gomock.NewController(t) + dm := mocks.NewUserDataDiskManager(ctrl) logger := mocks.NewLogger(ctrl) lcc := mocks.NewLimaCmdCreator(ctrl) - tc.mockSvc(logger, lcc, ctrl) - err := newStopVMAction(lcc, logger).run(tc.force) + tc.mockSvc(logger, lcc, ctrl, dm) + err := newStopVMAction(lcc, dm, logger).run(tc.force) assert.Equal(t, tc.wantErr, err) }) } diff --git a/deps/finch-core b/deps/finch-core index 46b601d19..c74dae36e 160000 --- a/deps/finch-core +++ b/deps/finch-core @@ -1 +1 @@ -Subproject commit 46b601d19300c6026b528d83e2f67faa8d33894d +Subproject commit c74dae36e232c47677a0268cbc498185813df43e diff --git a/e2e/container/container_test.go b/e2e/container/container_test.go index 297ec0113..61dfd6147 100644 --- a/e2e/container/container_test.go +++ b/e2e/container/container_test.go @@ -5,6 +5,9 @@ package container import ( + "os/exec" + "regexp" + "runtime" "testing" "github.com/onsi/ginkgo/v2" @@ -39,7 +42,19 @@ func TestContainer(t *testing.T) { tests.Pull(o) tests.Rm(o) tests.Rmi(o) - tests.Run(&tests.RunOption{BaseOpt: o, CGMode: tests.Unified, DefaultHostGatewayIP: "192.168.5.2"}) + if runtime.GOOS == "windows" { + // get ip address for adapter vEthernet (WSL) + n, err := exec.Command("cmd", "/C", "netsh", "interface", "ipv4", "show", + "addresses", "vEthernet (WSL)").Output() + gomega.Expect(err).Should(gomega.BeNil()) + hostIP := extractIPAddress(string(n)) + // wsl2 cgroup v2 is mounted at /sys/fs/cgroup/unified, + // containerd expects it at /sys/fs/cgroup based on + // https://github.com/containerd/cgroups/blob/cc78c6c1e32dc5bde018d92999910fdace3cfa27/utils.go#L36 + tests.Run(&tests.RunOption{BaseOpt: o, CGMode: tests.Hybrid, DefaultHostGatewayIP: hostIP}) + } else { + tests.Run(&tests.RunOption{BaseOpt: o, CGMode: tests.Unified, DefaultHostGatewayIP: "192.168.5.2"}) + } tests.Start(o) tests.Stop(o) tests.Cp(o) @@ -85,3 +100,13 @@ func TestContainer(t *testing.T) { gomega.RegisterFailHandler(ginkgo.Fail) ginkgo.RunSpecs(t, description) } + +func extractIPAddress(data string) string { + re := regexp.MustCompile(`IP Address:\s+(\d+\.\d+\.\d+\.\d+)`) + match := re.FindStringSubmatch(data) + + if match != nil { + return match[1] + } + return "" +} diff --git a/e2e/e2e.go b/e2e/e2e.go index 9fa225d91..9e5aa28c4 100644 --- a/e2e/e2e.go +++ b/e2e/e2e.go @@ -52,7 +52,7 @@ func CreateOption() (*option.Option, error) { return nil, fmt.Errorf("failed to get the current working directory: %w", err) } - subject := filepath.Join(wd, "../../_output/bin/finch") + subject := filepath.Join(wd, "..", "..", "_output", "bin", "finch") if *Installed { subject = InstalledTestSubject } diff --git a/e2e/vm/additional_disk_test.go b/e2e/vm/additional_disk_test.go index 0af13f266..63fe19334 100644 --- a/e2e/vm/additional_disk_test.go +++ b/e2e/vm/additional_disk_test.go @@ -5,6 +5,7 @@ package vm import ( "fmt" + "time" "github.com/onsi/ginkgo/v2" "github.com/onsi/gomega" @@ -19,23 +20,28 @@ const ( networkName = "test-network" ) -var testAdditionalDisk = func(o *option.Option) { +var testAdditionalDisk = func(o *option.Option, installed bool) { ginkgo.Describe("Additional disk", ginkgo.Serial, func() { ginkgo.It("Retains container user data after the VM is deleted", func() { + resetVM(o, installed) + resetDisks(o, installed) + command.New(o, virtualMachineRootCmd, "init").WithTimeoutInSeconds(600).Run() command.Run(o, "volume", "create", volumeName) ginkgo.DeferCleanup(command.Run, o, "volume", "rm", volumeName) command.Run(o, "network", "create", networkName) ginkgo.DeferCleanup(command.Run, o, "network", "rm", networkName) command.Run(o, "run", "-d", "--name", containerName, "-v", fmt.Sprintf("%s:/tmp", volumeName), - savedImage, "sh", "-c", "sleep infinity") + savedImage, "sleep", "infinity") command.Run(o, "exec", containerName, "sh", "-c", "echo foo > /tmp/test.txt") ginkgo.DeferCleanup(command.Run, o, "rmi", savedImage) ginkgo.DeferCleanup(command.Run, o, "rm", "-f", containerName) - command.Run(o, "kill", containerName) + command.New(o, "stop", containerName).WithTimeoutInSeconds(30).Run() - command.New(o, virtualMachineRootCmd, "stop").WithoutCheckingExitCode().WithTimeoutInSeconds(90).Run() + time.Sleep(20 * time.Second) + + command.New(o, virtualMachineRootCmd, "stop").WithTimeoutInSeconds(90).Run() command.Run(o, virtualMachineRootCmd, "remove") command.New(o, virtualMachineRootCmd, "init").WithTimeoutInSeconds(240).Run() diff --git a/e2e/vm/config_darwin_test.go b/e2e/vm/config_darwin_test.go new file mode 100644 index 000000000..6b4bdf68e --- /dev/null +++ b/e2e/vm/config_darwin_test.go @@ -0,0 +1,54 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +//go:build darwin + +package vm + +import ( + "os" + "path/filepath" + "runtime" + + "github.com/lima-vm/lima/pkg/limayaml" + "github.com/onsi/ginkgo/v2" + "github.com/onsi/gomega" + "github.com/onsi/gomega/gexec" + "github.com/runfinch/common-tests/command" + "github.com/runfinch/common-tests/option" + "gopkg.in/yaml.v3" + + finch_cmd "github.com/runfinch/finch/pkg/command" + "github.com/runfinch/finch/pkg/config" +) + +var finchConfigFilePath = os.Getenv("HOME") + "/.finch/finch.yaml" + +var testConfig = func(o *option.Option, installed bool) { + ginkgo.Describe("Config (after init)", ginkgo.Serial, func() { + ginkgo.It("updates init-only config values when values are changed after init", func() { + supportsVz, supportsVzErr := config.SupportsVirtualizationFramework(finch_cmd.NewExecCmdCreator()) + gomega.Expect(supportsVzErr).ShouldNot(gomega.HaveOccurred()) + if !supportsVz || runtime.GOOS != "darwin" { + ginkgo.Skip("Skipping because existing init only configuration options require Virtualization.framework support to test") + } + + limaConfigFilePath := resetVM(o, installed) + writeFile(finchConfigFilePath, []byte("memory: 4GiB\ncpus: 6\nvmType: vz\nrosetta: false")) + initCmdSession := command.New(o, virtualMachineRootCmd, "init").WithTimeoutInSeconds(600).Run() + gomega.Expect(initCmdSession).Should(gexec.Exit(0)) + + gomega.Expect(limaConfigFilePath).Should(gomega.BeARegularFile()) + cfgBuf, err := os.ReadFile(filepath.Clean(limaConfigFilePath)) + gomega.Expect(err).ShouldNot(gomega.HaveOccurred()) + + var limaCfg limayaml.LimaYAML + err = yaml.Unmarshal(cfgBuf, &limaCfg) + gomega.Expect(err).ShouldNot(gomega.HaveOccurred()) + gomega.Expect(*limaCfg.CPUs).Should(gomega.Equal(6)) + gomega.Expect(*limaCfg.Memory).Should(gomega.Equal("4GiB")) + gomega.Expect(*limaCfg.VMType).Should(gomega.Equal("vz")) + gomega.Expect(*limaCfg.Rosetta.Enabled).Should(gomega.Equal(false)) + gomega.Expect(*limaCfg.Rosetta.BinFmt).Should(gomega.Equal(false)) + }) + }) +} diff --git a/e2e/vm/config_test.go b/e2e/vm/config_test.go index dad13f6d8..8440a5496 100644 --- a/e2e/vm/config_test.go +++ b/e2e/vm/config_test.go @@ -9,7 +9,6 @@ import ( "os" "os/exec" "path/filepath" - "runtime" "github.com/lima-vm/lima/pkg/limayaml" "github.com/onsi/ginkgo/v2" @@ -21,12 +20,8 @@ import ( "gopkg.in/yaml.v3" "github.com/runfinch/finch/e2e" - finch_cmd "github.com/runfinch/finch/pkg/command" - "github.com/runfinch/finch/pkg/config" ) -var finchConfigFilePath = os.Getenv("HOME") + "/.finch/finch.yaml" - const defaultLimaConfigFilePath = "../../_output/lima/data/_config/override.yaml" func readFile(filePath string) []byte { @@ -62,7 +57,7 @@ func updateAndApplyConfig(o *option.Option, configBytes []byte) *gexec.Session { // empty and a non-existent Finch config.yaml. Meaning, if you run this without an existing config.yaml, // an empty config.yaml will be created after all test cases are run. This currently does not change the behavior // of Finch, but may need to be revisited later. -var testConfig = func(o *option.Option, installed bool) { +var _ = func(o *option.Option, installed bool) { // These tests are run in serial because we only define one virtual machine instance, and it requires disk I/O. ginkgo.Describe("Config", ginkgo.Serial, func() { var limaConfigFilePath string @@ -188,32 +183,4 @@ additional_directories: gomega.Expect(*limaCfg.Rosetta.BinFmt).Should(gomega.Equal(false)) }) }) - - ginkgo.Describe("Config (after init)", ginkgo.Serial, func() { - ginkgo.It("updates init-only config values when values are changed after init", func() { - supportsVz, supportsVzErr := config.SupportsVirtualizationFramework(finch_cmd.NewExecCmdCreator()) - gomega.Expect(supportsVzErr).ShouldNot(gomega.HaveOccurred()) - if !supportsVz || runtime.GOOS != "darwin" { - ginkgo.Skip("Skipping because existing init only configuration options require Virtualization.framework support to test") - } - - limaConfigFilePath := resetVM(o, installed) - writeFile(finchConfigFilePath, []byte("memory: 4GiB\ncpus: 6\nvmType: vz\nrosetta: false")) - initCmdSession := command.New(o, virtualMachineRootCmd, "init").WithTimeoutInSeconds(600).Run() - gomega.Expect(initCmdSession).Should(gexec.Exit(0)) - - gomega.Expect(limaConfigFilePath).Should(gomega.BeARegularFile()) - cfgBuf, err := os.ReadFile(filepath.Clean(limaConfigFilePath)) - gomega.Expect(err).ShouldNot(gomega.HaveOccurred()) - - var limaCfg limayaml.LimaYAML - err = yaml.Unmarshal(cfgBuf, &limaCfg) - gomega.Expect(err).ShouldNot(gomega.HaveOccurred()) - gomega.Expect(*limaCfg.CPUs).Should(gomega.Equal(6)) - gomega.Expect(*limaCfg.Memory).Should(gomega.Equal("4GiB")) - gomega.Expect(*limaCfg.VMType).Should(gomega.Equal("vz")) - gomega.Expect(*limaCfg.Rosetta.Enabled).Should(gomega.Equal(false)) - gomega.Expect(*limaCfg.Rosetta.BinFmt).Should(gomega.Equal(false)) - }) - }) } diff --git a/e2e/vm/config_windows_test.go b/e2e/vm/config_windows_test.go new file mode 100644 index 000000000..fd25a2f54 --- /dev/null +++ b/e2e/vm/config_windows_test.go @@ -0,0 +1,12 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +//go:build windows + +package vm + +import ( + "os" + "path/filepath" +) + +var finchConfigFilePath = filepath.Join(os.Getenv("LOCALAPPDATA"), ".finch", "finch.yaml") diff --git a/e2e/vm/cred_helper_test.go b/e2e/vm/cred_helper_test.go index cf22330e5..ff41105c5 100644 --- a/e2e/vm/cred_helper_test.go +++ b/e2e/vm/cred_helper_test.go @@ -4,6 +4,9 @@ package vm import ( + "fmt" + "runtime" + "github.com/onsi/ginkgo/v2" "github.com/onsi/gomega" "github.com/onsi/gomega/gexec" @@ -13,14 +16,23 @@ import ( var testCredHelper = func(o *option.Option, installed bool, registry string) { ginkgo.Describe("Credential Helper", func() { + var vmType string + + ginkgo.BeforeEach(func() { + if runtime.GOOS == "windows" { + vmType = "wsl2" + } else { + vmType = "vz" + } + }) ginkgo.It("should pull from container registry", func() { - resetVM(o, installed) - resetDisks(o, installed) if registry == "" { ginkgo.Skip("No Provided Container Registry Url") } - writeFile(finchConfigFilePath, []byte("cpus: 6\nmemory: 4GiB\ncreds_helpers:\n "+ - "- ecr-login\nvmType: vz\nrosetta: true")) + resetVM(o, installed) + resetDisks(o, installed) + writeFile(finchConfigFilePath, []byte(fmt.Sprintf("cpus: 6\nmemory: 4GiB\ncreds_helpers:\n "+ + "- ecr-login\nvmType: %s\nrosetta: true", vmType))) initCmdSession := command.New(o, virtualMachineRootCmd, "init").WithTimeoutInSeconds(600).Run() gomega.Expect(initCmdSession).Should(gexec.Exit(0)) command.New(o, "pull", registry).WithTimeoutInSeconds(600).Run() diff --git a/e2e/vm/finch_config_file_test.go b/e2e/vm/finch_config_file_test.go index f1bd02c68..0137c8b8f 100644 --- a/e2e/vm/finch_config_file_test.go +++ b/e2e/vm/finch_config_file_test.go @@ -7,6 +7,7 @@ import ( "fmt" "os" "path/filepath" + "runtime" "time" "github.com/onsi/ginkgo/v2" @@ -44,17 +45,25 @@ var testFinchConfigFile = func(o *option.Option) { "-e", "REGISTRY_AUTH_HTPASSWD_REALM=Registry Realm", "-e", fmt.Sprintf("REGISTRY_AUTH_HTPASSWD_PATH=/auth/%s", filename), registryImage) - ginkgo.DeferCleanup(command.Run, o, "rmi", registryImage) + ginkgo.DeferCleanup(command.Run, o, "rmi", "-f", registryImage) ginkgo.DeferCleanup(command.Run, o, "rm", "-f", registryContainer) for command.StdoutStr(o, "inspect", "-f", "{{.State.Running}}", containerID) != "true" { time.Sleep(1 * time.Second) } + time.Sleep(10 * time.Second) registry := fmt.Sprintf(`localhost:%d`, port) command.Run(o, "login", registry, "-u", "testUser", "-p", "testPassword") - homeDir, err := os.UserHomeDir() - gomega.Expect(err).ShouldNot(gomega.HaveOccurred()) - configPath := fmt.Sprintf("%s/.finch/config.json", homeDir) + var finchRootDir string + var err error + if runtime.GOOS == "windows" { + finchRootDir = os.Getenv("LOCALAPPDATA") + } else { + finchRootDir, err = os.UserHomeDir() + gomega.Expect(err).ShouldNot(gomega.HaveOccurred()) + } + + configPath := filepath.Join(finchRootDir, ".finch", "config.json") configContent, err := os.ReadFile(filepath.Clean(configPath)) gomega.Expect(err).ShouldNot(gomega.HaveOccurred()) diff --git a/e2e/vm/lifecycle_test.go b/e2e/vm/lifecycle_test.go index e5c99c9f6..587f45a3d 100644 --- a/e2e/vm/lifecycle_test.go +++ b/e2e/vm/lifecycle_test.go @@ -25,7 +25,7 @@ var testVMLifecycle = func(o *option.Option) { ginkgo.It("should be able to force stop the virtual machine", func() { command.Run(o, "images") - command.New(o, virtualMachineRootCmd, "stop", "--force").WithTimeoutInSeconds(90).Run() + command.New(o, virtualMachineRootCmd, "stop", "--force").WithTimeoutInSeconds(180).Run() command.RunWithoutSuccessfulExit(o, "images") command.New(o, virtualMachineRootCmd, "start").WithTimeoutInSeconds(240).Run() }) diff --git a/e2e/vm/soci_test.go b/e2e/vm/soci_test.go index 8e8fb18a9..266d25ee5 100644 --- a/e2e/vm/soci_test.go +++ b/e2e/vm/soci_test.go @@ -8,6 +8,7 @@ import ( "os" "os/exec" "path/filepath" + "runtime" "strings" "github.com/onsi/ginkgo/v2" @@ -28,7 +29,7 @@ const ( var testSoci = func(o *option.Option, installed bool) { ginkgo.Describe("SOCI", func() { var limactlO *option.Option - var fpath, realFinchPath, limactlPath, limaHomePathEnv, wd string + var fpath, realFinchPath, limactlPath, limaHomePathEnv, wd, vmType string var err error var port int @@ -52,13 +53,18 @@ var testSoci = func(o *option.Option, installed bool) { limactlO, err = option.New([]string{limactlPath}, option.Env([]string{limaHomePathEnv})) gomega.Expect(err).ShouldNot(gomega.HaveOccurred()) + if runtime.GOOS == "windows" { + vmType = "wsl2" + } else { + vmType = "qemu" + } }) ginkgo.It("finch pull should have same mounts as nerdctl pull with SOCI", func() { resetVM(o, installed) resetDisks(o, installed) - writeFile(finchConfigFilePath, []byte("cpus: 6\nmemory: 4GiB\nsnapshotters:\n "+ - "- soci\nvmType: qemu\nrosetta: false")) + writeFile(finchConfigFilePath, []byte(fmt.Sprintf("cpus: 6\nmemory: 4GiB\nsnapshotters:\n "+ + "- soci\nvmType: %s\nrosetta: false", vmType))) command.New(o, virtualMachineRootCmd, "init").WithTimeoutInSeconds(600).Run() command.New(o, "pull", "--snapshotter=soci", ffmpegSociImage).WithTimeoutInSeconds(30).Run() finchPullMounts := countMounts(limactlO) @@ -73,8 +79,8 @@ var testSoci = func(o *option.Option, installed bool) { ginkgo.It("finch run should have same mounts as nerdctl run with SOCI", func() { resetVM(o, installed) resetDisks(o, installed) - writeFile(finchConfigFilePath, []byte("cpus: 6\nmemory: 4GiB\nsnapshotters:\n "+ - "- soci\nvmType: qemu\nrosetta: false")) + writeFile(finchConfigFilePath, []byte(fmt.Sprintf("cpus: 6\nmemory: 4GiB\nsnapshotters:\n "+ + "- soci\nvmType: %s\nrosetta: false", vmType))) command.New(o, virtualMachineRootCmd, "init").WithTimeoutInSeconds(600).Run() command.New(o, "run", "--snapshotter=soci", ffmpegSociImage).WithTimeoutInSeconds(30).Run() finchPullMounts := countMounts(limactlO) @@ -88,8 +94,8 @@ var testSoci = func(o *option.Option, installed bool) { ginkgo.It("finch push should work", func() { resetVM(o, installed) resetDisks(o, installed) - writeFile(finchConfigFilePath, []byte("cpus: 6\nmemory: 4GiB\nsnapshotters:\n "+ - "- soci\nvmType: qemu\nrosetta: false")) + writeFile(finchConfigFilePath, []byte(fmt.Sprintf("cpus: 6\nmemory: 4GiB\nsnapshotters:\n "+ + "- soci\nvmType: %s\nrosetta: false", vmType))) command.New(o, virtualMachineRootCmd, "init").WithTimeoutInSeconds(600).Run() port = fnet.GetFreePort() command.New(o, "run", "-dp", fmt.Sprintf("%d:5000", port), "--name", "registry", registryImage). diff --git a/e2e/vm/support_bundle_test.go b/e2e/vm/support_bundle_test.go index 390bc9e82..778aeecd5 100644 --- a/e2e/vm/support_bundle_test.go +++ b/e2e/vm/support_bundle_test.go @@ -41,9 +41,10 @@ var testSupportBundle = func(o *option.Option) { ginkgo.It("Should generate a support bundle with an extra file included with --include flag by relative path", func() { includeFilename := fmt.Sprintf("tempTestfile%s", time.Now().Format("20060102150405")) //nolint:gosec // this file is only used for testing purposes and it does not include any user input - _, err := os.Create(includeFilename) + tempFile, err := os.Create(includeFilename) gomega.Expect(err).Should(gomega.BeNil()) defer func() { + gomega.Expect(tempFile.Close()).Should(gomega.BeNil()) err := os.Remove(includeFilename) gomega.Expect(err).Should(gomega.BeNil()) }() @@ -62,11 +63,12 @@ var testSupportBundle = func(o *option.Option) { reader, err := zip.OpenReader(dirEntry.Name()) gomega.Expect(err).Should(gomega.BeNil()) - zipBaseName := path.Base(dirEntry.Name()) - zipPrefix := strings.TrimSuffix(zipBaseName, path.Ext(zipBaseName)) + zipBaseName := filepath.Base(dirEntry.Name()) + zipPrefix := strings.TrimSuffix(zipBaseName, filepath.Ext(zipBaseName)) _, err = reader.Open(path.Join(zipPrefix, "misc", includeFilename)) gomega.Expect(err).Should(gomega.BeNil()) + gomega.Expect(reader.Close()).Should(gomega.BeNil()) err = os.Remove(dirEntry.Name()) gomega.Expect(err).Should(gomega.BeNil()) } @@ -76,16 +78,17 @@ var testSupportBundle = func(o *option.Option) { ginkgo.It("Should generate a support bundle with an extra file included with --include flag by absolute path", func() { includeFilename := fmt.Sprintf("tempTestfile%s", time.Now().Format("20060102150405")) //nolint:gosec // this file is only used for testing purposes and it does not include any user input - _, err := os.Create(includeFilename) + tempFile, err := os.Create(includeFilename) gomega.Expect(err).Should(gomega.BeNil()) defer func() { + gomega.Expect(tempFile.Close()).Should(gomega.BeNil()) err := os.Remove(includeFilename) gomega.Expect(err).Should(gomega.BeNil()) }() dir, err := os.Getwd() gomega.Expect(err).Should(gomega.BeNil()) - includeAbsPath := path.Join(dir, includeFilename) + includeAbsPath := filepath.Join(dir, includeFilename) command.Run(o, "support-bundle", "generate", "--include", includeAbsPath) entries, err := os.ReadDir(".") @@ -101,11 +104,12 @@ var testSupportBundle = func(o *option.Option) { reader, err := zip.OpenReader(dirEntry.Name()) gomega.Expect(err).Should(gomega.BeNil()) - zipBaseName := path.Base(dirEntry.Name()) - zipPrefix := strings.TrimSuffix(zipBaseName, path.Ext(zipBaseName)) + zipBaseName := filepath.Base(dirEntry.Name()) + zipPrefix := strings.TrimSuffix(zipBaseName, filepath.Ext(zipBaseName)) _, err = reader.Open(path.Join(zipPrefix, "misc", includeFilename)) gomega.Expect(err).Should(gomega.BeNil()) + gomega.Expect(reader.Close()).Should(gomega.BeNil()) err = os.Remove(dirEntry.Name()) gomega.Expect(err).Should(gomega.BeNil()) } @@ -128,11 +132,12 @@ var testSupportBundle = func(o *option.Option) { reader, err := zip.OpenReader(dirEntry.Name()) gomega.Expect(err).Should(gomega.BeNil()) - zipBaseName := path.Base(dirEntry.Name()) - zipPrefix := strings.TrimSuffix(zipBaseName, path.Ext(zipBaseName)) + zipBaseName := filepath.Base(dirEntry.Name()) + zipPrefix := strings.TrimSuffix(zipBaseName, filepath.Ext(zipBaseName)) _, err = reader.Open(path.Join(zipPrefix, "misc", fakeFileName)) gomega.Expect(err).ShouldNot(gomega.BeNil()) + gomega.Expect(reader.Close()).Should(gomega.BeNil()) err = os.Remove(dirEntry.Name()) gomega.Expect(err).Should(gomega.BeNil()) } @@ -140,7 +145,7 @@ var testSupportBundle = func(o *option.Option) { gomega.Expect(bundleExists).Should(gomega.BeTrue()) }) ginkgo.It("Should generate a support bundle with a default file excluded with --exclude flag by basename", func() { - command.Run(o, "support-bundle", "generate", "--exclude", "serial.log") + command.Run(o, "support-bundle", "generate", "--exclude", "finch.yaml") entries, err := os.ReadDir(".") gomega.Expect(err).Should(gomega.BeNil()) bundleExists := false @@ -154,11 +159,12 @@ var testSupportBundle = func(o *option.Option) { reader, err := zip.OpenReader(dirEntry.Name()) gomega.Expect(err).Should(gomega.BeNil()) - zipBaseName := path.Base(dirEntry.Name()) + zipBaseName := filepath.Base(dirEntry.Name()) zipPrefix := strings.TrimSuffix(zipBaseName, path.Ext(zipBaseName)) - _, err = reader.Open(path.Join(zipPrefix, "logs", "serial.log")) + _, err = reader.Open(path.Join(zipPrefix, "configs", "finch.yaml")) gomega.Expect(err).ShouldNot(gomega.BeNil()) + gomega.Expect(reader.Close()).Should(gomega.BeNil()) err = os.Remove(dirEntry.Name()) gomega.Expect(err).Should(gomega.BeNil()) } @@ -168,9 +174,10 @@ var testSupportBundle = func(o *option.Option) { ginkgo.It("Should generate a support bundle with a default file excluded with --exclude flag by absolute path", func() { includeFilename := fmt.Sprintf("tempTestfile%s", time.Now().Format("20060102150405")) //nolint:gosec // this file is only used for testing purposes and it does not include any user input - _, err := os.Create(includeFilename) + tempFile, err := os.Create(includeFilename) gomega.Expect(err).Should(gomega.BeNil()) defer func() { + gomega.Expect(tempFile.Close()).Should(gomega.BeNil()) err := os.Remove(includeFilename) gomega.Expect(err).Should(gomega.BeNil()) }() @@ -191,11 +198,12 @@ var testSupportBundle = func(o *option.Option) { reader, err := zip.OpenReader(dirEntry.Name()) gomega.Expect(err).Should(gomega.BeNil()) - zipBaseName := path.Base(dirEntry.Name()) - zipPrefix := strings.TrimSuffix(zipBaseName, path.Ext(zipBaseName)) + zipBaseName := filepath.Base(dirEntry.Name()) + zipPrefix := strings.TrimSuffix(zipBaseName, filepath.Ext(zipBaseName)) _, err = reader.Open(path.Join(zipPrefix, "misc", includeFilename)) gomega.Expect(err).ShouldNot(gomega.BeNil()) + gomega.Expect(reader.Close()).Should(gomega.BeNil()) err = os.Remove(dirEntry.Name()) gomega.Expect(err).Should(gomega.BeNil()) } @@ -205,14 +213,15 @@ var testSupportBundle = func(o *option.Option) { ginkgo.It("Should generate a support bundle with a default file excluded with --exclude flag by relative path", func() { includeFilename := fmt.Sprintf("tempTestfile%s", time.Now().Format("20060102150405")) //nolint:gosec // this file is only used for testing purposes and it does not include any user input - _, err := os.Create(includeFilename) + tempFile, err := os.Create(includeFilename) gomega.Expect(err).Should(gomega.BeNil()) defer func() { + gomega.Expect(tempFile.Close()).Should(gomega.BeNil()) err := os.Remove(includeFilename) gomega.Expect(err).Should(gomega.BeNil()) }() - command.Run(o, "support-bundle", "generate", "--include", includeFilename, "--exclude", path.Join(".", includeFilename)) + command.Run(o, "support-bundle", "generate", "--include", includeFilename, "--exclude", filepath.Join(".", includeFilename)) entries, err := os.ReadDir(".") gomega.Expect(err).Should(gomega.BeNil()) bundleExists := false @@ -226,11 +235,12 @@ var testSupportBundle = func(o *option.Option) { reader, err := zip.OpenReader(dirEntry.Name()) gomega.Expect(err).Should(gomega.BeNil()) - zipBaseName := path.Base(dirEntry.Name()) - zipPrefix := strings.TrimSuffix(zipBaseName, path.Ext(zipBaseName)) + zipBaseName := filepath.Base(dirEntry.Name()) + zipPrefix := strings.TrimSuffix(zipBaseName, filepath.Ext(zipBaseName)) _, err = reader.Open(path.Join(zipPrefix, "misc", includeFilename)) gomega.Expect(err).ShouldNot(gomega.BeNil()) + gomega.Expect(reader.Close()).Should(gomega.BeNil()) err = os.Remove(dirEntry.Name()) gomega.Expect(err).Should(gomega.BeNil()) } @@ -253,11 +263,12 @@ var testSupportBundle = func(o *option.Option) { reader, err := zip.OpenReader(dirEntry.Name()) gomega.Expect(err).Should(gomega.BeNil()) - zipBaseName := path.Base(dirEntry.Name()) - zipPrefix := strings.TrimSuffix(zipBaseName, path.Ext(zipBaseName)) - _, err = reader.Open(path.Join(zipPrefix, "logs", "serial.log")) + zipBaseName := filepath.Base(dirEntry.Name()) + zipPrefix := strings.TrimSuffix(zipBaseName, filepath.Ext(zipBaseName)) + _, err = reader.Open(path.Join(zipPrefix, "configs", "finch.yaml")) gomega.Expect(err).Should(gomega.BeNil()) + gomega.Expect(reader.Close()).Should(gomega.BeNil()) err = os.Remove(dirEntry.Name()) gomega.Expect(err).Should(gomega.BeNil()) } @@ -267,9 +278,10 @@ var testSupportBundle = func(o *option.Option) { ginkgo.It("Should generate a support bundle with a file excluded when specified with both --include and --exclude", func() { includeFilename := fmt.Sprintf("tempTestfile%s", time.Now().Format("20060102150405")) //nolint:gosec // this file is only used for testing purposes and it does not include any user input - _, err := os.Create(includeFilename) + tempFile, err := os.Create(includeFilename) gomega.Expect(err).Should(gomega.BeNil()) defer func() { + gomega.Expect(tempFile.Close()).Should(gomega.BeNil()) err := os.Remove(includeFilename) gomega.Expect(err).Should(gomega.BeNil()) }() @@ -288,11 +300,12 @@ var testSupportBundle = func(o *option.Option) { reader, err := zip.OpenReader(dirEntry.Name()) gomega.Expect(err).Should(gomega.BeNil()) - zipBaseName := path.Base(dirEntry.Name()) - zipPrefix := strings.TrimSuffix(zipBaseName, path.Ext(zipBaseName)) + zipBaseName := filepath.Base(dirEntry.Name()) + zipPrefix := strings.TrimSuffix(zipBaseName, filepath.Ext(zipBaseName)) _, err = reader.Open(path.Join(zipPrefix, "misc", includeFilename)) gomega.Expect(err).ShouldNot(gomega.BeNil()) + gomega.Expect(reader.Close()).Should(gomega.BeNil()) err = os.Remove(dirEntry.Name()) gomega.Expect(err).Should(gomega.BeNil()) } diff --git a/e2e/vm/virtualization_framework_rosetta_test.go b/e2e/vm/virtualization_framework_rosetta_darwin_test.go similarity index 99% rename from e2e/vm/virtualization_framework_rosetta_test.go rename to e2e/vm/virtualization_framework_rosetta_darwin_test.go index 418c95236..bbc654d9f 100644 --- a/e2e/vm/virtualization_framework_rosetta_test.go +++ b/e2e/vm/virtualization_framework_rosetta_darwin_test.go @@ -1,6 +1,8 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 +//go:build darwin + package vm import ( diff --git a/e2e/vm/vm_darwin_test.go b/e2e/vm/vm_darwin_test.go new file mode 100644 index 000000000..ebba93b2e --- /dev/null +++ b/e2e/vm/vm_darwin_test.go @@ -0,0 +1,81 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +//go:build darwin + +// Package vm runs tests related to the virtual machine. +package vm + +import ( + "errors" + "io/fs" + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/onsi/ginkgo/v2" + "github.com/onsi/gomega" + + "github.com/runfinch/common-tests/command" + "github.com/runfinch/common-tests/option" + + "github.com/runfinch/finch/e2e" +) + +//nolint:paralleltest // TestVM is like TestMain for the VM-related tests. +func TestVM(t *testing.T) { + const description = "Finch Virtual Machine E2E Tests" + + o, err := e2e.CreateOption() + if err != nil { + t.Fatal(err) + } + + ginkgo.SynchronizedBeforeSuite(func() []byte { + resetDisks(o, *e2e.Installed) + command.New(o, "vm", "init").WithTimeoutInSeconds(600).Run() + return nil + }, func(bytes []byte) {}) + + ginkgo.SynchronizedAfterSuite(func() { + command.New(o, "vm", "stop", "-f").WithTimeoutInSeconds(90).Run() + command.New(o, "vm", "remove", "-f").WithTimeoutInSeconds(60).Run() + }, func() {}) + + ginkgo.Describe("", func() { + testVMLifecycle(o) + testAdditionalDisk(o, *e2e.Installed) + testConfig(o, *e2e.Installed) + testFinchConfigFile(o) + testVersion(o) + testVirtualizationFrameworkAndRosetta(o, *e2e.Installed) + testSupportBundle(o) + testCredHelper(o, *e2e.Installed, *e2e.Registry) + testSoci(o, *e2e.Installed) + }) + + gomega.RegisterFailHandler(ginkgo.Fail) + ginkgo.RunSpecs(t, description) +} + +var resetDisks = func(o *option.Option, installed bool) { + var dataDiskDir string + limaDisksPath := "lima/data/_disks/" + if installed { + path, err := exec.LookPath(e2e.InstalledTestSubject) + gomega.Expect(err).ShouldNot(gomega.HaveOccurred()) + realFinchPath, err := filepath.EvalSymlinks(path) + gomega.Expect(err).ShouldNot(gomega.HaveOccurred()) + dataDiskDir = filepath.Join(realFinchPath, "..", "..", limaDisksPath) + } else { + dataDiskDir = filepath.Join("..", "..", "_output", limaDisksPath) + } + realDiskPath, err := os.Readlink(filepath.Join(dataDiskDir, "finch", "datadisk")) + if err == nil { + gomega.Expect(os.Remove(realDiskPath)).ShouldNot(gomega.HaveOccurred()) + } else if !errors.Is(err, fs.ErrNotExist) { + gomega.Expect(err).ShouldNot(gomega.HaveOccurred()) + } + gomega.Expect(os.RemoveAll(dataDiskDir)).ShouldNot(gomega.HaveOccurred()) +} diff --git a/e2e/vm/vm_test.go b/e2e/vm/vm_test.go index 02245e356..bc1ddf504 100644 --- a/e2e/vm/vm_test.go +++ b/e2e/vm/vm_test.go @@ -1,20 +1,15 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -// Package vm runs tests related to the virtual machine. package vm import ( - "errors" - "io/fs" - "os" "os/exec" "path/filepath" - "testing" + "runtime" "github.com/onsi/ginkgo/v2" "github.com/onsi/gomega" - "github.com/runfinch/common-tests/command" "github.com/runfinch/common-tests/option" @@ -25,41 +20,6 @@ const ( virtualMachineRootCmd = "vm" ) -//nolint:paralleltest // TestVM is like TestMain for the VM-related tests. -func TestVM(t *testing.T) { - const description = "Finch Virtual Machine E2E Tests" - - o, err := e2e.CreateOption() - if err != nil { - t.Fatal(err) - } - - ginkgo.SynchronizedBeforeSuite(func() []byte { - command.New(o, "vm", "init").WithTimeoutInSeconds(600).Run() - return nil - }, func(bytes []byte) {}) - - ginkgo.SynchronizedAfterSuite(func() { - command.New(o, "vm", "stop", "-f").WithTimeoutInSeconds(90).Run() - command.New(o, "vm", "remove", "-f").WithTimeoutInSeconds(60).Run() - }, func() {}) - - ginkgo.Describe("", func() { - testVMLifecycle(o) - testAdditionalDisk(o) - testConfig(o, *e2e.Installed) - testFinchConfigFile(o) - testVersion(o) - testVirtualizationFrameworkAndRosetta(o, *e2e.Installed) - testSupportBundle(o) - testCredHelper(o, *e2e.Installed, *e2e.Registry) - testSoci(o, *e2e.Installed) - }) - - gomega.RegisterFailHandler(ginkgo.Fail) - ginkgo.RunSpecs(t, description) -} - var resetVM = func(o *option.Option, installed bool) string { var limaConfigFilePath string @@ -70,41 +30,29 @@ var resetVM = func(o *option.Option, installed bool) string { gomega.Expect(err).ShouldNot(gomega.HaveOccurred()) realFinchPath, err := filepath.EvalSymlinks(path) gomega.Expect(err).ShouldNot(gomega.HaveOccurred()) - limaConfigFilePath = filepath.Join(realFinchPath, "../../lima/data/_config/override.yaml") + limaConfigFilePath = filepath.Join(realFinchPath, "..", "..", "lima", "data", "_config", "override.yaml") } origLimaCfg := readFile(limaConfigFilePath) - command.New(o, virtualMachineRootCmd, "stop", "-f").WithoutCheckingExitCode().WithTimeoutInSeconds(90).Run() - command.New(o, virtualMachineRootCmd, "remove", "-f").WithoutCheckingExitCode().WithTimeoutInSeconds(90).Run() + command.New(o, virtualMachineRootCmd, "stop").WithTimeoutInSeconds(120).Run() + command.New(o, virtualMachineRootCmd, "remove").WithTimeoutInSeconds(90).Run() + if runtime.GOOS == "windows" { + // clean up iptables + //nolint:lll // link to explanation + // https://docs.rancherdesktop.io/troubleshooting-tips/#q-how-do-i-fix-fata0005-subnet-1040024-overlaps-with-other-one-on-this-address-space-when-running-a-container-using-nerdctl-run + gomega.Expect(exec.Command("wsl", "--shutdown").Run()).Should(gomega.BeNil()) + } ginkgo.DeferCleanup(func() { writeFile(finchConfigFilePath, origFinchCfg) writeFile(limaConfigFilePath, origLimaCfg) - command.New(o, virtualMachineRootCmd, "stop", "-f").WithoutCheckingExitCode().WithTimeoutInSeconds(90).Run() - command.New(o, virtualMachineRootCmd, "remove", "-f").WithoutCheckingExitCode().WithTimeoutInSeconds(90).Run() + command.New(o, virtualMachineRootCmd, "stop", "-f").WithTimeoutInSeconds(180).Run() + command.New(o, virtualMachineRootCmd, "remove", "-f").WithTimeoutInSeconds(180).Run() + if runtime.GOOS == "windows" { + gomega.Expect(exec.Command("wsl", "--shutdown").Run()).Should(gomega.BeNil()) + } command.New(o, virtualMachineRootCmd, "init").WithoutCheckingExitCode().WithTimeoutInSeconds(600).Run() }) return limaConfigFilePath } - -var resetDisks = func(o *option.Option, installed bool) { - var dataDiskDir string - limaDisksPath := "lima/data/_disks/" - if installed { - path, err := exec.LookPath(e2e.InstalledTestSubject) - gomega.Expect(err).ShouldNot(gomega.HaveOccurred()) - realFinchPath, err := filepath.EvalSymlinks(path) - gomega.Expect(err).ShouldNot(gomega.HaveOccurred()) - dataDiskDir = filepath.Join(realFinchPath, "../../", limaDisksPath) - } else { - dataDiskDir = filepath.Join("../../_output/", limaDisksPath) - } - realDiskPath, err := os.Readlink(filepath.Join(dataDiskDir, "finch/datadisk")) - if err == nil { - gomega.Expect(os.Remove(realDiskPath)).ShouldNot(gomega.HaveOccurred()) - } else if !errors.Is(err, fs.ErrNotExist) { - gomega.Expect(err).ShouldNot(gomega.HaveOccurred()) - } - gomega.Expect(os.RemoveAll(dataDiskDir)).ShouldNot(gomega.HaveOccurred()) -} diff --git a/e2e/vm/vm_windows_test.go b/e2e/vm/vm_windows_test.go new file mode 100644 index 000000000..bde28bd07 --- /dev/null +++ b/e2e/vm/vm_windows_test.go @@ -0,0 +1,61 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +//go:build windows + +// Package vm runs tests related to the virtual machine. +package vm + +import ( + "os" + "path/filepath" + "testing" + + "github.com/onsi/ginkgo/v2" + "github.com/onsi/gomega" + + "github.com/runfinch/common-tests/command" + "github.com/runfinch/common-tests/option" + + "github.com/runfinch/finch/e2e" +) + +//nolint:paralleltest // TestVM is like TestMain for the VM-related tests. +func TestVM(t *testing.T) { + const description = "Finch Virtual Machine E2E Tests" + + o, err := e2e.CreateOption() + if err != nil { + t.Fatal(err) + } + + ginkgo.SynchronizedBeforeSuite(func() []byte { + resetDisks(o, *e2e.Installed) + command.New(o, "vm", "init").WithTimeoutInSeconds(600).Run() + return nil + }, func(bytes []byte) {}) + + ginkgo.SynchronizedAfterSuite(func() { + command.New(o, "vm", "stop").WithTimeoutInSeconds(90).Run() + command.New(o, "vm", "remove").WithTimeoutInSeconds(60).Run() + }, func() {}) + + ginkgo.Describe("", func() { + testVMLifecycle(o) + testAdditionalDisk(o, *e2e.Installed) + testFinchConfigFile(o) + testVersion(o) + testSupportBundle(o) + testCredHelper(o, *e2e.Installed, *e2e.Registry) + testSoci(o, *e2e.Installed) + }) + + gomega.RegisterFailHandler(ginkgo.Fail) + ginkgo.RunSpecs(t, description) +} + +var resetDisks = func(o *option.Option, installed bool) { + finchRootDir := os.Getenv("LOCALAPPDATA") + dataDiskDir := filepath.Join(finchRootDir, ".finch", ".disks") + gomega.Expect(os.RemoveAll(dataDiskDir)).ShouldNot(gomega.HaveOccurred()) +} diff --git a/finch.windows.yaml b/finch.windows.yaml new file mode 100644 index 000000000..06f685471 --- /dev/null +++ b/finch.windows.yaml @@ -0,0 +1,94 @@ +# ===================================================================== # +# BASIC CONFIGURATION +# ===================================================================== # + +# Default values in this YAML file are specified by `null` instead of Lima's "builtin default" values, +# so they can be overridden by the $LIMA_HOME/_config/default.yaml mechanism documented at the end of this file. + +# VM type: "qemu" or "vz" (on macOS 13 and later). +# The vmType can be specified only on creating the instance. +# The vmType of existing instances cannot be changed. +# 🟢 Builtin default: "qemu" +vmType: wsl2 + +# OpenStack-compatible disk image. +# 🟢 Builtin default: null (must be specified) +# 🔵 This file: Ubuntu 23.04 Lunar Lobster images +images: +# Try to use release-yyyyMMdd image if available. Note that release-yyyyMMdd will be removed after several months. +- location: "" + arch: "" + digest: "" + +mountType: wsl2 + +containerd: + system: true + user: false + +provision: +- mode: system + script: | + modprobe br_netfilter + cat < /etc/sysctl.d/99-finch.conf + net.bridge.bridge-nf-call-iptables = 1 + net.bridge.bridge-nf-call-ip6tables = 1 + net.ipv4.ip_forward = 1 + EOF + sysctl --system +- mode: system + script: | + # systemd services stays running between lima VM reboots. + # Because the vsock port is randomized on vm start, the hostagent + # waits for the guestagent to be listening at a new port, while the + # guestagent just stays running at the original port. This causes + # vm stop => vm start to timeout. + # TODO: fix this in a Lima PR + systemctl restart lima-guestagent +# # `user` is executed without the root privilege +- mode: user + script: | + #!/bin/bash + + # Enable SSHing into the VM as root (e.g., in `nerdctlConfigApplier.Apply`). + sudo cp ~/.ssh/authorized_keys /root/.ssh/ + sudo chown $USER /mnt/lima-finch + + # This block of configuration facilitates the startup of rootless containers created prior to this change within the rootful vm configuration by mounting /mnt/lima-finch to both rootless and rootful dataroots. + + # https://github.com/containerd/containerd/blob/main/docs/ops.md#base-configuration + sudo mkdir -p /mnt/lima-finch/containerd /var/lib/containerd + sudo mount --bind /mnt/lima-finch/containerd /var/lib/containerd + + # https://github.com/containerd/nerdctl/blob/cffdf87ff4d648a5344eea1406bb95ca3ad7eaa4/extras/rootless/containerd-rootless.sh#L144-L146 + # XDG_DATA_HOME & ~/.local/share: https://github.com/containerd/nerdctl/blob/cffdf87ff4d648a5344eea1406bb95ca3ad7eaa4/extras/rootless/containerd-rootless.sh#L51 + mkdir ~/.local/share/containerd + sudo mount --bind /mnt/lima-finch/containerd ~/.local/share/containerd + + # https://github.com/containerd/nerdctl/blob/main/docs/dir.md#dataroot + sudo mkdir -p /mnt/lima-finch/nerdctl /var/lib/nerdctl + sudo mount --bind /mnt/lima-finch/nerdctl /var/lib/nerdctl + mkdir -p ~/.local/share/nerdctl + sudo mount --bind /mnt/lima-finch/nerdctl ~/.local/share/nerdctl + + # https://github.com/containerd/nerdctl/blob/main/docs/dir.md#netconfpath + sudo mkdir -p /mnt/lima-finch/cni-config /etc/cni/ + sudo mount --bind /mnt/lima-finch/cni-config /etc/cni/ + mkdir -p ~/.config/cni + sudo mount --bind /mnt/lima-finch/cni-config ~/.config/cni + + # https://github.com/containerd/nerdctl/blob/cffdf87ff4d648a5344eea1406bb95ca3ad7eaa4/extras/rootless/containerd-rootless.sh#L148-L150 + sudo mkdir -p /mnt/lima-finch/cni + sudo mount --bind /mnt/lima-finch/cni /var/lib/cni + mkdir -p ~/.local/share/cni + sudo mount --bind /mnt/lima-finch/cni ~/.local/share/cni + + # Make sure buildkit is restarted with containerd, so it uses the correct UUID + sudo systemctl add-requires buildkit.service containerd.service + sudo systemctl restart containerd.service + +env: + # Containerd namespace is used by the lima cidata script + # 40-install-containerd.sh. Specifically this variable is defining the + # Buildkit Workers Containerd namespace. + CONTAINERD_NAMESPACE: finch diff --git a/finch.yaml b/finch.yaml index 8aa9f8403..168de452e 100644 --- a/finch.yaml +++ b/finch.yaml @@ -5,6 +5,8 @@ # Default values in this YAML file are specified by `null` instead of Lima's "builtin default" values, # so they can be overridden by the $LIMA_HOME/_config/default.yaml mechanism documented at the end of this file. +#vmType: null + # Arch: "default", "x86_64", "aarch64". # 🟢 Builtin default: "default" (corresponds to the host architecture) arch: null @@ -88,8 +90,6 @@ mounts: # `/mnt/lima-${VOLUME}`. # 🟢 Builtin default: null # For Finch, this value should always be the same as the diskName in pkg/disk/disk.go -additionalDisks: -- "finch" ssh: # A localhost port of the host. Forwarded to port 22 of the guest. diff --git a/go.mod b/go.mod index 240275b2a..9e1884c8e 100644 --- a/go.mod +++ b/go.mod @@ -13,12 +13,13 @@ require ( github.com/onsi/gomega v1.30.0 github.com/pelletier/go-toml v1.9.5 github.com/pkg/sftp v1.13.6 - github.com/runfinch/common-tests v0.7.9 + github.com/runfinch/common-tests v0.7.11 github.com/shirou/gopsutil/v3 v3.23.11 github.com/sirupsen/logrus v1.9.3 github.com/spf13/afero v1.11.0 github.com/spf13/cobra v1.8.0 github.com/stretchr/testify v1.8.4 + github.com/tc-hib/go-winres v0.3.1 github.com/xorcare/pointer v1.2.2 golang.org/x/crypto v0.16.0 golang.org/x/exp v0.0.0-20230810033253-352e893a4cad @@ -31,6 +32,7 @@ require ( github.com/aws/smithy-go v1.19.0 // indirect github.com/containerd/containerd v1.7.11 // indirect github.com/coreos/go-semver v0.3.1 // indirect + github.com/cpuguy83/go-md2man/v2 v2.0.3 // indirect github.com/docker/docker-credential-helpers v0.7.0 // indirect github.com/docker/go-connections v0.4.0 // indirect github.com/go-ole/go-ole v1.2.6 // indirect @@ -40,13 +42,18 @@ require ( github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1 // indirect github.com/lima-vm/go-qcow2reader v0.1.1 // indirect github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect - github.com/opencontainers/image-spec v1.1.0-rc2.0.20221005185240-3a7f492d3f1b // indirect + github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect + github.com/opencontainers/image-spec v1.1.0-rc3 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect + github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/shoenig/go-m1cpu v0.1.6 // indirect + github.com/tc-hib/winres v0.1.6 // indirect github.com/tklauser/go-sysconf v0.3.12 // indirect github.com/tklauser/numcpus v0.6.1 // indirect + github.com/urfave/cli/v2 v2.3.0 // indirect github.com/yusufpapurcu/wmi v1.2.3 // indirect + golang.org/x/image v0.0.0-20210220032944-ac19c3e999fb // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20231120223509-83a465c0220f // indirect google.golang.org/grpc v1.59.0 // indirect google.golang.org/protobuf v1.31.0 // indirect @@ -58,7 +65,7 @@ require ( github.com/emirpasic/gods v1.12.0 // indirect github.com/fatih/color v1.15.0 // indirect github.com/go-logr/logr v1.3.0 // indirect - github.com/goccy/go-yaml v1.11.2 // indirect + github.com/goccy/go-yaml v1.11.2 github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect github.com/google/go-cmp v0.6.0 // indirect github.com/google/licenseclassifier v0.0.0-20210722185704-3043a050f148 // indirect @@ -80,8 +87,8 @@ require ( go.opencensus.io v0.24.0 // indirect golang.org/x/mod v0.14.0 // indirect golang.org/x/net v0.19.0 // indirect - golang.org/x/sys v0.15.0 // indirect - golang.org/x/text v0.14.0 // indirect + golang.org/x/sys v0.15.0 + golang.org/x/text v0.14.0 golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect gopkg.in/src-d/go-billy.v4 v4.3.2 // indirect diff --git a/go.sum b/go.sum index a8b63b1b0..ce26a63ab 100644 --- a/go.sum +++ b/go.sum @@ -91,7 +91,10 @@ github.com/containerd/containerd v1.7.11 h1:lfGKw3eU35sjV0aG2eYZTiwFEY1pCzxdzicH github.com/containerd/containerd v1.7.11/go.mod h1:5UluHxHTX2rdvYuZ5OJTC5m/KJNs0Zs9wVoJm9zf5ZE= github.com/coreos/go-semver v0.3.1 h1:yi21YpKnrx1gt5R+la8n5WgS0kCrsPp33dmEyHReZr4= github.com/coreos/go-semver v0.3.1/go.mod h1:irMmmIw/7yzSRPWryHsK7EYSg09caPQL03VsM8rvUec= +github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= +github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/cpuguy83/go-md2man/v2 v2.0.3 h1:qMCsGGgs+MAzDFyp9LpAe1Lqy/fY/qCovCm0qnXZOBM= github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= @@ -281,6 +284,8 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ= +github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= github.com/onsi/ginkgo/v2 v2.13.2 h1:Bi2gGVkfn6gQcjNjZJVO8Gf0FHzMPf2phUei9tejVMs= github.com/onsi/ginkgo/v2 v2.13.2/go.mod h1:XStQ8QcGwLyF4HdfcZB8SFOS/MWCgDuXMSBe6zrvLgM= @@ -288,8 +293,8 @@ github.com/onsi/gomega v1.30.0 h1:hvMK7xYz4D3HapigLTeGdId/NcfQx1VHMJc60ew99+8= github.com/onsi/gomega v1.30.0/go.mod h1:9sxs+SwGrKI0+PWe4Fxa9tFQQBG5xSsSbMXOI8PPpoQ= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= -github.com/opencontainers/image-spec v1.1.0-rc2.0.20221005185240-3a7f492d3f1b h1:YWuSjZCQAPM8UUBLkYUk1e+rZcvWHJmFb6i6rM44Xs8= -github.com/opencontainers/image-spec v1.1.0-rc2.0.20221005185240-3a7f492d3f1b/go.mod h1:3OVijpioIKYWTqjiG0zfF6wvoJ4fAXGbjdZuI2NgsRQ= +github.com/opencontainers/image-spec v1.1.0-rc3 h1:fzg1mXZFj8YdPeNkRXMg+zb88BFV0Ys52cJydRwBkb8= +github.com/opencontainers/image-spec v1.1.0-rc3/go.mod h1:X4pATf0uXsnn3g5aiGIsVnJBR4mxhKzfwmvK/B2NTm8= github.com/otiai10/copy v1.6.0 h1:IinKAryFFuPONZ7cm6T6E2QX/vcJwSnlaA5lfoaXIiQ= github.com/otiai10/copy v1.6.0/go.mod h1:XWfuS3CrI0R6IE0FbgHsEazaXO8G0LpMp9o8tos0x4E= github.com/otiai10/curr v0.0.0-20150429015615-9b4961190c95/go.mod h1:9qAhocn7zKJG+0mI8eUu6xqkFDYS2kb2saOteoSB3cE= @@ -315,8 +320,10 @@ github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1: github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= -github.com/runfinch/common-tests v0.7.9 h1:BcaiHZ/e8VTpaXKb3usP3vwddxVmsfpIELHM5peFJcg= -github.com/runfinch/common-tests v0.7.9/go.mod h1:iztvjCXgz5DmmRw1SAVWqcyVgER4932X1Q4W1EgY9QU= +github.com/runfinch/common-tests v0.7.11 h1:yidfcmovVwfFiEvUXfCwvBmoNNc8JxWrRzBVLQ6O96c= +github.com/runfinch/common-tests v0.7.11/go.mod h1:OYhthAa0cBEOir7tW3ykVD/+3pTGtwl1eHKpt31qOB8= +github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= github.com/sergi/go-diff v1.2.0 h1:XU+rvMAioB0UC3q1MFrIQy4Vo5/4VsRDQQXHsEya6xQ= @@ -327,6 +334,7 @@ github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFt github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ= github.com/shoenig/test v0.6.4 h1:kVTaSd7WLz5WZ2IaoM0RSzRsUD+m8wRR+5qvntpn4LU= github.com/shoenig/test v0.6.4/go.mod h1:byHiCGXqrVaflBLAMq/srcZIHynQPQgeyvkvXnjqq0k= +github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= @@ -353,10 +361,16 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/tc-hib/go-winres v0.3.1 h1:9r67V7Ep34yyx8SL716BzcKePRvEBOjan47SmMnxEdE= +github.com/tc-hib/go-winres v0.3.1/go.mod h1:lTPf0MW3eu6rmvMyLrPXSy6xsSz4t5dRxB7dc5YFP6k= +github.com/tc-hib/winres v0.1.6 h1:qgsYHze+BxQPEYilxIz/KCQGaClvI2+yLBAZs+3+0B8= +github.com/tc-hib/winres v0.1.6/go.mod h1:pe6dOR40VOrGz8PkzreVKNvEKnlE8t4yR8A8naL+t7A= github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU= github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI= github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk= github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY= +github.com/urfave/cli/v2 v2.3.0 h1:qph92Y649prgesehzOrQjdWyxFOp/QVM+6imKHad91M= +github.com/urfave/cli/v2 v2.3.0/go.mod h1:LJmUH05zAU44vOAcrfzZQKsZbVcdbOG8rtL3/XcUArI= github.com/xanzy/ssh-agent v0.2.1 h1:TCbipTQL2JiiCprBWx9frJ2eJlCYT00NmctrHxVAr70= github.com/xanzy/ssh-agent v0.2.1/go.mod h1:mLlQY/MoOhWBj+gOGMQkOeiEvkx+8pJSI+0Bx9h2kr4= github.com/xorcare/pointer v1.2.2 h1:zjD77b5DTehClND4MK+9dDE0DcpFIZisAJ/+yVJvKYA= @@ -404,6 +418,9 @@ golang.org/x/exp v0.0.0-20230810033253-352e893a4cad h1:g0bG7Z4uG+OgH2QDODnjp6ggk golang.org/x/exp v0.0.0-20230810033253-352e893a4cad/go.mod h1:FXUEEKJgO7OQYeo8N01OfiKP8RXMtf6e8aTskBGqWdc= golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/image v0.0.0-20201208152932-35266b937fa6/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/image v0.0.0-20210220032944-ac19c3e999fb h1:fqpd0EBDzlHRCjiphRR5Zo/RSWWQlWv34418dnEixWk= +golang.org/x/image v0.0.0-20210220032944-ac19c3e999fb/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= diff --git a/msi-builder/BuildFinchMSI.ps1 b/msi-builder/BuildFinchMSI.ps1 new file mode 100644 index 000000000..7e3bb52b3 --- /dev/null +++ b/msi-builder/BuildFinchMSI.ps1 @@ -0,0 +1,101 @@ +param( + [Parameter(Mandatory=$true)] + $Version, + $SourcePath = (Join-Path (Split-Path $PSScriptRoot -Parent) "_output") +) + +# 0. Get the directory path of the currently executing script +$scriptDirectory = $PSScriptRoot + +Write-Host "0. Finch MSI generation started..." +Write-Host "version: $Version," +Write-Host "finch original build path: $SourcePath," +Write-Host "script root: $scriptDirectory," + +# 1. Prepare the build directory +Write-Host "1. Prepare for build directory..." +# Initialize build folder +$buildFolderPath = Join-Path -Path $scriptDirectory -ChildPath "build" + +# Clean up old build and create new build directory +if (Test-Path $buildFolderPath) { + Remove-Item -Path $buildFolderPath -Recurse -Force +} + +New-Item -Path $buildFolderPath -ItemType Directory +Write-Host "build folder is created." + +# 2. Download WiX Tool binaries +Write-Host "2. Download Wix Tool binaries..." +$url = "https://github.com/wixtoolset/wix3/releases/download/wix3112rtm/wix311-binaries.zip" +$downloadPath = Join-Path -Path $scriptDirectory -ChildPath "build\WiXToolset\wix311-binaries.zip" +$wixToolPath = Join-Path -Path $scriptDirectory -ChildPath "build\WiXToolset\" +New-Item -Path $wixToolPath -ItemType Directory + +Invoke-WebRequest -Uri $url -OutFile $downloadPath +Expand-Archive -Path $downloadPath -DestinationPath $wixToolPath +Remove-Item -Path $downloadPath +Write-Host "Wix Tool is ready." + +# 3. Copy resources to MSI build directory +Write-Host "3. Copy finch resources..." +$finchResourcePath = Join-Path -Path $scriptDirectory -ChildPath "build\Finch" +New-Item -Path $finchResourcePath -ItemType Directory + +# Copy files recursively from finch original build path to MSI tool Finch resources path +Get-ChildItem -Path $SourcePath -Recurse | + Where-Object { !$_.PSIsContainer } | + ForEach-Object { + $destFile = $finchResourcePath + $_.FullName.Substring($SourcePath.Length) + $destDir = [System.IO.Path]::GetDirectoryName($destFile) + if (-not (Test-Path $destDir)) { + New-Item -Path $destDir -ItemType Directory + } + Copy-Item -Path $_.FullName -Destination $destFile + } + +Write-Host "Files copied successfully." + +# 4. Put additional resources to MSI build directory, e.g., postinstall, uninstall script and Finch license +Write-Host "4. Copy extra scripts, license and icon..." +Copy-Item -Path (Join-Path -Path $scriptDirectory -ChildPath "postinstall.bat") -Destination (Join-Path -Path $scriptDirectory -ChildPath "build\Finch") +Copy-Item -Path (Join-Path -Path $scriptDirectory -ChildPath "uninstall.bat") -Destination (Join-Path -Path $scriptDirectory -ChildPath "build\Finch") +Copy-Item -Path (Join-Path -Path $scriptDirectory -ChildPath "finch.ico") -Destination (Join-Path -Path $scriptDirectory -ChildPath "build\Finch") +Copy-Item -Path (Join-Path -Path $scriptDirectory -ChildPath "LICENSE.rtf") -Destination (Join-Path -Path $scriptDirectory -ChildPath "build\Finch") +Write-Host "Files copied successfully." + +# 5. Copy WiX template and update resources path and version +Write-Host "5. Copy Wix template and update value..." +Copy-Item -Path (Join-Path -Path $scriptDirectory -ChildPath "FinchMSITemplate.wxs") -Destination (Join-Path -Path $scriptDirectory -ChildPath "build\") +$wxsFilePath = Join-Path -Path $scriptDirectory -ChildPath "build\FinchMSITemplate.wxs" + +# Search finch-roofs-production-amd64-*.tar.gz and get its name +$roofsPath = Join-Path $PSScriptRoot "build\Finch\os" +$roofsFile = Get-ChildItem -Path $roofsPath -Filter "finch-rootfs-production-amd64-*.tar.gz" | Select-Object -First 1 +$roofsFileName = $roofsFile.Name + +# Replace __ROOTFS__, __SOURCE__ and __VERSION__ +$content = Get-Content -Path $wxsFilePath -Raw +$updatedContent = $content -replace '__SOURCE__', $finchResourcePath ` + -replace '__VERSION__', $Version ` + -replace '__ROOTFS__', $roofsFileName +$updatedContent | Set-Content -Path $wxsFilePath +Write-Host "Source path and version are updated successfully." + +# 6. Build MSI +Write-Host "6. Start build MSI..." +$wixobjPath = Join-Path -Path $buildFolderPath -ChildPath "FinchMSITemplate.wixobj" +$msiPath = Join-Path -Path $buildFolderPath -ChildPath "Finch-$Version.msi" + +$candlePath = Join-Path -Path $wixToolPath -ChildPath "candle.exe" +$candleArgs = "$wxsFilePath -out $wixobjPath" +$lightPath = Join-Path -Path $wixToolPath -ChildPath "light.exe" +$lightArgs = "$wixobjPath -ext WixUIExtension -out $msiPath" + +Start-Process -FilePath $candlePath -ArgumentList $candleArgs -Wait +Write-Host "Candle finished." +Write-Host "Light started, it may take some time..." +Start-Process -FilePath $lightPath -ArgumentList $lightArgs -Wait +Write-Host "Light finished." + +Write-Host "Finch-$Version.msi is generated. Location: $msiPath" \ No newline at end of file diff --git a/msi-builder/FinchMSITemplate.wxs b/msi-builder/FinchMSITemplate.wxs new file mode 100644 index 000000000..6fb08448f --- /dev/null +++ b/msi-builder/FinchMSITemplate.wxs @@ -0,0 +1,123 @@ + + + + + + + + + + NOT NEWER_VERSION_FOUND + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + NOT Installed + REMOVE="ALL" + + + + + + + + + + + + 1 + 1 + + + + \ No newline at end of file diff --git a/msi-builder/LICENSE.rtf b/msi-builder/LICENSE.rtf new file mode 100644 index 000000000..0e925bac9 --- /dev/null +++ b/msi-builder/LICENSE.rtf @@ -0,0 +1,326 @@ +{\rtf1\adeflang1025\ansi\ansicpg936\uc2\adeff0\deff0\stshfdbch31505\stshfloch31506\stshfhich31506\stshfbi0\deflang1033\deflangfe2052\themelang1033\themelangfe2052\themelangcs0{\fonttbl{\f0\fbidi \froman\fcharset0\fprq2{\*\panose 02020603050405020304}Times New Roman;} +{\f13\fbidi \fnil\fcharset134\fprq2{\*\panose 02010600030101010101}\'cb\'ce\'cc\'e5{\*\falt SimSun};}{\f34\fbidi \froman\fcharset0\fprq2{\*\panose 02040503050406030204}Cambria Math;} +{\f36\fbidi \fnil\fcharset134\fprq2{\*\panose 02010600030101010101}\'b5\'c8\'cf\'df{\*\falt DengXian};}{\f44\fbidi \fnil\fcharset134\fprq2{\*\panose 00000000000000000000}@\'cb\'ce\'cc\'e5;} +{\f45\fbidi \fnil\fcharset134\fprq2{\*\panose 00000000000000000000}@\'b5\'c8\'cf\'df;}{\flomajor\f31500\fbidi \froman\fcharset0\fprq2{\*\panose 02020603050405020304}Times New Roman;} +{\fdbmajor\f31501\fbidi \fnil\fcharset134\fprq2{\*\panose 02010600030101010101}\'b5\'c8\'cf\'df Light;}{\fhimajor\f31502\fbidi \fnil\fcharset134\fprq2{\*\panose 02010600030101010101}\'b5\'c8\'cf\'df Light;} +{\fbimajor\f31503\fbidi \froman\fcharset0\fprq2{\*\panose 02020603050405020304}Times New Roman;}{\flominor\f31504\fbidi \froman\fcharset0\fprq2{\*\panose 02020603050405020304}Times New Roman;} +{\fdbminor\f31505\fbidi \fnil\fcharset134\fprq2{\*\panose 02010600030101010101}\'b5\'c8\'cf\'df{\*\falt DengXian};}{\fhiminor\f31506\fbidi \fnil\fcharset134\fprq2{\*\panose 02010600030101010101}\'b5\'c8\'cf\'df{\*\falt DengXian};} +{\fbiminor\f31507\fbidi \froman\fcharset0\fprq2{\*\panose 02020603050405020304}Times New Roman;}{\f46\fbidi \froman\fcharset238\fprq2 Times New Roman CE;}{\f47\fbidi \froman\fcharset204\fprq2 Times New Roman Cyr;} +{\f49\fbidi \froman\fcharset161\fprq2 Times New Roman Greek;}{\f50\fbidi \froman\fcharset162\fprq2 Times New Roman Tur;}{\f51\fbidi \froman\fcharset177\fprq2 Times New Roman (Hebrew);}{\f52\fbidi \froman\fcharset178\fprq2 Times New Roman (Arabic);} +{\f53\fbidi \froman\fcharset186\fprq2 Times New Roman Baltic;}{\f54\fbidi \froman\fcharset163\fprq2 Times New Roman (Vietnamese);}{\f178\fbidi \fnil\fcharset0\fprq2 SimSun Western{\*\falt SimSun};}{\f386\fbidi \froman\fcharset238\fprq2 Cambria Math CE;} +{\f387\fbidi \froman\fcharset204\fprq2 Cambria Math Cyr;}{\f389\fbidi \froman\fcharset161\fprq2 Cambria Math Greek;}{\f390\fbidi \froman\fcharset162\fprq2 Cambria Math Tur;}{\f393\fbidi \froman\fcharset186\fprq2 Cambria Math Baltic;} +{\f394\fbidi \froman\fcharset163\fprq2 Cambria Math (Vietnamese);}{\f408\fbidi \fnil\fcharset0\fprq2 DengXian Western{\*\falt DengXian};}{\f406\fbidi \fnil\fcharset238\fprq2 DengXian CE{\*\falt DengXian};} +{\f407\fbidi \fnil\fcharset204\fprq2 DengXian Cyr{\*\falt DengXian};}{\f409\fbidi \fnil\fcharset161\fprq2 DengXian Greek{\*\falt DengXian};}{\f488\fbidi \fnil\fcharset0\fprq2 @SimSun Western;}{\f498\fbidi \fnil\fcharset0\fprq2 @DengXian Western;} +{\f496\fbidi \fnil\fcharset238\fprq2 @DengXian CE;}{\f497\fbidi \fnil\fcharset204\fprq2 @DengXian Cyr;}{\f499\fbidi \fnil\fcharset161\fprq2 @DengXian Greek;}{\flomajor\f31508\fbidi \froman\fcharset238\fprq2 Times New Roman CE;} +{\flomajor\f31509\fbidi \froman\fcharset204\fprq2 Times New Roman Cyr;}{\flomajor\f31511\fbidi \froman\fcharset161\fprq2 Times New Roman Greek;}{\flomajor\f31512\fbidi \froman\fcharset162\fprq2 Times New Roman Tur;} +{\flomajor\f31513\fbidi \froman\fcharset177\fprq2 Times New Roman (Hebrew);}{\flomajor\f31514\fbidi \froman\fcharset178\fprq2 Times New Roman (Arabic);}{\flomajor\f31515\fbidi \froman\fcharset186\fprq2 Times New Roman Baltic;} +{\flomajor\f31516\fbidi \froman\fcharset163\fprq2 Times New Roman (Vietnamese);}{\fdbmajor\f31520\fbidi \fnil\fcharset0\fprq2 DengXian Light Western;}{\fdbmajor\f31518\fbidi \fnil\fcharset238\fprq2 DengXian Light CE;} +{\fdbmajor\f31519\fbidi \fnil\fcharset204\fprq2 DengXian Light Cyr;}{\fdbmajor\f31521\fbidi \fnil\fcharset161\fprq2 DengXian Light Greek;}{\fhimajor\f31530\fbidi \fnil\fcharset0\fprq2 DengXian Light Western;} +{\fhimajor\f31528\fbidi \fnil\fcharset238\fprq2 DengXian Light CE;}{\fhimajor\f31529\fbidi \fnil\fcharset204\fprq2 DengXian Light Cyr;}{\fhimajor\f31531\fbidi \fnil\fcharset161\fprq2 DengXian Light Greek;} +{\fbimajor\f31538\fbidi \froman\fcharset238\fprq2 Times New Roman CE;}{\fbimajor\f31539\fbidi \froman\fcharset204\fprq2 Times New Roman Cyr;}{\fbimajor\f31541\fbidi \froman\fcharset161\fprq2 Times New Roman Greek;} +{\fbimajor\f31542\fbidi \froman\fcharset162\fprq2 Times New Roman Tur;}{\fbimajor\f31543\fbidi \froman\fcharset177\fprq2 Times New Roman (Hebrew);}{\fbimajor\f31544\fbidi \froman\fcharset178\fprq2 Times New Roman (Arabic);} +{\fbimajor\f31545\fbidi \froman\fcharset186\fprq2 Times New Roman Baltic;}{\fbimajor\f31546\fbidi \froman\fcharset163\fprq2 Times New Roman (Vietnamese);}{\flominor\f31548\fbidi \froman\fcharset238\fprq2 Times New Roman CE;} +{\flominor\f31549\fbidi \froman\fcharset204\fprq2 Times New Roman Cyr;}{\flominor\f31551\fbidi \froman\fcharset161\fprq2 Times New Roman Greek;}{\flominor\f31552\fbidi \froman\fcharset162\fprq2 Times New Roman Tur;} +{\flominor\f31553\fbidi \froman\fcharset177\fprq2 Times New Roman (Hebrew);}{\flominor\f31554\fbidi \froman\fcharset178\fprq2 Times New Roman (Arabic);}{\flominor\f31555\fbidi \froman\fcharset186\fprq2 Times New Roman Baltic;} +{\flominor\f31556\fbidi \froman\fcharset163\fprq2 Times New Roman (Vietnamese);}{\fdbminor\f31560\fbidi \fnil\fcharset0\fprq2 DengXian Western{\*\falt DengXian};}{\fdbminor\f31558\fbidi \fnil\fcharset238\fprq2 DengXian CE{\*\falt DengXian};} +{\fdbminor\f31559\fbidi \fnil\fcharset204\fprq2 DengXian Cyr{\*\falt DengXian};}{\fdbminor\f31561\fbidi \fnil\fcharset161\fprq2 DengXian Greek{\*\falt DengXian};}{\fhiminor\f31570\fbidi \fnil\fcharset0\fprq2 DengXian Western{\*\falt DengXian};} +{\fhiminor\f31568\fbidi \fnil\fcharset238\fprq2 DengXian CE{\*\falt DengXian};}{\fhiminor\f31569\fbidi \fnil\fcharset204\fprq2 DengXian Cyr{\*\falt DengXian};}{\fhiminor\f31571\fbidi \fnil\fcharset161\fprq2 DengXian Greek{\*\falt DengXian};} +{\fbiminor\f31578\fbidi \froman\fcharset238\fprq2 Times New Roman CE;}{\fbiminor\f31579\fbidi \froman\fcharset204\fprq2 Times New Roman Cyr;}{\fbiminor\f31581\fbidi \froman\fcharset161\fprq2 Times New Roman Greek;} +{\fbiminor\f31582\fbidi \froman\fcharset162\fprq2 Times New Roman Tur;}{\fbiminor\f31583\fbidi \froman\fcharset177\fprq2 Times New Roman (Hebrew);}{\fbiminor\f31584\fbidi \froman\fcharset178\fprq2 Times New Roman (Arabic);} +{\fbiminor\f31585\fbidi \froman\fcharset186\fprq2 Times New Roman Baltic;}{\fbiminor\f31586\fbidi \froman\fcharset163\fprq2 Times New Roman (Vietnamese);}}{\colortbl;\red0\green0\blue0;\red0\green0\blue255;\red0\green255\blue255;\red0\green255\blue0; +\red255\green0\blue255;\red255\green0\blue0;\red255\green255\blue0;\red255\green255\blue255;\red0\green0\blue128;\red0\green128\blue128;\red0\green128\blue0;\red128\green0\blue128;\red128\green0\blue0;\red128\green128\blue0;\red128\green128\blue128; +\red192\green192\blue192;\red0\green0\blue0;\red0\green0\blue0;\chyperlink\ctint255\cshade255\red5\green99\blue193;\red96\green94\blue92;\red225\green223\blue221;}{\*\defchp \fs21\kerning2\loch\af31506\hich\af31506\dbch\af31505 }{\*\defpap +\ql \li0\ri0\widctlpar\wrapdefault\aspalpha\aspnum\faauto\adjustright\rin0\lin0\itap0 }\noqfpromote {\stylesheet{\qj \li0\ri0\nowidctlpar\wrapdefault\aspalpha\aspnum\faauto\adjustright\rin0\lin0\itap0 \rtlch\fcs1 \af0\afs22\alang1025 \ltrch\fcs0 +\fs21\lang1033\langfe2052\kerning2\loch\f31506\hich\af31506\dbch\af31505\cgrid\langnp1033\langfenp2052 \snext0 \sqformat \spriority0 Normal;}{\*\cs10 \additive \ssemihidden \sunhideused \spriority1 Default Paragraph Font;}{\* +\ts11\tsrowd\trftsWidthB3\trpaddl108\trpaddr108\trpaddfl3\trpaddft3\trpaddfb3\trpaddfr3\trcbpat1\trcfpat1\tblind0\tblindtype3\tsvertalt\tsbrdrt\tsbrdrl\tsbrdrb\tsbrdrr\tsbrdrdgl\tsbrdrdgr\tsbrdrh\tsbrdrv +\ql \li0\ri0\widctlpar\wrapdefault\aspalpha\aspnum\faauto\adjustright\rin0\lin0\itap0 \rtlch\fcs1 \af0\afs21\alang1025 \ltrch\fcs0 \fs21\lang1033\langfe2052\kerning2\loch\f31506\hich\af31506\dbch\af31505\cgrid\langnp1033\langfenp2052 +\snext11 \ssemihidden \sunhideused Normal Table;}{\*\cs15 \additive \rtlch\fcs1 \af0 \ltrch\fcs0 \ul\cf19 \sbasedon10 \sunhideused \styrsid11745378 Hyperlink;}{\*\cs16 \additive \rtlch\fcs1 \af0 \ltrch\fcs0 \cf20\chshdng0\chcfpat0\chcbpat21 +\sbasedon10 \ssemihidden \sunhideused \styrsid11745378 Unresolved Mention;}}{\*\rsidtbl \rsid3817249\rsid9523092\rsid11745378}{\mmathPr\mmathFont34\mbrkBin0\mbrkBinSub0\msmallFrac0\mdispDef1\mlMargin0\mrMargin0\mdefJc1\mwrapIndent1440\mintLim0\mnaryLim1} +{\info{\operator Chaoning Li}{\creatim\yr2023\mo9\dy14\hr7\min31}{\revtim\yr2023\mo9\dy14\hr7\min38}{\version3}{\edmins3}{\nofpages4}{\nofwords1545}{\nofchars8808}{\nofcharsws10333}{\vern79}}{\*\xmlnstbl {\xmlns1 http://schemas.microsoft.com/office/word/2 +003/wordml}}\paperw12240\paperh15840\margl1800\margr1800\margt1440\margb1440\gutter0\ltrsect +\ftnbj\aenddoc\trackmoves0\trackformatting1\donotembedsysfont0\relyonvml0\donotembedlingdata1\grfdocevents0\validatexml0\showplaceholdtext0\ignoremixedcontent0\saveinvalidxml0\showxmlerrors0\horzdoc\dghspace120\dgvspace120\dghorigin1701\dgvorigin1984 +\dghshow0\dgvshow3\jcompress\viewkind1\viewscale100\rsidroot9523092 \nouicompat \fet0{\*\wgrffmtfilter 2450}\nofeaturethrottle1\ilfomacatclnup0\ltrpar \sectd \ltrsect\linex0\sectdefaultcl\sftnbj {\*\pnseclvl1\pnucrm\pnstart1\pnindent720\pnhang +{\pntxta \dbch .}}{\*\pnseclvl2\pnucltr\pnstart1\pnindent720\pnhang {\pntxta \dbch .}}{\*\pnseclvl3\pndec\pnstart1\pnindent720\pnhang {\pntxta \dbch .}}{\*\pnseclvl4\pnlcltr\pnstart1\pnindent720\pnhang {\pntxta \dbch )}}{\*\pnseclvl5 +\pndec\pnstart1\pnindent720\pnhang {\pntxtb \dbch (}{\pntxta \dbch )}}{\*\pnseclvl6\pnlcltr\pnstart1\pnindent720\pnhang {\pntxtb \dbch (}{\pntxta \dbch )}}{\*\pnseclvl7\pnlcrm\pnstart1\pnindent720\pnhang {\pntxtb \dbch (}{\pntxta \dbch )}}{\*\pnseclvl8 +\pnlcltr\pnstart1\pnindent720\pnhang {\pntxtb \dbch (}{\pntxta \dbch )}}{\*\pnseclvl9\pnlcrm\pnstart1\pnindent720\pnhang {\pntxtb \dbch (}{\pntxta \dbch )}}\pard\plain \ltrpar\ql \li0\ri0\nowidctlpar\wrapdefault\faauto\rin0\lin0\itap0\pararsid9523092 +\rtlch\fcs1 \af0\afs22\alang1025 \ltrch\fcs0 \fs21\lang1033\langfe2052\kerning2\loch\af31506\hich\af31506\dbch\af31505\cgrid\langnp1033\langfenp2052 {\rtlch\fcs1 \af13 \ltrch\fcs0 \fs22\kerning0\loch\af13\dbch\af13\insrsid9523092\charrsid9523092 +\hich\af31506\dbch\af13\loch\f13 }{\rtlch\fcs1 \af13 \ltrch\fcs0 \fs22\kerning0\loch\af13\dbch\af13\insrsid3817249 \hich\af31506\dbch\af13\loch\f13 Apache License}{\rtlch\fcs1 \af13 \ltrch\fcs0 \fs22\kerning0\loch\af13\dbch\af13\insrsid9523092 +\par }{\rtlch\fcs1 \af13 \ltrch\fcs0 \fs22\kerning0\loch\af13\dbch\af13\insrsid3817249 \hich\af31506\dbch\af13\loch\f13 Version 2.0, January 2004}{\rtlch\fcs1 \af13 \ltrch\fcs0 \fs22\kerning0\loch\af13\dbch\af13\insrsid9523092 +\par }{\field{\*\fldinst {\rtlch\fcs1 \af13 \ltrch\fcs0 \fs22\kerning0\loch\af13\dbch\af13\insrsid3817249 \hich\af31506\dbch\af13\loch\f13 HYPERLINK http://www.apache.org/licenses/ }{\rtlch\fcs1 \af13 \ltrch\fcs0 +\fs22\kerning0\loch\af13\dbch\af13\insrsid9523092 {\*\datafield +00d0c9ea79f9bace118c8200aa004ba90b0200000003000000e0c9ea79f9bace118c8200aa004ba90b5800000068007400740070003a002f002f007700770077002e006100700061006300680065002e006f00720067002f006c006900630065006e007300650073002f000000795881f43b1d7f48af2c825dc48527630000 +0000a5ab000300}}}{\fldrslt {\rtlch\fcs1 \af13 \ltrch\fcs0 \fs22\kerning0\loch\af13\dbch\af13\insrsid3817249 \hich\af31506\dbch\af13\loch\f13 http://www.apache.org/licenses/}}}\sectd \ltrsect\linex0\sectdefaultcl\sftnbj {\rtlch\fcs1 \af13 \ltrch\fcs0 +\fs22\kerning0\loch\af13\dbch\af13\insrsid3817249 +\par }\pard \ltrpar\ql \li0\ri0\nowidctlpar\wrapdefault\faauto\rin0\lin0\itap0 {\rtlch\fcs1 \af13 \ltrch\fcs0 \fs22\kerning0\loch\af13\dbch\af13\insrsid3817249 +\par }{\rtlch\fcs1 \af13 \ltrch\fcs0 \fs22\kerning0\loch\af13\dbch\af13\insrsid9523092\charrsid9523092 \hich\af31506\dbch\af13\loch\f13 }{\rtlch\fcs1 \af13 \ltrch\fcs0 \fs22\kerning0\loch\af13\dbch\af13\insrsid3817249 \hich\af31506\dbch\af13\loch\f13 +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION +\par +\par \hich\af31506\dbch\af13\loch\f13 1. Definitions. +\par \hich\af31506\dbch\af13\loch\f13 "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. +\par \hich\af31506\dbch\af13\loch\f13 "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. +\par \hich\af31506\dbch\af13\loch\f13 "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the p\hich\af31506\dbch\af13\loch\f13 +urposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) bene +\hich\af31506\dbch\af13\loch\f13 ficial ownership of such entity. +\par \hich\af31506\dbch\af13\loch\f13 "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. +\par \hich\af31506\dbch\af13\loch\f13 "Source" form shall mean the preferred form for making modifications, including but not limited to software source \hich\af31506\dbch\af13\loch\f13 code, documentation source, and configuration files. +\par \hich\af31506\dbch\af13\loch\f13 "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. + +\par \hich\af31506\dbch\af13\loch\f13 + "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). +\par \hich\af31506\dbch\af13\loch\f13 + "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of auth +\hich\af31506\dbch\af13\loch\f13 orship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. +\par \hich\af31506\dbch\af13\loch\f13 "Contribution" shall mean any work of authorship,\hich\af31506\dbch\af13\loch\f13 + including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity autho +\hich\af31506\dbch\af13\loch\f13 +rized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on el +\hich\af31506\dbch\af13\loch\f13 ec\hich\af31506\dbch\af13\loch\f13 +tronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise de +\hich\af31506\dbch\af13\loch\f13 signated in writing by the copyright owner as "Not a Contribution." +\par \hich\af31506\dbch\af13\loch\f13 "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. +\par +\par \hich\af31506\dbch\af13\loch\f13 2. Gr\hich\af31506\dbch\af13\loch\f13 +ant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, p +\hich\af31506\dbch\af13\loch\f13 ublicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. +\par +\par \hich\af31506\dbch\af13\loch\f13 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual\hich\af31506\dbch\af13\loch\f13 +, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claim +\hich\af31506\dbch\af13\loch\f13 +s licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (inc +\hich\af31506\dbch\af13\loch\f13 lu\hich\af31506\dbch\af13\loch\f13 +ding a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall t +\hich\af31506\dbch\af13\loch\f13 erminate as of the date such litigation is filed. +\par +\par \hich\af31506\dbch\af13\loch\f13 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the f +\hich\af31506\dbch\af13\loch\f13 ollowing conditions: +\par \hich\af31506\dbch\af13\loch\f13 (a) You must give any other recipients of the Work or}{\rtlch\fcs1 \af13 \ltrch\fcs0 \fs22\kerning0\loch\af13\dbch\af13\insrsid9523092 \hich\af31506\dbch\af13\loch\f13 }{\rtlch\fcs1 \af13 \ltrch\fcs0 +\fs22\kerning0\loch\af13\dbch\af13\insrsid3817249 \hich\af31506\dbch\af13\loch\f13 Derivative Works a copy of this License; and +\par \hich\af31506\dbch\af13\loch\f13 (b) You must cause any modified files to carry prominent notices}{\rtlch\fcs1 \af13 \ltrch\fcs0 \fs22\kerning0\loch\af13\dbch\af13\insrsid9523092 \hich\af31506\dbch\af13\loch\f13 }{\rtlch\fcs1 \af13 \ltrch\fcs0 +\fs22\kerning0\loch\af13\dbch\af13\insrsid3817249 \hich\af31506\dbch\af13\loch\f13 stating that You changed the files; and +\par \hich\af31506\dbch\af13\loch\f13 (c) You must retain, in the Source form of any Derivative Works}{\rtlch\fcs1 \af13 \ltrch\fcs0 \fs22\kerning0\loch\af13\dbch\af13\insrsid9523092 \hich\af31506\dbch\af13\loch\f13 }{\rtlch\fcs1 \af13 \ltrch\fcs0 +\fs22\kerning0\loch\af13\dbch\af13\insrsid3817249 \hich\af31506\dbch\af13\loch\f13 that You distribute, all copyright, patent, trademark, and}{\rtlch\fcs1 \af13 \ltrch\fcs0 \fs22\kerning0\loch\af13\dbch\af13\insrsid9523092 +\hich\af31506\dbch\af13\loch\f13 }{\rtlch\fcs1 \af13 \ltrch\fcs0 \fs22\kerning0\loch\af13\dbch\af13\insrsid3817249 \hich\af31506\dbch\af13\loch\f13 attribution notices from the Source form of the Work,}{\rtlch\fcs1 \af13 \ltrch\fcs0 +\fs22\kerning0\loch\af13\dbch\af13\insrsid9523092 \hich\af31506\dbch\af13\loch\f13 }{\rtlch\fcs1 \af13 \ltrch\fcs0 \fs22\kerning0\loch\af13\dbch\af13\insrsid3817249 \hich\af31506\dbch\af13\loch\f13 +excluding those notices that do not pertain to any part of}{\rtlch\fcs1 \af13 \ltrch\fcs0 \fs22\kerning0\loch\af13\dbch\af13\insrsid9523092 \hich\af31506\dbch\af13\loch\f13 }{\rtlch\fcs1 \af13 \ltrch\fcs0 +\fs22\kerning0\loch\af13\dbch\af13\insrsid3817249 \hich\af31506\dbch\af13\loch\f13 the Derivative Works; and +\par \hich\af31506\dbch\af13\loch\f13 (d) If the Work inclu\hich\af31506\dbch\af13\loch\f13 des a "NOTICE" text file as part of its}{\rtlch\fcs1 \af13 \ltrch\fcs0 \fs22\kerning0\loch\af13\dbch\af13\insrsid9523092 \hich\af31506\dbch\af13\loch\f13 }{ +\rtlch\fcs1 \af13 \ltrch\fcs0 \fs22\kerning0\loch\af13\dbch\af13\insrsid3817249 \hich\af31506\dbch\af13\loch\f13 distribution, then any Derivative Works that You distribute must}{\rtlch\fcs1 \af13 \ltrch\fcs0 +\fs22\kerning0\loch\af13\dbch\af13\insrsid9523092 \hich\af31506\dbch\af13\loch\f13 }{\rtlch\fcs1 \af13 \ltrch\fcs0 \fs22\kerning0\loch\af13\dbch\af13\insrsid3817249 \hich\af31506\dbch\af13\loch\f13 +include a readable copy of the attribution notices contained}{\rtlch\fcs1 \af13 \ltrch\fcs0 \fs22\kerning0\loch\af13\dbch\af13\insrsid9523092 \hich\af31506\dbch\af13\loch\f13 }{\rtlch\fcs1 \af13 \ltrch\fcs0 +\fs22\kerning0\loch\af13\dbch\af13\insrsid3817249 \hich\af31506\dbch\af13\loch\f13 within such NOTICE file, excluding those notices that do not}{\rtlch\fcs1 \af13 \ltrch\fcs0 \fs22\kerning0\loch\af13\dbch\af13\insrsid9523092 +\hich\af31506\dbch\af13\loch\f13 }{\rtlch\fcs1 \af13 \ltrch\fcs0 \fs22\kerning0\loch\af13\dbch\af13\insrsid3817249 \hich\af31506\dbch\af13\loch\f13 pertain to any part of the Derivative Works, in at least one}{\rtlch\fcs1 \af13 \ltrch\fcs0 +\fs22\kerning0\loch\af13\dbch\af13\insrsid9523092 \hich\af31506\dbch\af13\loch\f13 }{\rtlch\fcs1 \af13 \ltrch\fcs0 \fs22\kerning0\loch\af13\dbch\af13\insrsid3817249 \hich\af31506\dbch\af13\loch\f13 +of the following places: within a NOTICE text file distributed}{\rtlch\fcs1 \af13 \ltrch\fcs0 \fs22\kerning0\loch\af13\dbch\af13\insrsid9523092 \hich\af31506\dbch\af13\loch\f13 }{\rtlch\fcs1 \af13 \ltrch\fcs0 +\fs22\kerning0\loch\af13\dbch\af13\insrsid3817249 \hich\af31506\dbch\af13\loch\f13 as part of the Derivative Works; within the Source form or}{\rtlch\fcs1 \af13 \ltrch\fcs0 \fs22\kerning0\loch\af13\dbch\af13\insrsid9523092 +\hich\af31506\dbch\af13\loch\f13 }{\rtlch\fcs1 \af13 \ltrch\fcs0 \fs22\kerning0\loch\af13\dbch\af13\insrsid3817249 \hich\af31506\dbch\af13\loch\f13 documentation, if provided along with the Derivative Works; or,}{\rtlch\fcs1 \af13 \ltrch\fcs0 +\fs22\kerning0\loch\af13\dbch\af13\insrsid9523092 \hich\af31506\dbch\af13\loch\f13 }{\rtlch\fcs1 \af13 \ltrch\fcs0 \fs22\kerning0\loch\af13\dbch\af13\insrsid3817249 \hich\af31506\dbch\af13\loch\f13 within a display generated by the Deri +\hich\af31506\dbch\af13\loch\f13 vative Works, if and}{\rtlch\fcs1 \af13 \ltrch\fcs0 \fs22\kerning0\loch\af13\dbch\af13\insrsid9523092 \hich\af31506\dbch\af13\loch\f13 }{\rtlch\fcs1 \af13 \ltrch\fcs0 \fs22\kerning0\loch\af13\dbch\af13\insrsid3817249 +\hich\af31506\dbch\af13\loch\f13 wherever such third-party notices normally appear. The contents}{\rtlch\fcs1 \af13 \ltrch\fcs0 \fs22\kerning0\loch\af13\dbch\af13\insrsid9523092 \hich\af31506\dbch\af13\loch\f13 }{\rtlch\fcs1 \af13 \ltrch\fcs0 +\fs22\kerning0\loch\af13\dbch\af13\insrsid3817249 \hich\af31506\dbch\af13\loch\f13 of the NOTICE file are for informational purposes only and}{\rtlch\fcs1 \af13 \ltrch\fcs0 \fs22\kerning0\loch\af13\dbch\af13\insrsid9523092 +\hich\af31506\dbch\af13\loch\f13 }{\rtlch\fcs1 \af13 \ltrch\fcs0 \fs22\kerning0\loch\af13\dbch\af13\insrsid3817249 \hich\af31506\dbch\af13\loch\f13 do not modify the License. You may add Your own attribution}{\rtlch\fcs1 \af13 \ltrch\fcs0 +\fs22\kerning0\loch\af13\dbch\af13\insrsid9523092 \hich\af31506\dbch\af13\loch\f13 }{\rtlch\fcs1 \af13 \ltrch\fcs0 \fs22\kerning0\loch\af13\dbch\af13\insrsid3817249 \hich\af31506\dbch\af13\loch\f13 +notices within Derivative Works that You distribute, alongside}{\rtlch\fcs1 \af13 \ltrch\fcs0 \fs22\kerning0\loch\af13\dbch\af13\insrsid9523092 \hich\af31506\dbch\af13\loch\f13 }{\rtlch\fcs1 \af13 \ltrch\fcs0 +\fs22\kerning0\loch\af13\dbch\af13\insrsid3817249 \hich\af31506\dbch\af13\loch\f13 or as an addendum to the NOTICE text from the Work, provided}{\rtlch\fcs1 \af13 \ltrch\fcs0 \fs22\kerning0\loch\af13\dbch\af13\insrsid9523092 +\hich\af31506\dbch\af13\loch\f13 }{\rtlch\fcs1 \af13 \ltrch\fcs0 \fs22\kerning0\loch\af13\dbch\af13\insrsid3817249 \hich\af31506\dbch\af13\loch\f13 that such additional attribution notices cannot be construed}{\rtlch\fcs1 \af13 \ltrch\fcs0 +\fs22\kerning0\loch\af13\dbch\af13\insrsid9523092 \hich\af31506\dbch\af13\loch\f13 }{\rtlch\fcs1 \af13 \ltrch\fcs0 \fs22\kerning0\loch\af13\dbch\af13\insrsid3817249 \hich\af31506\dbch\af13\loch\f13 as modifying the License. +\par \hich\af31506\dbch\af13\loch\f13 You may add Your own copyright statement to Your modifications and may provide additional or dif\hich\af31506\dbch\af13\loch\f13 +ferent license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in t +\hich\af31506\dbch\af13\loch\f13 his License. +\par +\par \hich\af31506\dbch\af13\loch\f13 5. Submission of Contributions. Unless Yo\hich\af31506\dbch\af13\loch\f13 +u explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing +\hich\af31506\dbch\af13\loch\f13 herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. +\par +\par \hich\af31506\dbch\af13\loch\f13 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or p\hich\af31506\dbch\af13\loch\f13 +roduct names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. +\par +\par \hich\af31506\dbch\af13\loch\f13 + 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or impl +\hich\af31506\dbch\af13\loch\f13 ied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEME\hich\af31506\dbch\af13\loch\f13 +NT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. +\par +\par \hich\af31506\dbch\af13\loch\f13 + 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Cont +\hich\af31506\dbch\af13\loch\f13 ributor be li\hich\af31506\dbch\af13\loch\f13 +able to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of go +\hich\af31506\dbch\af13\loch\f13 odwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. +\par +\par \hich\af31506\dbch\af13\loch\f13 9. Accepting Warranty or Additional Liability. While redistributing\hich\af31506\dbch\af13\loch\f13 + the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may ac +\hich\af31506\dbch\af13\loch\f13 +t only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor b +\hich\af31506\dbch\af13\loch\f13 y \hich\af31506\dbch\af13\loch\f13 reason of your accepting any such warranty or additional liability. +\par +\par \hich\af31506\dbch\af13\loch\f13 END OF TERMS AND CONDITIONS +\par +\par \hich\af31506\dbch\af13\loch\f13 APPENDIX: How to apply the Apache License to your work. +\par \hich\af31506\dbch\af13\loch\f13 + To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate com +\hich\af31506\dbch\af13\loch\f13 ment syntax for the file format. We also recommend that a file or class name and description of p\hich\af31506\dbch\af13\loch\f13 +urpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. +\par +\par \hich\af31506\dbch\af13\loch\f13 Copyright [yyyy] [name of copyright owner] +\par +\par }\pard \ltrpar\ql \li0\ri0\nowidctlpar\wrapdefault\faauto\rin0\lin0\itap0\pararsid11745378 {\rtlch\fcs1 \af13 \ltrch\fcs0 \fs22\kerning0\loch\af13\dbch\af13\insrsid3817249 \hich\af31506\dbch\af13\loch\f13 + Licensed under the Apache License, Version 2.0 (the "License");}{\rtlch\fcs1 \af13 \ltrch\fcs0 \fs22\kerning0\loch\af13\dbch\af13\insrsid11745378 \hich\af31506\dbch\af13\loch\f13 }{\rtlch\fcs1 \af13 \ltrch\fcs0 +\fs22\kerning0\loch\af13\dbch\af13\insrsid3817249 \hich\af31506\dbch\af13\loch\f13 you may not use this file except in compliance with the License. +\par }\pard \ltrpar\ql \li0\ri0\nowidctlpar\wrapdefault\faauto\rin0\lin0\itap0 {\rtlch\fcs1 \af13 \ltrch\fcs0 \fs22\kerning0\loch\af13\dbch\af13\insrsid3817249 \hich\af31506\dbch\af13\loch\f13 \hich\af31506\dbch\af13\loch\f13 + You may obtain a copy of the License \hich\af31506\dbch\af13\loch\f13 a\hich\af31506\dbch\af13\loch\f13 t}{\rtlch\fcs1 \af13 \ltrch\fcs0 \fs22\kerning0\loch\af13\dbch\af13\insrsid11745378 \hich\af31506\dbch\af13\loch\f13 }{\rtlch\fcs1 \af13 +\ltrch\fcs0 \fs22\kerning0\loch\af13\dbch\af13\insrsid11745378\charrsid11745378 \hich\af31506\dbch\af13\loch\f13 http://www.apache.org/licenses/LICENSE-2.0}{\rtlch\fcs1 \af13 \ltrch\fcs0 \fs22\kerning0\loch\af13\dbch\af13\insrsid3817249 +\par +\par }\pard \ltrpar\ql \li0\ri0\nowidctlpar\wrapdefault\faauto\rin0\lin0\itap0\pararsid11745378 {\rtlch\fcs1 \af13 \ltrch\fcs0 \fs22\kerning0\loch\af13\dbch\af13\insrsid3817249 \hich\af31506\dbch\af13\loch\f13 + Unless required by applicable law or agreed to in writing, software}{\rtlch\fcs1 \af13 \ltrch\fcs0 \fs22\kerning0\loch\af13\dbch\af13\insrsid11745378 \hich\af31506\dbch\af13\loch\f13 }{\rtlch\fcs1 \af13 \ltrch\fcs0 +\fs22\kerning0\loch\af13\dbch\af13\insrsid3817249 \hich\af31506\dbch\af13\loch\f13 distributed under the License is distributed on an "AS IS" BASIS,\hich\af31506\dbch\af13\loch\f13 WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + +\par }\pard \ltrpar\ql \li0\ri0\nowidctlpar\wrapdefault\faauto\rin0\lin0\itap0 {\rtlch\fcs1 \af13 \ltrch\fcs0 \fs22\kerning0\loch\af13\dbch\af13\insrsid3817249 \hich\af31506\dbch\af13\loch\f13 + See the License for the specific language governing permissions and}{\rtlch\fcs1 \af13 \ltrch\fcs0 \fs22\kerning0\loch\af13\dbch\af13\insrsid11745378 \hich\af31506\dbch\af13\loch\f13 }{\rtlch\fcs1 \af13 \ltrch\fcs0 +\fs22\kerning0\loch\af13\dbch\af13\insrsid3817249 \hich\af31506\dbch\af13\loch\f13 limitations under the License.}{\rtlch\fcs1 \af13 \ltrch\fcs0 \fs22\kerning0\loch\af13\dbch\af13\insrsid3817249 +\par }{\*\themedata 504b030414000600080000002100e9de0fbfff0000001c020000130000005b436f6e74656e745f54797065735d2e786d6cac91cb4ec3301045f748fc83e52d4a +9cb2400825e982c78ec7a27cc0c8992416c9d8b2a755fbf74cd25442a820166c2cd933f79e3be372bd1f07b5c3989ca74aaff2422b24eb1b475da5df374fd9ad +5689811a183c61a50f98f4babebc2837878049899a52a57be670674cb23d8e90721f90a4d2fa3802cb35762680fd800ecd7551dc18eb899138e3c943d7e503b6 +b01d583deee5f99824e290b4ba3f364eac4a430883b3c092d4eca8f946c916422ecab927f52ea42b89a1cd59c254f919b0e85e6535d135a8de20f20b8c12c3b0 +0c895fcf6720192de6bf3b9e89ecdbd6596cbcdd8eb28e7c365ecc4ec1ff1460f53fe813d3cc7f5b7f020000ffff0300504b030414000600080000002100a5d6 +a7e7c0000000360100000b0000005f72656c732f2e72656c73848fcf6ac3300c87ef85bd83d17d51d2c31825762fa590432fa37d00e1287f68221bdb1bebdb4f +c7060abb0884a4eff7a93dfeae8bf9e194e720169aaa06c3e2433fcb68e1763dbf7f82c985a4a725085b787086a37bdbb55fbc50d1a33ccd311ba548b6309512 +0f88d94fbc52ae4264d1c910d24a45db3462247fa791715fd71f989e19e0364cd3f51652d73760ae8fa8c9ffb3c330cc9e4fc17faf2ce545046e37944c69e462 +a1a82fe353bd90a865aad41ed0b5b8f9d6fd010000ffff0300504b0304140006000800000021006b799616830000008a0000001c0000007468656d652f746865 +6d652f7468656d654d616e616765722e786d6c0ccc4d0ac3201040e17da17790d93763bb284562b2cbaebbf600439c1a41c7a0d29fdbd7e5e38337cedf14d59b +4b0d592c9c070d8a65cd2e88b7f07c2ca71ba8da481cc52c6ce1c715e6e97818c9b48d13df49c873517d23d59085adb5dd20d6b52bd521ef2cdd5eb9246a3d8b +4757e8d3f729e245eb2b260a0238fd010000ffff0300504b0304140006000800000021007fdac6cd91070000c7200000160000007468656d652f7468656d652f +7468656d65312e786d6cec59cd8b1bc915bf07f23f347d97f5d5ad8fc1f2a24fcfda33b6b164873dd648a5eef2547789aad28cc56208de532e81c0ee924316f6 +b68765c94216b2e4923fc660936cfe88bcaa6e755749a5f5cce080093382a1bbf47baf7ef5deabf79eaaee7ef232a1de05e682b0b4e7d7efd47c0fa773b62069 +d4f39fcd26958eef0989d205a22cc53d7f8385ffc9bddffee62e3a92314eb007f2a938423d3f96727554ad8a390c237187ad700adf2d194f9084571e55171c5d +82de84561bb55aab9a2092fa5e8a1250fb78b92473eccd944affde56f998c26b2a851a98533e55aab125a1b18bf3ba42888d1852ee5d20daf3619e05bb9ce197 +d2f7281212bee8f935fde757efddada2a35c88ca03b286dc44ffe572b9c0e2bca1e7e4d1593169108441ab5fe8d7002af771e3f6b8356e15fa3400cde7b0d28c +8badb3dd180639d600658f0edda3f6a859b7f086fee61ee77ea83e165e8332fdc11e7e321982152dbc0665f8700f1f0eba8391ad5f83327c6b0fdfaef54741db +d2af413125e9f91eba16b69ac3ed6a0bc892d16327bc1b06937623575ea2201a8ae852532c592a0fc55a825e303e018002522449eac9cd0a2fd11ca2f8dd0f7f +78f78f7f7a27248a21ee56286502466b8ddaa4d684ffea13e827ed5074849121ac680111b137a4e87862cec94af6fc07a0d537206f7ffef9cdeb9fdebcfefb9b +2fbe78f3faaff9dc5a9525778cd2c894fbe5bb3ffde79bdf7bfffedbb7bf7cf95536f52e5e98786b694ef5b0e2d2126fbffef1dd4f3fbefdf31ffff5fd970eed +7d8ece4cf88c2458788ff0a5f79425b040c704f88c5f4f621623624af4d348a014a9591cfac732b6d08f368822076e806d3b3ee790695cc0fbeb1716e169ccd7 +9238343e8c130b78ca181d30eeb4c243359761e6d93a8ddc93f3b5897b8ad0856bee214a2d2f8fd72b48b1c4a57218638be6138a5289229c62e9a9efd839c68e +d57d468865d75332e74cb0a5f43e23de0011a74966e4cc8aa652e89824e0978d8b20f8dbb2cde9736fc0a86bd5237c6123616f20ea203fc3d432e37db4962871 +a99ca1849a063f413276919c6ef8dcc48d85044f4798326fbcc042b8641e7358afe1f48708929bd3eda77493d8482ec9b94be70962cc448ed8f93046c9ca859d +923436b19f8a730851e43d61d2053f65f60e51efe007941e74f773822d77bf3f1b3c830c6b522a03447db3e60e5fdec7cc8adfe9862e1176a59a3e4fac14dbe7 +c4191d83756485f609c6145da205c6deb34f1d0c066c65d9bc24fd2086ac728c5d81f500d9b1aade532ca05552bdcd7e9e3c21c20ad9298ed8013ea79b9dc4b3 +416982f821cd8fc0eba6cdc7671c36a383c2633a3f37818f08b480102f4ea33c16a0c308ee835a9fc4c82a60ea5db8e375c32dff5d658fc1be7c61d1b8c2be04 +197c6d1948eca6ccafda6686a8354119303344bc1357ba0511cbfda5882aae5a6ced945bda9bb67403344756cf9390f47d0dd04eeb13feef5a1f6830defee51b +470c7e9876c7add8ca55d76c740ee592e39df6e6106eb7a91932be201f7f4f3342ebf4098632b29fb06e5b9adb96c6ffbf6f690eede7db46e650bb71dbc8f8d0 +60dc3632f9d1ca876964cade05da1a75de911df3e8439fe4e099cf92503a951b8a4f843ef611f0736631814125a7cf3b717106b88ae151953998c0c2451c6919 +8f33f93b22e3698c56703854f7959248e4aa23e1ad988033233decd4adf0749d9cb24576d459afab63cdacb20a24cbf15a588cc33195ccd0ad76797c57a8d76c +237dccba25a064af43c298cc26d17490686f079591f4a12e18cd4142afec83b0e83a587494faadabf65800b5c22bf07bdb835fe93d3f0c400484e0380e7af385 +f253e6eaad77b5333fa4a70f19d38a0068b0b711507abaabb81e5c9e5a5d166a57f0b445c208379b84b68c6ef0440cbf82f3e854a357a1715d5f774b975af494 +29f47c105a258d76e7d758dcd4d720b79b1b686a660a9a7a973dbfd50c2164e668d5f39770640c8fc90a6247a89f5c884670ef32973cdbf037c92c2b2ee40889 +3833b84e3a59364888c4dca324e9f96af9851b68aa7388e6566f4042f868c97521ad7c6ce4c0e9b693f17289e7d274bb31a22c9dbd4286cf7285f35b2d7e73b0 +92646b70f7345e5c7a6774cd9f2208b1b05d57065c10015707f5cc9a0b02576145222be36fa730e569d7bc8bd231948d23ba8a515e51cc649ec1752a2fe8e8b7 +c206c65bbe6630a86192bc109e45aac09a46b5aa695135320e07abeefb8594e58ca459d64c2baba8aae9ce62d60cdb32b063cb9b157983d5d6c490d3cc0a9fa5 +eedd94dbdde6ba9d3ea1a81260f0c27e8eaa7b858260502b27b3a829c6fb6958e5ec7cd4ae1ddb05be87da558a8491f55b5bb53b762b6a84733a18bc51e507b9 +dda885a1e5b6afd496d677e6e6b5363b7b01c963045dee9a4aa15d0907bb1c414334d53d499636608bbc94f9d680276fcd49cfffbc16f68361231c566a9d705c +099a41add209fbcd4a3f0c9bf57158af8d068d575058649cd4c3ecbe7e02f7177493dfdaebf1bd9bfb647b457367ce922ad337f3554d5cdfdcd71b876fee3d02 +49e7f35663d26d7607ad4ab7d99f5482d1a053e90e5b83caa8356c8f26a361d8e94e5ef9de850607fde630688d3b95567d38ac04ad9aa2dfe956da41a3d10fda +fdce38e8bfcadb185879963e725b807935af7bff050000ffff0300504b0304140006000800000021000dd1909fb60000001b010000270000007468656d652f74 +68656d652f5f72656c732f7468656d654d616e616765722e786d6c2e72656c73848f4d0ac2301484f78277086f6fd3ba109126dd88d0add40384e4350d363f24 +51eced0dae2c082e8761be9969bb979dc9136332de3168aa1a083ae995719ac16db8ec8e4052164e89d93b64b060828e6f37ed1567914b284d262452282e3198 +720e274a939cd08a54f980ae38a38f56e422a3a641c8bbd048f7757da0f19b017cc524bd62107bd5001996509affb3fd381a89672f1f165dfe514173d9850528 +a2c6cce0239baa4c04ca5bbabac4df000000ffff0300504b01022d0014000600080000002100e9de0fbfff0000001c0200001300000000000000000000000000 +000000005b436f6e74656e745f54797065735d2e786d6c504b01022d0014000600080000002100a5d6a7e7c0000000360100000b000000000000000000000000 +00300100005f72656c732f2e72656c73504b01022d00140006000800000021006b799616830000008a0000001c00000000000000000000000000190200007468 +656d652f7468656d652f7468656d654d616e616765722e786d6c504b01022d00140006000800000021007fdac6cd91070000c720000016000000000000000000 +00000000d60200007468656d652f7468656d652f7468656d65312e786d6c504b01022d00140006000800000021000dd1909fb60000001b010000270000000000 +00000000000000009b0a00007468656d652f7468656d652f5f72656c732f7468656d654d616e616765722e786d6c2e72656c73504b050600000000050005005d010000960b00000000} +{\*\colorschememapping 3c3f786d6c2076657273696f6e3d22312e302220656e636f64696e673d225554462d3822207374616e64616c6f6e653d22796573223f3e0d0a3c613a636c724d +617020786d6c6e733a613d22687474703a2f2f736368656d61732e6f70656e786d6c666f726d6174732e6f72672f64726177696e676d6c2f323030362f6d6169 +6e22206267313d226c743122207478313d22646b3122206267323d226c743222207478323d22646b322220616363656e74313d22616363656e74312220616363 +656e74323d22616363656e74322220616363656e74333d22616363656e74332220616363656e74343d22616363656e74342220616363656e74353d22616363656e74352220616363656e74363d22616363656e74362220686c696e6b3d22686c696e6b2220666f6c486c696e6b3d22666f6c486c696e6b222f3e} +{\*\latentstyles\lsdstimax376\lsdlockeddef0\lsdsemihiddendef0\lsdunhideuseddef0\lsdqformatdef0\lsdprioritydef99{\lsdlockedexcept \lsdqformat1 \lsdpriority0 \lsdlocked0 Normal;\lsdqformat1 \lsdpriority9 \lsdlocked0 heading 1; +\lsdsemihidden1 \lsdunhideused1 \lsdqformat1 \lsdpriority9 \lsdlocked0 heading 2;\lsdsemihidden1 \lsdunhideused1 \lsdqformat1 \lsdpriority9 \lsdlocked0 heading 3;\lsdsemihidden1 \lsdunhideused1 \lsdqformat1 \lsdpriority9 \lsdlocked0 heading 4; +\lsdsemihidden1 \lsdunhideused1 \lsdqformat1 \lsdpriority9 \lsdlocked0 heading 5;\lsdsemihidden1 \lsdunhideused1 \lsdqformat1 \lsdpriority9 \lsdlocked0 heading 6;\lsdsemihidden1 \lsdunhideused1 \lsdqformat1 \lsdpriority9 \lsdlocked0 heading 7; +\lsdsemihidden1 \lsdunhideused1 \lsdqformat1 \lsdpriority9 \lsdlocked0 heading 8;\lsdsemihidden1 \lsdunhideused1 \lsdqformat1 \lsdpriority9 \lsdlocked0 heading 9;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 index 1; +\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 index 2;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 index 3;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 index 4;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 index 5; +\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 index 6;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 index 7;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 index 8;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 index 9; +\lsdsemihidden1 \lsdunhideused1 \lsdpriority39 \lsdlocked0 toc 1;\lsdsemihidden1 \lsdunhideused1 \lsdpriority39 \lsdlocked0 toc 2;\lsdsemihidden1 \lsdunhideused1 \lsdpriority39 \lsdlocked0 toc 3; +\lsdsemihidden1 \lsdunhideused1 \lsdpriority39 \lsdlocked0 toc 4;\lsdsemihidden1 \lsdunhideused1 \lsdpriority39 \lsdlocked0 toc 5;\lsdsemihidden1 \lsdunhideused1 \lsdpriority39 \lsdlocked0 toc 6; +\lsdsemihidden1 \lsdunhideused1 \lsdpriority39 \lsdlocked0 toc 7;\lsdsemihidden1 \lsdunhideused1 \lsdpriority39 \lsdlocked0 toc 8;\lsdsemihidden1 \lsdunhideused1 \lsdpriority39 \lsdlocked0 toc 9;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Normal Indent; +\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 footnote text;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 annotation text;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 header;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 footer; +\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 index heading;\lsdsemihidden1 \lsdunhideused1 \lsdqformat1 \lsdpriority35 \lsdlocked0 caption;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 table of figures; +\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 envelope address;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 envelope return;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 footnote reference;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 annotation reference; +\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 line number;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 page number;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 endnote reference;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 endnote text; +\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 table of authorities;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 macro;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 toa heading;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 List; +\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 List Bullet;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 List Number;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 List 2;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 List 3; +\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 List 4;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 List 5;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 List Bullet 2;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 List Bullet 3; +\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 List Bullet 4;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 List Bullet 5;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 List Number 2;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 List Number 3; +\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 List Number 4;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 List Number 5;\lsdqformat1 \lsdpriority10 \lsdlocked0 Title;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Closing; +\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Signature;\lsdsemihidden1 \lsdunhideused1 \lsdpriority1 \lsdlocked0 Default Paragraph Font;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Body Text;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Body Text Indent; +\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 List Continue;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 List Continue 2;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 List Continue 3;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 List Continue 4; +\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 List Continue 5;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Message Header;\lsdqformat1 \lsdpriority11 \lsdlocked0 Subtitle;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Salutation; +\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Date;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Body Text First Indent;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Body Text First Indent 2;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Note Heading; +\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Body Text 2;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Body Text 3;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Body Text Indent 2;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Body Text Indent 3; +\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Block Text;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Hyperlink;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 FollowedHyperlink;\lsdqformat1 \lsdpriority22 \lsdlocked0 Strong; +\lsdqformat1 \lsdpriority20 \lsdlocked0 Emphasis;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Document Map;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Plain Text;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 E-mail Signature; +\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 HTML Top of Form;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 HTML Bottom of Form;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Normal (Web);\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 HTML Acronym; +\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 HTML Address;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 HTML Cite;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 HTML Code;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 HTML Definition; +\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 HTML Keyboard;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 HTML Preformatted;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 HTML Sample;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 HTML Typewriter; +\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 HTML Variable;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 annotation subject;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 No List;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Outline List 1; +\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Outline List 2;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Outline List 3;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Balloon Text;\lsdpriority39 \lsdlocked0 Table Grid; +\lsdsemihidden1 \lsdlocked0 Placeholder Text;\lsdqformat1 \lsdpriority1 \lsdlocked0 No Spacing;\lsdpriority60 \lsdlocked0 Light Shading;\lsdpriority61 \lsdlocked0 Light List;\lsdpriority62 \lsdlocked0 Light Grid; +\lsdpriority63 \lsdlocked0 Medium Shading 1;\lsdpriority64 \lsdlocked0 Medium Shading 2;\lsdpriority65 \lsdlocked0 Medium List 1;\lsdpriority66 \lsdlocked0 Medium List 2;\lsdpriority67 \lsdlocked0 Medium Grid 1;\lsdpriority68 \lsdlocked0 Medium Grid 2; +\lsdpriority69 \lsdlocked0 Medium Grid 3;\lsdpriority70 \lsdlocked0 Dark List;\lsdpriority71 \lsdlocked0 Colorful Shading;\lsdpriority72 \lsdlocked0 Colorful List;\lsdpriority73 \lsdlocked0 Colorful Grid;\lsdpriority60 \lsdlocked0 Light Shading Accent 1; +\lsdpriority61 \lsdlocked0 Light List Accent 1;\lsdpriority62 \lsdlocked0 Light Grid Accent 1;\lsdpriority63 \lsdlocked0 Medium Shading 1 Accent 1;\lsdpriority64 \lsdlocked0 Medium Shading 2 Accent 1;\lsdpriority65 \lsdlocked0 Medium List 1 Accent 1; +\lsdsemihidden1 \lsdlocked0 Revision;\lsdqformat1 \lsdpriority34 \lsdlocked0 List Paragraph;\lsdqformat1 \lsdpriority29 \lsdlocked0 Quote;\lsdqformat1 \lsdpriority30 \lsdlocked0 Intense Quote;\lsdpriority66 \lsdlocked0 Medium List 2 Accent 1; +\lsdpriority67 \lsdlocked0 Medium Grid 1 Accent 1;\lsdpriority68 \lsdlocked0 Medium Grid 2 Accent 1;\lsdpriority69 \lsdlocked0 Medium Grid 3 Accent 1;\lsdpriority70 \lsdlocked0 Dark List Accent 1;\lsdpriority71 \lsdlocked0 Colorful Shading Accent 1; +\lsdpriority72 \lsdlocked0 Colorful List Accent 1;\lsdpriority73 \lsdlocked0 Colorful Grid Accent 1;\lsdpriority60 \lsdlocked0 Light Shading Accent 2;\lsdpriority61 \lsdlocked0 Light List Accent 2;\lsdpriority62 \lsdlocked0 Light Grid Accent 2; +\lsdpriority63 \lsdlocked0 Medium Shading 1 Accent 2;\lsdpriority64 \lsdlocked0 Medium Shading 2 Accent 2;\lsdpriority65 \lsdlocked0 Medium List 1 Accent 2;\lsdpriority66 \lsdlocked0 Medium List 2 Accent 2; +\lsdpriority67 \lsdlocked0 Medium Grid 1 Accent 2;\lsdpriority68 \lsdlocked0 Medium Grid 2 Accent 2;\lsdpriority69 \lsdlocked0 Medium Grid 3 Accent 2;\lsdpriority70 \lsdlocked0 Dark List Accent 2;\lsdpriority71 \lsdlocked0 Colorful Shading Accent 2; +\lsdpriority72 \lsdlocked0 Colorful List Accent 2;\lsdpriority73 \lsdlocked0 Colorful Grid Accent 2;\lsdpriority60 \lsdlocked0 Light Shading Accent 3;\lsdpriority61 \lsdlocked0 Light List Accent 3;\lsdpriority62 \lsdlocked0 Light Grid Accent 3; +\lsdpriority63 \lsdlocked0 Medium Shading 1 Accent 3;\lsdpriority64 \lsdlocked0 Medium Shading 2 Accent 3;\lsdpriority65 \lsdlocked0 Medium List 1 Accent 3;\lsdpriority66 \lsdlocked0 Medium List 2 Accent 3; +\lsdpriority67 \lsdlocked0 Medium Grid 1 Accent 3;\lsdpriority68 \lsdlocked0 Medium Grid 2 Accent 3;\lsdpriority69 \lsdlocked0 Medium Grid 3 Accent 3;\lsdpriority70 \lsdlocked0 Dark List Accent 3;\lsdpriority71 \lsdlocked0 Colorful Shading Accent 3; +\lsdpriority72 \lsdlocked0 Colorful List Accent 3;\lsdpriority73 \lsdlocked0 Colorful Grid Accent 3;\lsdpriority60 \lsdlocked0 Light Shading Accent 4;\lsdpriority61 \lsdlocked0 Light List Accent 4;\lsdpriority62 \lsdlocked0 Light Grid Accent 4; +\lsdpriority63 \lsdlocked0 Medium Shading 1 Accent 4;\lsdpriority64 \lsdlocked0 Medium Shading 2 Accent 4;\lsdpriority65 \lsdlocked0 Medium List 1 Accent 4;\lsdpriority66 \lsdlocked0 Medium List 2 Accent 4; +\lsdpriority67 \lsdlocked0 Medium Grid 1 Accent 4;\lsdpriority68 \lsdlocked0 Medium Grid 2 Accent 4;\lsdpriority69 \lsdlocked0 Medium Grid 3 Accent 4;\lsdpriority70 \lsdlocked0 Dark List Accent 4;\lsdpriority71 \lsdlocked0 Colorful Shading Accent 4; +\lsdpriority72 \lsdlocked0 Colorful List Accent 4;\lsdpriority73 \lsdlocked0 Colorful Grid Accent 4;\lsdpriority60 \lsdlocked0 Light Shading Accent 5;\lsdpriority61 \lsdlocked0 Light List Accent 5;\lsdpriority62 \lsdlocked0 Light Grid Accent 5; +\lsdpriority63 \lsdlocked0 Medium Shading 1 Accent 5;\lsdpriority64 \lsdlocked0 Medium Shading 2 Accent 5;\lsdpriority65 \lsdlocked0 Medium List 1 Accent 5;\lsdpriority66 \lsdlocked0 Medium List 2 Accent 5; +\lsdpriority67 \lsdlocked0 Medium Grid 1 Accent 5;\lsdpriority68 \lsdlocked0 Medium Grid 2 Accent 5;\lsdpriority69 \lsdlocked0 Medium Grid 3 Accent 5;\lsdpriority70 \lsdlocked0 Dark List Accent 5;\lsdpriority71 \lsdlocked0 Colorful Shading Accent 5; +\lsdpriority72 \lsdlocked0 Colorful List Accent 5;\lsdpriority73 \lsdlocked0 Colorful Grid Accent 5;\lsdpriority60 \lsdlocked0 Light Shading Accent 6;\lsdpriority61 \lsdlocked0 Light List Accent 6;\lsdpriority62 \lsdlocked0 Light Grid Accent 6; +\lsdpriority63 \lsdlocked0 Medium Shading 1 Accent 6;\lsdpriority64 \lsdlocked0 Medium Shading 2 Accent 6;\lsdpriority65 \lsdlocked0 Medium List 1 Accent 6;\lsdpriority66 \lsdlocked0 Medium List 2 Accent 6; +\lsdpriority67 \lsdlocked0 Medium Grid 1 Accent 6;\lsdpriority68 \lsdlocked0 Medium Grid 2 Accent 6;\lsdpriority69 \lsdlocked0 Medium Grid 3 Accent 6;\lsdpriority70 \lsdlocked0 Dark List Accent 6;\lsdpriority71 \lsdlocked0 Colorful Shading Accent 6; +\lsdpriority72 \lsdlocked0 Colorful List Accent 6;\lsdpriority73 \lsdlocked0 Colorful Grid Accent 6;\lsdqformat1 \lsdpriority19 \lsdlocked0 Subtle Emphasis;\lsdqformat1 \lsdpriority21 \lsdlocked0 Intense Emphasis; +\lsdqformat1 \lsdpriority31 \lsdlocked0 Subtle Reference;\lsdqformat1 \lsdpriority32 \lsdlocked0 Intense Reference;\lsdqformat1 \lsdpriority33 \lsdlocked0 Book Title;\lsdsemihidden1 \lsdunhideused1 \lsdpriority37 \lsdlocked0 Bibliography; +\lsdsemihidden1 \lsdunhideused1 \lsdqformat1 \lsdpriority39 \lsdlocked0 TOC Heading;\lsdpriority41 \lsdlocked0 Plain Table 1;\lsdpriority42 \lsdlocked0 Plain Table 2;\lsdpriority43 \lsdlocked0 Plain Table 3;\lsdpriority44 \lsdlocked0 Plain Table 4; +\lsdpriority45 \lsdlocked0 Plain Table 5;\lsdpriority40 \lsdlocked0 Grid Table Light;\lsdpriority46 \lsdlocked0 Grid Table 1 Light;\lsdpriority47 \lsdlocked0 Grid Table 2;\lsdpriority48 \lsdlocked0 Grid Table 3;\lsdpriority49 \lsdlocked0 Grid Table 4; +\lsdpriority50 \lsdlocked0 Grid Table 5 Dark;\lsdpriority51 \lsdlocked0 Grid Table 6 Colorful;\lsdpriority52 \lsdlocked0 Grid Table 7 Colorful;\lsdpriority46 \lsdlocked0 Grid Table 1 Light Accent 1;\lsdpriority47 \lsdlocked0 Grid Table 2 Accent 1; +\lsdpriority48 \lsdlocked0 Grid Table 3 Accent 1;\lsdpriority49 \lsdlocked0 Grid Table 4 Accent 1;\lsdpriority50 \lsdlocked0 Grid Table 5 Dark Accent 1;\lsdpriority51 \lsdlocked0 Grid Table 6 Colorful Accent 1; +\lsdpriority52 \lsdlocked0 Grid Table 7 Colorful Accent 1;\lsdpriority46 \lsdlocked0 Grid Table 1 Light Accent 2;\lsdpriority47 \lsdlocked0 Grid Table 2 Accent 2;\lsdpriority48 \lsdlocked0 Grid Table 3 Accent 2; +\lsdpriority49 \lsdlocked0 Grid Table 4 Accent 2;\lsdpriority50 \lsdlocked0 Grid Table 5 Dark Accent 2;\lsdpriority51 \lsdlocked0 Grid Table 6 Colorful Accent 2;\lsdpriority52 \lsdlocked0 Grid Table 7 Colorful Accent 2; +\lsdpriority46 \lsdlocked0 Grid Table 1 Light Accent 3;\lsdpriority47 \lsdlocked0 Grid Table 2 Accent 3;\lsdpriority48 \lsdlocked0 Grid Table 3 Accent 3;\lsdpriority49 \lsdlocked0 Grid Table 4 Accent 3; +\lsdpriority50 \lsdlocked0 Grid Table 5 Dark Accent 3;\lsdpriority51 \lsdlocked0 Grid Table 6 Colorful Accent 3;\lsdpriority52 \lsdlocked0 Grid Table 7 Colorful Accent 3;\lsdpriority46 \lsdlocked0 Grid Table 1 Light Accent 4; +\lsdpriority47 \lsdlocked0 Grid Table 2 Accent 4;\lsdpriority48 \lsdlocked0 Grid Table 3 Accent 4;\lsdpriority49 \lsdlocked0 Grid Table 4 Accent 4;\lsdpriority50 \lsdlocked0 Grid Table 5 Dark Accent 4; +\lsdpriority51 \lsdlocked0 Grid Table 6 Colorful Accent 4;\lsdpriority52 \lsdlocked0 Grid Table 7 Colorful Accent 4;\lsdpriority46 \lsdlocked0 Grid Table 1 Light Accent 5;\lsdpriority47 \lsdlocked0 Grid Table 2 Accent 5; +\lsdpriority48 \lsdlocked0 Grid Table 3 Accent 5;\lsdpriority49 \lsdlocked0 Grid Table 4 Accent 5;\lsdpriority50 \lsdlocked0 Grid Table 5 Dark Accent 5;\lsdpriority51 \lsdlocked0 Grid Table 6 Colorful Accent 5; +\lsdpriority52 \lsdlocked0 Grid Table 7 Colorful Accent 5;\lsdpriority46 \lsdlocked0 Grid Table 1 Light Accent 6;\lsdpriority47 \lsdlocked0 Grid Table 2 Accent 6;\lsdpriority48 \lsdlocked0 Grid Table 3 Accent 6; +\lsdpriority49 \lsdlocked0 Grid Table 4 Accent 6;\lsdpriority50 \lsdlocked0 Grid Table 5 Dark Accent 6;\lsdpriority51 \lsdlocked0 Grid Table 6 Colorful Accent 6;\lsdpriority52 \lsdlocked0 Grid Table 7 Colorful Accent 6; +\lsdpriority46 \lsdlocked0 List Table 1 Light;\lsdpriority47 \lsdlocked0 List Table 2;\lsdpriority48 \lsdlocked0 List Table 3;\lsdpriority49 \lsdlocked0 List Table 4;\lsdpriority50 \lsdlocked0 List Table 5 Dark; +\lsdpriority51 \lsdlocked0 List Table 6 Colorful;\lsdpriority52 \lsdlocked0 List Table 7 Colorful;\lsdpriority46 \lsdlocked0 List Table 1 Light Accent 1;\lsdpriority47 \lsdlocked0 List Table 2 Accent 1;\lsdpriority48 \lsdlocked0 List Table 3 Accent 1; +\lsdpriority49 \lsdlocked0 List Table 4 Accent 1;\lsdpriority50 \lsdlocked0 List Table 5 Dark Accent 1;\lsdpriority51 \lsdlocked0 List Table 6 Colorful Accent 1;\lsdpriority52 \lsdlocked0 List Table 7 Colorful Accent 1; +\lsdpriority46 \lsdlocked0 List Table 1 Light Accent 2;\lsdpriority47 \lsdlocked0 List Table 2 Accent 2;\lsdpriority48 \lsdlocked0 List Table 3 Accent 2;\lsdpriority49 \lsdlocked0 List Table 4 Accent 2; +\lsdpriority50 \lsdlocked0 List Table 5 Dark Accent 2;\lsdpriority51 \lsdlocked0 List Table 6 Colorful Accent 2;\lsdpriority52 \lsdlocked0 List Table 7 Colorful Accent 2;\lsdpriority46 \lsdlocked0 List Table 1 Light Accent 3; +\lsdpriority47 \lsdlocked0 List Table 2 Accent 3;\lsdpriority48 \lsdlocked0 List Table 3 Accent 3;\lsdpriority49 \lsdlocked0 List Table 4 Accent 3;\lsdpriority50 \lsdlocked0 List Table 5 Dark Accent 3; +\lsdpriority51 \lsdlocked0 List Table 6 Colorful Accent 3;\lsdpriority52 \lsdlocked0 List Table 7 Colorful Accent 3;\lsdpriority46 \lsdlocked0 List Table 1 Light Accent 4;\lsdpriority47 \lsdlocked0 List Table 2 Accent 4; +\lsdpriority48 \lsdlocked0 List Table 3 Accent 4;\lsdpriority49 \lsdlocked0 List Table 4 Accent 4;\lsdpriority50 \lsdlocked0 List Table 5 Dark Accent 4;\lsdpriority51 \lsdlocked0 List Table 6 Colorful Accent 4; +\lsdpriority52 \lsdlocked0 List Table 7 Colorful Accent 4;\lsdpriority46 \lsdlocked0 List Table 1 Light Accent 5;\lsdpriority47 \lsdlocked0 List Table 2 Accent 5;\lsdpriority48 \lsdlocked0 List Table 3 Accent 5; +\lsdpriority49 \lsdlocked0 List Table 4 Accent 5;\lsdpriority50 \lsdlocked0 List Table 5 Dark Accent 5;\lsdpriority51 \lsdlocked0 List Table 6 Colorful Accent 5;\lsdpriority52 \lsdlocked0 List Table 7 Colorful Accent 5; +\lsdpriority46 \lsdlocked0 List Table 1 Light Accent 6;\lsdpriority47 \lsdlocked0 List Table 2 Accent 6;\lsdpriority48 \lsdlocked0 List Table 3 Accent 6;\lsdpriority49 \lsdlocked0 List Table 4 Accent 6; +\lsdpriority50 \lsdlocked0 List Table 5 Dark Accent 6;\lsdpriority51 \lsdlocked0 List Table 6 Colorful Accent 6;\lsdpriority52 \lsdlocked0 List Table 7 Colorful Accent 6;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Mention; +\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Smart Hyperlink;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Hashtag;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Unresolved Mention;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Smart Link;}}{\*\datastore 01050000 +02000000180000004d73786d6c322e534158584d4c5265616465722e362e3000000000000000000000060000 +d0cf11e0a1b11ae1000000000000000000000000000000003e000300feff090006000000000000000000000001000000010000000000000000100000feffffff00000000feffffff0000000000000000ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff +ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff +ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff +ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff +fffffffffffffffffdfffffffeffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff +ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff +ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff +ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff +ffffffffffffffffffffffffffffffff52006f006f007400200045006e00740072007900000000000000000000000000000000000000000000000000000000000000000000000000000000000000000016000500ffffffffffffffffffffffff0c6ad98892f1d411a65f0040963251e5000000000000000000000000105d +f52019e7d901feffffff00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000ffffffffffffffffffffffff00000000000000000000000000000000000000000000000000000000 +00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000ffffffffffffffffffffffff0000000000000000000000000000000000000000000000000000 +000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000ffffffffffffffffffffffff000000000000000000000000000000000000000000000000 +0000000000000000000000000000000000000000000000000105000000000000}} \ No newline at end of file diff --git a/msi-builder/README.md b/msi-builder/README.md new file mode 100644 index 000000000..eeb2023ba --- /dev/null +++ b/msi-builder/README.md @@ -0,0 +1,20 @@ +# FinchWindowsMSI + +Finch Windows MSI Tool to generate MSI installer from Finch build + +## Instructions + +[1] Build finch: `make FINCH_ROOTFS_LOCATION_ROOT=/__INSTALLFOLDER__` + +- It will inject the placeholder `__INSTALLFOLDER__` into `os\finch.yaml` for the rootfs location + +[2] Run the following command to generate the MSI installer: +`.\BuildFinchMSI.ps1 -SourcePath "" -Version ` + +**Parameters:** + +- SourcePath: Refers to the finch build `_output` folder, e.g., `C:\Users\\Repo\finch\_output\.` It's an optional parameter. If not provided, the default path is finch's _output folder. (You need to build finch before running the MSI tool). + +- Version: A required parameter that should match the version format, e.g., `0.10.2`. + +[3] The `Finch-.msi` will be generated to the `msi-builder\build` folder diff --git a/msi-builder/finch.ico b/msi-builder/finch.ico new file mode 100644 index 0000000000000000000000000000000000000000..0d9f6396c75641d20a2155564b3d20f0d6ddcbc0 GIT binary patch literal 85514 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 literal 0 HcmV?d00001 diff --git a/msi-builder/postinstall.bat b/msi-builder/postinstall.bat new file mode 100644 index 000000000..7b51231e5 --- /dev/null +++ b/msi-builder/postinstall.bat @@ -0,0 +1,17 @@ +@echo off +SET InstallDir=%~1 +SET FilePath=%InstallDir%\os\finch.yaml + +if exist "%FilePath%" ( + powershell -Command "$installPath = '%InstallDir%'.Replace('\', '/'); (Get-Content '%FilePath%') -replace '__INSTALLFOLDER__', $installPath | Set-Content '%FilePath%'" +) + +icacls "%InstallDir%\lima\data" /grant Users:(OI)(CI)M + +:: Delete files and directories if they exist +if exist "%InstallDir%\lima\data\finch\" rmdir /s /q "%InstallDir%\lima\data\finch\" +if exist "%InstallDir%\lima\data\_config\override.yaml" del /f /q "%InstallDir%\lima\data\_config\override.yaml" +if exist "%InstallDir%\lima\data\_config\user" del /f /q "%InstallDir%\lima\data\_config\user" +if exist "%InstallDir%\lima\data\_config\user.pub" del /f /q "%InstallDir%\lima\data\_config\user.pub" +if exist "%InstallDir%\lima\data\_networks\" rmdir /s /q "%InstallDir%\lima\data\_networks\" +if exist "%InstallDir%\lima\data\_disks\" rmdir /s /q "%InstallDir%\lima\data\_disks\" diff --git a/msi-builder/uninstall.bat b/msi-builder/uninstall.bat new file mode 100644 index 000000000..9d80a2121 --- /dev/null +++ b/msi-builder/uninstall.bat @@ -0,0 +1,5 @@ +@echo off +SET InstallDir=%~1 + +:: Delete files and directories if they exist +if exist "%InstallDir%\lima\" rmdir /s /q "%InstallDir%\lima\" \ No newline at end of file diff --git a/pkg/command/command.go b/pkg/command/command.go index 478aeedd4..b63b56fb2 100644 --- a/pkg/command/command.go +++ b/pkg/command/command.go @@ -24,6 +24,7 @@ type Command interface { SetStdin(io.Reader) SetStdout(io.Writer) SetStderr(io.Writer) + StdinPipe() (io.WriteCloser, error) Run() error Start() error diff --git a/pkg/command/exec.go b/pkg/command/exec.go index f09f5deb9..49c30930b 100644 --- a/pkg/command/exec.go +++ b/pkg/command/exec.go @@ -35,6 +35,10 @@ type execCmd struct { var _ Command = (*execCmd)(nil) +func (c *execCmd) String() string { + return c.Cmd.String() +} + func (c *execCmd) Output() ([]byte, error) { b, err := c.Cmd.Output() return b, wrapIfExitError(err) @@ -55,3 +59,7 @@ func (c *execCmd) SetStdout(stdout io.Writer) { func (c *execCmd) SetStderr(stderr io.Writer) { c.Stderr = stderr } + +func (c *execCmd) StdinPipe() (io.WriteCloser, error) { + return c.Cmd.StdinPipe() +} diff --git a/pkg/command/lima.go b/pkg/command/lima.go index 5819d05f9..338fbd2de 100644 --- a/pkg/command/lima.go +++ b/pkg/command/lima.go @@ -7,6 +7,10 @@ import ( "bytes" "fmt" "io" + "runtime" + "strings" + + "golang.org/x/exp/slices" "github.com/runfinch/finch/pkg/flog" "github.com/runfinch/finch/pkg/system" @@ -14,7 +18,8 @@ import ( const ( envKeyLimaHome = "LIMA_HOME" - envKeyPath = "PATH" + envKeyUnixPath = "PATH" + envKeyWinPath = "Path" ) // LimaCmdCreator creates a limactl command. @@ -50,7 +55,7 @@ type limaCmdCreator struct { systemDeps LimaCmdCreatorSystemDeps limaHomePath string limactlPath string - qemuBinPath string + binPath string } var _ LimaCmdCreator = (*limaCmdCreator)(nil) @@ -70,7 +75,7 @@ type LimaCmdCreatorSystemDeps interface { func NewLimaCmdCreator( cmdCreator Creator, logger flog.Logger, - limaHomePath, limactlPath string, qemuBinPath string, + limaHomePath, limactlPath string, binPath string, systemDeps LimaCmdCreatorSystemDeps, ) LimaCmdCreator { return &limaCmdCreator{ @@ -78,7 +83,7 @@ func NewLimaCmdCreator( logger: logger, limaHomePath: limaHomePath, limactlPath: limactlPath, - qemuBinPath: qemuBinPath, + binPath: binPath, systemDeps: systemDeps, } } @@ -122,13 +127,38 @@ func (lcc *limaCmdCreator) create(stdin io.Reader, stdout, stderr io.Writer, arg lcc.logger.Debugf("Creating limactl command: ARGUMENTS: %v, %s: %s", args, envKeyLimaHome, lcc.limaHomePath) cmd := lcc.cmdCreator.Create(lcc.limactlPath, args...) limaHomeEnv := fmt.Sprintf("%s=%s", envKeyLimaHome, lcc.limaHomePath) - path := lcc.systemDeps.Env(envKeyPath) - path = fmt.Sprintf("%s:%s", lcc.qemuBinPath, path) - pathEnv := fmt.Sprintf("%s=%s", envKeyPath, path) - env := append(lcc.systemDeps.Environ(), limaHomeEnv, pathEnv) - cmd.SetEnv(env) + var pathEnv string + var envKeyPath string + var path string + if runtime.GOOS == "windows" { + envKeyPath = envKeyWinPath + path = lcc.systemDeps.Env(envKeyPath) + path = fmt.Sprintf(`%s\;%s`, lcc.binPath, path) + pathEnv = fmt.Sprintf("%s=%s", envKeyPath, path) + } else { + envKeyPath = envKeyUnixPath + path = lcc.systemDeps.Env(envKeyPath) + path = fmt.Sprintf("%s:%s", lcc.binPath, path) + pathEnv = fmt.Sprintf("%s=%s", envKeyPath, path) + } + + newPathEnv := replaceOrAppend(lcc.systemDeps.Environ(), envKeyLimaHome, limaHomeEnv) + newPathEnv = replaceOrAppend(newPathEnv, envKeyPath, pathEnv) + + cmd.SetEnv(newPathEnv) cmd.SetStdin(stdin) cmd.SetStdout(stdout) cmd.SetStderr(stderr) return cmd } + +func replaceOrAppend(orig []string, varName, newVar string) []string { + envIdx := slices.IndexFunc(orig, func(envVar string) bool { + return strings.HasPrefix(envVar, fmt.Sprintf("%s=", varName)) + }) + + if envIdx != -1 { + return slices.Replace(orig, envIdx, envIdx+1, newVar) + } + return append(orig, newVar) +} diff --git a/pkg/command/lima_test.go b/pkg/command/lima_test.go index cf8d76651..2dbcda6f4 100644 --- a/pkg/command/lima_test.go +++ b/pkg/command/lima_test.go @@ -27,8 +27,6 @@ const ( envKeyLimaHome = "LIMA_HOME" mockQemuBinPath = "/lima/bin" mockSystemPath = "/usr/bin" - envKeyPath = "PATH" - finalPath = mockQemuBinPath + ":" + mockSystemPath ) var mockArgs = []string{"shell", "finch"} @@ -312,6 +310,8 @@ func TestLimaCmdCreator_RunWithReplacingStdout(t *testing.T) { stdout, err := os.ReadFile(stdoutFilepath) require.NoError(t, err) assert.Equal(t, tc.outOut, string(stdout)) + assert.NoError(t, stdoutFile.Close()) + assert.NoError(t, os.Remove(stdoutFilepath)) }) } } diff --git a/pkg/command/lima_unix_test.go b/pkg/command/lima_unix_test.go new file mode 100644 index 000000000..d1b704313 --- /dev/null +++ b/pkg/command/lima_unix_test.go @@ -0,0 +1,12 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +//go:build !windows +// +build !windows + +package command_test + +const ( + envKeyPath = "PATH" + finalPath = mockQemuBinPath + ":" + mockSystemPath +) diff --git a/pkg/command/lima_windows_test.go b/pkg/command/lima_windows_test.go new file mode 100644 index 000000000..71e978ef8 --- /dev/null +++ b/pkg/command/lima_windows_test.go @@ -0,0 +1,12 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +//go:build windows +// +build windows + +package command_test + +const ( + envKeyPath = "Path" + finalPath = mockQemuBinPath + `\;` + mockSystemPath +) diff --git a/pkg/config/config.go b/pkg/config/config.go index 88416b03a..795b50152 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -12,15 +12,12 @@ package config import ( "errors" "fmt" - "path" - "strconv" - "strings" + "path/filepath" "github.com/lima-vm/lima/pkg/limayaml" "github.com/spf13/afero" "gopkg.in/yaml.v3" - "github.com/runfinch/finch/pkg/command" "github.com/runfinch/finch/pkg/flog" "github.com/runfinch/finch/pkg/fmemory" "github.com/runfinch/finch/pkg/system" @@ -33,8 +30,8 @@ type AdditionalDirectory struct { // Finch represents the configuration file for Finch CLI. type Finch struct { - CPUs *int `yaml:"cpus"` - Memory *string `yaml:"memory"` + CPUs *int `yaml:"cpus,omitempty"` + Memory *string `yaml:"memory,omitempty"` // Snapshotters: the snapshotters that will be installed and configured automatically on vm init or on vm start. // Values: `soci` for SOCI snapshotter; `overlayfs` for default overlay snapshotter. Snapshotters []string `yaml:"snapshotters,omitempty"` @@ -102,7 +99,7 @@ func writeConfig(cfg *Finch, fs afero.Fs, path string) error { return fmt.Errorf("failed to write to marshal config: %w", err) } - if err := afero.WriteFile(fs, path, cfgBuf, 0o755); err != nil { + if err := afero.WriteFile(fs, path, cfgBuf, 0o600); err != nil { return fmt.Errorf("failed to write to config file: %w", err) } @@ -116,7 +113,7 @@ func ensureConfigDir(fs afero.Fs, path string, log flog.Logger) error { } if !dirExists { log.Infof("%q directory doesn't exist, attempting to create it", path) - if err := fs.Mkdir(path, 0o755); err != nil { + if err := fs.Mkdir(path, 0o700); err != nil { return fmt.Errorf("failed to create config directory: %w", err) } } @@ -130,7 +127,7 @@ func Load(fs afero.Fs, cfgPath string, log flog.Logger, systemDeps LoadSystemDep if errors.Is(err, afero.ErrFileNotFound) { log.Infof("Using default values due to missing config file at %q", cfgPath) defCfg := applyDefaults(&Finch{}, systemDeps, mem) - if err := ensureConfigDir(fs, path.Dir(cfgPath), log); err != nil { + if err := ensureConfigDir(fs, filepath.Dir(cfgPath), log); err != nil { return nil, fmt.Errorf("failed to ensure %q directory: %w", cfgPath, err) } if err := writeConfig(defCfg, fs, cfgPath); err != nil { @@ -157,28 +154,3 @@ func Load(fs afero.Fs, cfgPath string, log flog.Logger, systemDeps LoadSystemDep return defCfg, nil } - -// SupportsVirtualizationFramework checks if the user's system supports Virtualization.framework. -func SupportsVirtualizationFramework(cmdCreator command.Creator) (bool, error) { - cmd := cmdCreator.Create("sw_vers", "-productVersion") - out, err := cmd.Output() - if err != nil { - return false, fmt.Errorf("failed to run sw_vers command: %w", err) - } - - splitVer := strings.Split(string(out), ".") - if len(splitVer) == 0 { - return false, fmt.Errorf("unexpected result from string split: %v", splitVer) - } - - majorVersionInt, err := strconv.ParseInt(splitVer[0], 10, 64) - if err != nil { - return false, fmt.Errorf("failed to parse split sw_vers output (%s) into int: %w", splitVer[0], err) - } - - if majorVersionInt >= 13 { - return true, nil - } - - return false, nil -} diff --git a/pkg/config/config_darwin.go b/pkg/config/config_darwin.go new file mode 100644 index 000000000..0f71c3251 --- /dev/null +++ b/pkg/config/config_darwin.go @@ -0,0 +1,39 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +//go:build darwin + +package config + +import ( + "fmt" + "strconv" + "strings" + + "github.com/runfinch/finch/pkg/command" +) + +// SupportsVirtualizationFramework checks if the user's system supports Virtualization.framework. +func SupportsVirtualizationFramework(cmdCreator command.Creator) (bool, error) { + cmd := cmdCreator.Create("sw_vers", "-productVersion") + out, err := cmd.Output() + if err != nil { + return false, fmt.Errorf("failed to run sw_vers command: %w", err) + } + + splitVer := strings.Split(string(out), ".") + if len(splitVer) == 0 { + return false, fmt.Errorf("unexpected result from string split: %v", splitVer) + } + + majorVersionInt, err := strconv.ParseInt(splitVer[0], 10, 64) + if err != nil { + return false, fmt.Errorf("failed to parse split sw_vers output (%s) into int: %w", splitVer[0], err) + } + + if majorVersionInt >= 13 { + return true, nil + } + + return false, nil +} diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index e6a9439a8..145d8ca95 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -4,8 +4,8 @@ package config import ( - "errors" "fmt" + "runtime" "testing" "github.com/golang/mock/gomock" @@ -20,13 +20,33 @@ import ( func TestLoad(t *testing.T) { t.Parallel() - testCases := []struct { name string path string mockSvc func(fs afero.Fs, l *mocks.Logger, deps *mocks.LoadSystemDeps, mem *mocks.Memory) want *Finch wantErr error + }{ + { + name: "config file does not contain valid YAML", + path: "/config.yaml", + mockSvc: func(fs afero.Fs, l *mocks.Logger, deps *mocks.LoadSystemDeps, mem *mocks.Memory) { + require.NoError(t, afero.WriteFile(fs, "/config.yaml", []byte("this isn't YAML"), 0o600)) + }, + want: nil, + wantErr: fmt.Errorf( + "failed to unmarshal config file: %w", + &yaml.TypeError{Errors: []string{"line 1: cannot unmarshal !!str `this is...` into config.Finch"}}, + ), + }, + } + + darwinTestCases := []struct { + name string + path string + mockSvc func(fs afero.Fs, l *mocks.Logger, deps *mocks.LoadSystemDeps, mem *mocks.Memory) + want *Finch + wantErr error }{ { name: "happy path", @@ -113,33 +133,86 @@ cpus: 8 }, wantErr: nil, }, + } + + windowsTestCases := []struct { + name string + path string + mockSvc func(fs afero.Fs, l *mocks.Logger, deps *mocks.LoadSystemDeps, mem *mocks.Memory) + want *Finch + wantErr error + }{ { - name: "config file does not contain valid YAML", + name: "happy path", path: "/config.yaml", mockSvc: func(fs afero.Fs, l *mocks.Logger, deps *mocks.LoadSystemDeps, mem *mocks.Memory) { - require.NoError(t, afero.WriteFile(fs, "/config.yaml", []byte("this isn't YAML"), 0o600)) + data := ` +memory: 4GiB +cpus: 8 +` + require.NoError(t, afero.WriteFile(fs, "/config.yaml", []byte(data), 0o600)) }, - want: nil, - wantErr: fmt.Errorf( - "failed to unmarshal config file: %w", - &yaml.TypeError{Errors: []string{"line 1: cannot unmarshal !!str `this is...` into config.Finch"}}, - ), + want: &Finch{ + Memory: pointer.String("4GiB"), + CPUs: pointer.Int(8), + VMType: pointer.String("wsl2"), + }, + wantErr: nil, }, { - name: "config file doesn't pass validation", + name: "config file exists, but is empty", path: "/config.yaml", mockSvc: func(fs afero.Fs, l *mocks.Logger, deps *mocks.LoadSystemDeps, mem *mocks.Memory) { - require.NoError(t, afero.WriteFile(fs, "/config.yaml", []byte(`memory: 4GiB -cpus: 0 -`, - ), 0o600)) + require.NoError(t, afero.WriteFile(fs, "/config.yaml", []byte(""), 0o600)) }, - want: nil, - wantErr: fmt.Errorf( - "failed to validate config file: %w", - errors.New("specified number of CPUs (0) must be greater than 0"), - ), + want: &Finch{ + VMType: pointer.String("wsl2"), + }, + wantErr: nil, }, + { + name: "config file exists, but contains only some fields", + path: "/config.yaml", + mockSvc: func(fs afero.Fs, l *mocks.Logger, deps *mocks.LoadSystemDeps, mem *mocks.Memory) { + require.NoError(t, afero.WriteFile(fs, "/config.yaml", []byte("memory: 2GiB"), 0o600)) + }, + want: &Finch{ + Memory: pointer.String("2GiB"), + VMType: pointer.String("wsl2"), + }, + wantErr: nil, + }, + { + name: "config file exists, but contains an unknown field", + path: "/config.yaml", + mockSvc: func(fs afero.Fs, l *mocks.Logger, deps *mocks.LoadSystemDeps, mem *mocks.Memory) { + require.NoError(t, afero.WriteFile(fs, "/config.yaml", []byte("unknownField: 2GiB"), 0o600)) + }, + want: &Finch{ + VMType: pointer.String("wsl2"), + }, + wantErr: nil, + }, + { + name: "config file does not exist", + path: "/config.yaml", + mockSvc: func(fs afero.Fs, l *mocks.Logger, deps *mocks.LoadSystemDeps, mem *mocks.Memory) { + l.EXPECT().Infof("Using default values due to missing config file at %q", "/config.yaml") + }, + want: &Finch{ + VMType: pointer.String("wsl2"), + }, + wantErr: nil, + }, + } + + switch runtime.GOOS { + case "windows": + testCases = append(testCases, windowsTestCases...) + case "darwin": + testCases = append(testCases, darwinTestCases...) + default: + t.Skip("Not running tests for " + runtime.GOOS) } for _, tc := range testCases { diff --git a/pkg/config/config_windows.go b/pkg/config/config_windows.go new file mode 100644 index 000000000..61ecec773 --- /dev/null +++ b/pkg/config/config_windows.go @@ -0,0 +1,15 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +//go:build windows + +package config + +import ( + "github.com/runfinch/finch/pkg/command" +) + +// SupportsWSL2 checks if system supports WSL2 and sets default version to 2. +func SupportsWSL2(cmdCreator command.Creator) error { + return cmdCreator.Create("wsl", "--set-default-version", "2").Run() +} diff --git a/pkg/config/defaults.go b/pkg/config/defaults.go index 66c0a9c92..e77c8c049 100644 --- a/pkg/config/defaults.go +++ b/pkg/config/defaults.go @@ -4,47 +4,18 @@ package config import ( - "math" - - "github.com/docker/go-units" - "github.com/xorcare/pointer" - "github.com/runfinch/finch/pkg/fmemory" ) -const ( - // 2,147,483,648 => 2GiB. - fallbackMemory float64 = 2_147_483_648 - fallbackCPUs int = 2 -) - // applyDefaults sets default configuration options if they are not already set. func applyDefaults(cfg *Finch, deps LoadSystemDeps, mem fmemory.Memory) *Finch { - if cfg.CPUs == nil { - defaultCPUs := int(math.Round(float64(deps.NumCPU()) * 0.25)) - if defaultCPUs >= fallbackCPUs { - cfg.CPUs = pointer.Int(defaultCPUs) - } else { - cfg.CPUs = pointer.Int(fallbackCPUs) - } - } + cpuDefault(cfg, deps) - if cfg.Memory == nil { - defaultMemory := math.Round(float64(mem.TotalMemory()) * 0.25) - if defaultMemory >= fallbackMemory { - cfg.Memory = pointer.String(units.BytesSize(defaultMemory)) - } else { - cfg.Memory = pointer.String(units.BytesSize(fallbackMemory)) - } - } + memoryDefault(cfg, mem) - if cfg.VMType == nil { - cfg.VMType = pointer.String("qemu") - } + vmDefault(cfg) - if cfg.Rosetta == nil { - cfg.Rosetta = pointer.Bool(false) - } + rosettaDefault(cfg) return cfg } diff --git a/pkg/config/defaults_darwin.go b/pkg/config/defaults_darwin.go new file mode 100644 index 000000000..3dffabcca --- /dev/null +++ b/pkg/config/defaults_darwin.go @@ -0,0 +1,55 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +//go:build darwin + +package config + +import ( + "math" + + "github.com/docker/go-units" + "github.com/xorcare/pointer" + + "github.com/runfinch/finch/pkg/fmemory" +) + +const ( + // 2,147,483,648 => 2GiB. + fallbackMemory float64 = 2_147_483_648 + fallbackCPUs int = 2 +) + +func vmDefault(cfg *Finch) { + if cfg.VMType == nil { + cfg.VMType = pointer.String("qemu") + } +} + +func rosettaDefault(cfg *Finch) { + if cfg.Rosetta == nil { + cfg.Rosetta = pointer.Bool(false) + } +} + +func memoryDefault(cfg *Finch, mem fmemory.Memory) { + if cfg.Memory == nil { + defaultMemory := math.Round(float64(mem.TotalMemory()) * 0.25) + if defaultMemory >= fallbackMemory { + cfg.Memory = pointer.String(units.BytesSize(defaultMemory)) + } else { + cfg.Memory = pointer.String(units.BytesSize(fallbackMemory)) + } + } +} + +func cpuDefault(cfg *Finch, deps LoadSystemDeps) { + if cfg.CPUs == nil { + defaultCPUs := int(math.Round(float64(deps.NumCPU()) * 0.25)) + if defaultCPUs >= fallbackCPUs { + cfg.CPUs = pointer.Int(defaultCPUs) + } else { + cfg.CPUs = pointer.Int(fallbackCPUs) + } + } +} diff --git a/pkg/config/defaults_test.go b/pkg/config/defaults_test.go index 23235f96a..ee713eb5c 100644 --- a/pkg/config/defaults_test.go +++ b/pkg/config/defaults_test.go @@ -4,6 +4,7 @@ package config import ( + "runtime" "testing" "github.com/golang/mock/gomock" @@ -16,7 +17,13 @@ import ( func Test_applyDefaults(t *testing.T) { t.Parallel() - testCases := []struct { + var testCases []struct { + name string + cfg *Finch + mockSvc func(deps *mocks.LoadSystemDeps, mem *mocks.Memory) + want *Finch + } + darwinTestCases := []struct { name string cfg *Finch mockSvc func(deps *mocks.LoadSystemDeps, mem *mocks.Memory) @@ -85,6 +92,42 @@ func Test_applyDefaults(t *testing.T) { }, } + windowsTestCases := []struct { + name string + cfg *Finch + mockSvc func(deps *mocks.LoadSystemDeps, mem *mocks.Memory) + want *Finch + }{ + { + name: "happy path", + cfg: &Finch{}, + mockSvc: func(deps *mocks.LoadSystemDeps, mem *mocks.Memory) { + }, + want: &Finch{ + VMType: pointer.String("wsl2"), + }, + }, + { + name: "does not fill wsl2 default when it's set to something else", + cfg: &Finch{ + VMType: pointer.String("wsl"), + }, + mockSvc: func(deps *mocks.LoadSystemDeps, mem *mocks.Memory) { + }, + want: &Finch{ + VMType: pointer.String("wsl"), + }, + }, + } + switch runtime.GOOS { + case "windows": + testCases = append(testCases, windowsTestCases...) + case "darwin": + testCases = append(testCases, darwinTestCases...) + default: + t.Skip("Not running tests for " + runtime.GOOS) + } + for _, tc := range testCases { tc := tc t.Run(tc.name, func(t *testing.T) { diff --git a/pkg/config/defaults_windows.go b/pkg/config/defaults_windows.go new file mode 100644 index 000000000..88a9eb204 --- /dev/null +++ b/pkg/config/defaults_windows.go @@ -0,0 +1,30 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +//go:build windows + +package config + +import ( + "github.com/xorcare/pointer" + + "github.com/runfinch/finch/pkg/fmemory" +) + +// Does not matter if Rosetta is set, no-op. +func rosettaDefault(_ *Finch) { +} + +func vmDefault(cfg *Finch) { + if cfg.VMType == nil { + cfg.VMType = pointer.String("wsl2") + } +} + +// no-op , not configurable in wsl. +func memoryDefault(_ *Finch, _ fmemory.Memory) { +} + +// no-op , not configurable in wsl. +func cpuDefault(_ *Finch, _ LoadSystemDeps) { +} diff --git a/pkg/config/lima_config_applier.go b/pkg/config/lima_config_applier.go index becd01bb0..ab21ebe78 100644 --- a/pkg/config/lima_config_applier.go +++ b/pkg/config/lima_config_applier.go @@ -7,6 +7,7 @@ import ( "fmt" "strings" + goyaml "github.com/goccy/go-yaml" "github.com/lima-vm/lima/pkg/limayaml" "github.com/spf13/afero" "github.com/xorcare/pointer" @@ -51,6 +52,7 @@ sudo systemctl restart containerd.service ` userModeEmulationProvisioningScriptHeader = "# cross-arch tools" + wslDiskFormatScriptHeader = "# wsl disk format script" ) // LimaConfigApplierSystemDeps contains the system dependencies for LimaConfigApplier. @@ -95,7 +97,7 @@ func (lca *limaConfigApplier) Apply(isInit bool) error { if cfgExists, err := afero.Exists(lca.fs, lca.limaConfigPath); err != nil { return fmt.Errorf("error checking if file at path %s exists, error: %w", lca.limaConfigPath, err) } else if !cfgExists { - if err := afero.WriteFile(lca.fs, lca.limaConfigPath, []byte(""), 0o644); err != nil { + if err := afero.WriteFile(lca.fs, lca.limaConfigPath, []byte(""), 0o600); err != nil { return fmt.Errorf("failed to create the an empty lima config file: %w", err) } } @@ -110,6 +112,13 @@ func (lca *limaConfigApplier) Apply(isInit bool) error { return fmt.Errorf("failed to unmarshal the lima config file: %w", err) } + // Unmarshall with custom unmarshaler for Disk: + // https://github.com/lima-vm/lima/blob/v0.17.2/pkg/limayaml/load.go#L16 + if err := goyaml.UnmarshalWithOptions(b, &limaCfg, goyaml.DisallowDuplicateKey(), + goyaml.CustomUnmarshaler[limayaml.Disk](unmarshalDisk)); err != nil { + return fmt.Errorf("failed to unmarshal the lima config file: %w", err) + } + limaCfg.CPUs = lca.cfg.CPUs limaCfg.Memory = lca.cfg.Memory limaCfg.Mounts = []limayaml.Mount{} @@ -149,99 +158,28 @@ func (lca *limaConfigApplier) Apply(isInit bool) error { toggleSnaphotters(&limaCfg, snapshotters) + if *lca.cfg.VMType != "wsl2" && len(limaCfg.AdditionalDisks) == 0 { + limaCfg.AdditionalDisks = append(limaCfg.AdditionalDisks, limayaml.Disk{ + Name: "finch", + }) + } + + if *lca.cfg.VMType == "wsl2" { + ensureWslDiskFormatScript(&limaCfg) + } + limaCfgBytes, err := yaml.Marshal(limaCfg) if err != nil { return fmt.Errorf("failed to marshal the lima config file: %w", err) } - if err := afero.WriteFile(lca.fs, lca.limaConfigPath, limaCfgBytes, 0o644); err != nil { + if err := afero.WriteFile(lca.fs, lca.limaConfigPath, limaCfgBytes, 0o600); err != nil { return fmt.Errorf("failed to write to the lima config file: %w", err) } return nil } -// applyInit changes settings that will only apply to the VM after a new init. -func (lca *limaConfigApplier) applyInit(limaCfg *limayaml.LimaYAML) (*limayaml.LimaYAML, error) { - hasSupport, hasSupportErr := SupportsVirtualizationFramework(lca.cmdCreator) - if *lca.cfg.Rosetta && - lca.systemDeps.OS() == "darwin" && - lca.systemDeps.Arch() == "arm64" { - if hasSupportErr != nil { - return nil, fmt.Errorf("failed to check for virtualization framework support: %w", hasSupportErr) - } - if !hasSupport { - return nil, fmt.Errorf(`system does not have virtualization framework support, change vmType to "qemu"`) - } - - limaCfg.Rosetta.Enabled = pointer.Bool(true) - limaCfg.Rosetta.BinFmt = pointer.Bool(true) - limaCfg.VMType = pointer.String("vz") - limaCfg.MountType = pointer.String("virtiofs") - toggleUserModeEmulationInstallationScript(limaCfg, false) - } else { - if *lca.cfg.VMType == "vz" { - if hasSupportErr != nil { - return nil, fmt.Errorf("failed to check for virtualization framework support: %w", hasSupportErr) - } - if !hasSupport { - return nil, fmt.Errorf(`system does not have virtualization framework support, change vmType to "qemu"`) - } - limaCfg.MountType = pointer.String("virtiofs") - } else if *lca.cfg.VMType == "qemu" { - limaCfg.MountType = pointer.String("reverse-sshfs") - } - limaCfg.Rosetta.Enabled = pointer.Bool(false) - limaCfg.Rosetta.BinFmt = pointer.Bool(false) - limaCfg.VMType = lca.cfg.VMType - toggleUserModeEmulationInstallationScript(limaCfg, true) - } - - return limaCfg, nil -} - -func toggleUserModeEmulationInstallationScript(limaCfg *limayaml.LimaYAML, enabled bool) { - idx, hasScript := hasUserModeEmulationInstallationScript(limaCfg) - if !hasScript && enabled { - limaCfg.Provision = append(limaCfg.Provision, limayaml.Provision{ - Mode: "system", - Script: fmt.Sprintf(`%s -#!/bin/bash -qemu_pkgs="" -if [ ! -f /usr/bin/qemu-aarch64-static ]; then - qemu_pkgs="$qemu_pkgs qemu-user-static-aarch64" -elif [ ! -f /usr/bin/qemu-aarch64-static ]; then - qemu_pkgs="$qemu_pkgs qemu-user-static-arm" -elif [ ! -f /usr/bin/qemu-aarch64-static ]; then - qemu_pkgs="$qemu_pkgs qemu-user-static-x86" -fi - -if [[ $qemu_pkgs ]]; then - dnf install -y --setopt=install_weak_deps=False ${qemu_pkgs} -fi -`, userModeEmulationProvisioningScriptHeader), - }) - } else if hasScript && !enabled { - if len(limaCfg.Provision) > 0 { - limaCfg.Provision = append(limaCfg.Provision[:idx], limaCfg.Provision[idx+1:]...) - } - } -} - -func hasUserModeEmulationInstallationScript(limaCfg *limayaml.LimaYAML) (int, bool) { - hasCrossArchToolInstallationScript := false - var scriptIdx int - for idx, prov := range limaCfg.Provision { - trimmed := strings.Trim(prov.Script, " ") - if !hasCrossArchToolInstallationScript && strings.HasPrefix(trimmed, userModeEmulationProvisioningScriptHeader) { - hasCrossArchToolInstallationScript = true - scriptIdx = idx - } - } - - return scriptIdx, hasCrossArchToolInstallationScript -} - // toggles snapshotters and sets default snapshotter. func toggleSnaphotters(limaCfg *limayaml.LimaYAML, snapshotters map[string][2]bool) { toggleOverlayFs(limaCfg, snapshotters["overlayfs"][1]) @@ -294,3 +232,39 @@ func findSociInstallationScript(limaCfg *limayaml.LimaYAML) (int, bool) { return scriptIdx, hasSociInstallationScript } + +func ensureWslDiskFormatScript(limaCfg *limayaml.LimaYAML) { + if hasScript := findWslDiskFormatScript(limaCfg); !hasScript { + limaCfg.Provision = append(limaCfg.Provision, limayaml.Provision{ + Mode: "system", + Script: fmt.Sprintf(`%s +#!/bin/bash +mkdir -p /mnt/lima-finch +mount "$(blkid -s TYPE -t LABEL=FinchDataDisk -o device)" /mnt/lima-finch +`, wslDiskFormatScriptHeader), + }) + } +} + +func findWslDiskFormatScript(limaCfg *limayaml.LimaYAML) bool { + hasWslDiskFormatScript := false + for _, prov := range limaCfg.Provision { + trimmed := strings.Trim(prov.Script, " ") + if !hasWslDiskFormatScript && strings.HasPrefix(trimmed, wslDiskFormatScriptHeader) { + hasWslDiskFormatScript = true + break + } + } + + return hasWslDiskFormatScript +} + +// https://github.com/lima-vm/lima/blob/v0.17.2/pkg/limayaml/load.go#L16 +func unmarshalDisk(dst *limayaml.Disk, b []byte) error { + var s string + if err := goyaml.Unmarshal(b, &s); err == nil { + *dst = limayaml.Disk{Name: s} + return nil + } + return goyaml.Unmarshal(b, dst) +} diff --git a/pkg/config/lima_config_applier_darwin.go b/pkg/config/lima_config_applier_darwin.go new file mode 100644 index 000000000..0a9465f34 --- /dev/null +++ b/pkg/config/lima_config_applier_darwin.go @@ -0,0 +1,103 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +//go:build darwin + +package config + +import ( + "errors" + "fmt" + "strings" + + "github.com/lima-vm/lima/pkg/limayaml" + "github.com/xorcare/pointer" +) + +// applyInit changes settings that will only apply to the VM after a new init. +func (lca *limaConfigApplier) applyInit(limaCfg *limayaml.LimaYAML) (*limayaml.LimaYAML, error) { + hasSupport, hasSupportErr := SupportsVirtualizationFramework(lca.cmdCreator) + if *lca.cfg.Rosetta && + lca.systemDeps.Arch() == "arm64" { + if hasSupportErr != nil { + return nil, fmt.Errorf("failed to check for virtualization framework support: %w", hasSupportErr) + } + if !hasSupport { + return nil, errors.New(`system does not have virtualization framework support, change vmType to "qemu"`) + } + + limaCfg.Rosetta.Enabled = pointer.Bool(true) + limaCfg.Rosetta.BinFmt = pointer.Bool(true) + limaCfg.VMType = pointer.String("vz") + limaCfg.MountType = pointer.String("virtiofs") + toggleUserModeEmulationInstallationScript(limaCfg, false) + } else { + switch *lca.cfg.VMType { + case "vz": + { + if hasSupportErr != nil { + return nil, fmt.Errorf("failed to check for virtualization framework support: %w", hasSupportErr) + } + if !hasSupport { + return nil, errors.New(`system does not have virtualization framework support, change vmType to "qemu"`) + } + limaCfg.MountType = pointer.String("virtiofs") + } + case "qemu": + { + limaCfg.MountType = pointer.String("reverse-sshfs") + } + default: + return nil, fmt.Errorf("unsupported vm type \"%s\" for macOS", *lca.cfg.VMType) + } + + limaCfg.Rosetta.Enabled = pointer.Bool(false) + limaCfg.Rosetta.BinFmt = pointer.Bool(false) + limaCfg.VMType = lca.cfg.VMType + toggleUserModeEmulationInstallationScript(limaCfg, true) + } + + return limaCfg, nil +} + +func toggleUserModeEmulationInstallationScript(limaCfg *limayaml.LimaYAML, enabled bool) { + idx, hasScript := hasUserModeEmulationInstallationScript(limaCfg) + if !hasScript && enabled { + limaCfg.Provision = append(limaCfg.Provision, limayaml.Provision{ + Mode: "system", + Script: fmt.Sprintf(`%s +#!/bin/bash +qemu_pkgs="" +if [ ! -f /usr/bin/qemu-aarch64-static ]; then + qemu_pkgs="$qemu_pkgs qemu-user-static-aarch64" +elif [ ! -f /usr/bin/qemu-aarch64-static ]; then + qemu_pkgs="$qemu_pkgs qemu-user-static-arm" +elif [ ! -f /usr/bin/qemu-aarch64-static ]; then + qemu_pkgs="$qemu_pkgs qemu-user-static-x86" +fi + +if [[ $qemu_pkgs ]]; then + dnf install -y --setopt=install_weak_deps=False ${qemu_pkgs} +fi +`, userModeEmulationProvisioningScriptHeader), + }) + } else if hasScript && !enabled { + if len(limaCfg.Provision) > 0 { + limaCfg.Provision = append(limaCfg.Provision[:idx], limaCfg.Provision[idx+1:]...) + } + } +} + +func hasUserModeEmulationInstallationScript(limaCfg *limayaml.LimaYAML) (int, bool) { + hasCrossArchToolInstallationScript := false + var scriptIdx int + for idx, prov := range limaCfg.Provision { + trimmed := strings.Trim(prov.Script, " ") + if !hasCrossArchToolInstallationScript && strings.HasPrefix(trimmed, userModeEmulationProvisioningScriptHeader) { + hasCrossArchToolInstallationScript = true + scriptIdx = idx + } + } + + return scriptIdx, hasCrossArchToolInstallationScript +} diff --git a/pkg/config/lima_config_applier_test.go b/pkg/config/lima_config_applier_darwin_test.go similarity index 99% rename from pkg/config/lima_config_applier_test.go rename to pkg/config/lima_config_applier_darwin_test.go index c8ffb50ae..eaf578aca 100644 --- a/pkg/config/lima_config_applier_test.go +++ b/pkg/config/lima_config_applier_darwin_test.go @@ -1,6 +1,9 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 +//go:build darwin +// +build darwin + package config import ( @@ -428,7 +431,6 @@ provision: require.NoError(t, err) cmd.EXPECT().Output().Return([]byte("13.0.0"), nil) creator.EXPECT().Create("sw_vers", "-productVersion").Return(cmd) - deps.EXPECT().OS().Return("darwin") deps.EXPECT().Arch().Return("arm64") }, postRunCheck: func(t *testing.T, fs afero.Fs) { diff --git a/pkg/config/lima_config_applier_windows.go b/pkg/config/lima_config_applier_windows.go new file mode 100644 index 000000000..538445c55 --- /dev/null +++ b/pkg/config/lima_config_applier_windows.go @@ -0,0 +1,28 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +//go:build windows + +package config + +import ( + "fmt" + + "github.com/lima-vm/lima/pkg/limayaml" + "github.com/xorcare/pointer" +) + +func (lca *limaConfigApplier) applyInit(limaCfg *limayaml.LimaYAML) (*limayaml.LimaYAML, error) { + // Check if system supports wsl2 + + if err := SupportsWSL2(lca.cmdCreator); err != nil { + return nil, fmt.Errorf("wsl2 is not supported by your system %w", err) + } + if *lca.cfg.VMType == "wsl2" { + limaCfg.MountType = pointer.String("wsl2") + limaCfg.VMType = lca.cfg.VMType + } else { + return nil, fmt.Errorf("unsupported vm type \"%s\" for windows", *lca.cfg.VMType) + } + return limaCfg, nil +} diff --git a/pkg/config/nerdctl_config_applier.go b/pkg/config/nerdctl_config_applier.go index f7c170778..63254d6f5 100644 --- a/pkg/config/nerdctl_config_applier.go +++ b/pkg/config/nerdctl_config_applier.go @@ -7,6 +7,7 @@ import ( "errors" "fmt" "path" + "path/filepath" "strings" toml "github.com/pelletier/go-toml" @@ -15,7 +16,6 @@ import ( "github.com/spf13/afero/sftpfs" "github.com/runfinch/finch/pkg/fssh" - "github.com/runfinch/finch/pkg/lima/wrapper" ) const ( @@ -24,31 +24,43 @@ const ( ) type nerdctlConfigApplier struct { - dialer fssh.Dialer - fs afero.Fs - privateKeyPath string - hostUser string - lima wrapper.LimaWrapper + dialer fssh.Dialer + fs afero.Fs + privateKeyPath string + finchDir string + homeDir string + limaInstancePath string + fc *Finch } var _ NerdctlConfigApplier = (*nerdctlConfigApplier)(nil) // NewNerdctlApplier creates a new NerdctlConfigApplier that // applies nerdctl configuration changes by SSHing to the lima VM to update the nerdctl configuration file in it. -func NewNerdctlApplier(dialer fssh.Dialer, fs afero.Fs, privateKeyPath string, hostUser string, lima wrapper.LimaWrapper) NerdctlConfigApplier { +func NewNerdctlApplier( + dialer fssh.Dialer, + fs afero.Fs, + privateKeyPath, + finchDir, + homeDir string, + limaInstancePath string, + fc *Finch, +) NerdctlConfigApplier { return &nerdctlConfigApplier{ - dialer: dialer, - fs: fs, - privateKeyPath: privateKeyPath, - hostUser: hostUser, - lima: lima, + dialer: dialer, + fs: fs, + privateKeyPath: privateKeyPath, + finchDir: finchDir, + homeDir: homeDir, + limaInstancePath: limaInstancePath, + fc: fc, } } func addLineToBashrc(fs afero.Fs, profileFilePath string, profStr string, cmd string) (string, error) { if !strings.Contains(profStr, cmd) { profBufWithCmd := fmt.Sprintf("%s\n%s", profStr, cmd) - if err := afero.WriteFile(fs, profileFilePath, []byte(profBufWithCmd), 0o644); err != nil { + if err := afero.WriteFile(fs, profileFilePath, []byte(profBufWithCmd), 0o600); err != nil { return "", fmt.Errorf("failed to write to profile file: %w", err) } return profBufWithCmd, nil @@ -56,9 +68,62 @@ func addLineToBashrc(fs afero.Fs, profileFilePath string, profStr string, cmd st return profStr, nil } +// updateEnvironment adds variables to the user's shell's environment. Currently it uses ~/.bashrc because +// Bash is the default shell and Bash will not load ~/.profile if ~/.bash_profile exists (which it does). +// ~/.bash_profile sources ~/.bashrc, so ~/.bashrc is currently the best place to define additional variables. +// The [GNU docs for Bash] explain how these files work together in more details. +// The default location of DOCKER_CONFIG is ~/.docker/config.json. This config change sets the location to +// ~/.finch/config.json, but from the perspective of macOS (/Users//.finch/config.json). +// The reason that we don't set environment variables inside /root/.bashrc is that the vars inside it are +// not able to be picked up even if we specify `sudo -E`. We have to switch to root user in order to access them, while +// normally we would access the VM as the regular user. +// For more information on the variable, see the registry nerdctl docs. +// +// [GNU docs for Bash]: https://www.gnu.org/software/bash/manual/html_node/Bash-Startup-Files.html +// +// [registry nerdctl docs]: https://github.com/containerd/nerdctl/blob/master/docs/registry.md + +func updateEnvironment(fs afero.Fs, fc *Finch, finchDir, homeDir, limaVMHomeDir string) error { + cmdArr := []string{ + `export DOCKER_CONFIG="$FINCH_DIR"`, + "[ -L /usr/local/bin/docker-credential-ecr-login ] " + + `|| sudo ln -s "$FINCH_DIR"/cred-helpers/docker-credential-ecr-login /usr/local/bin/`, + `[ -L /root/.aws ] || sudo ln -fs "$AWS_DIR" /root/.aws`, + } + + awsDir := fmt.Sprintf("%s/.aws", homeDir) + + if *fc.VMType == "wsl2" { + cmdArr = append([]string{ + fmt.Sprintf(`FINCH_DIR="$(/usr/bin/wslpath '%s')"`, finchDir), + fmt.Sprintf(`AWS_DIR="$(/usr/bin/wslpath '%s')"`, awsDir), + }, cmdArr...) + } else { + cmdArr = append([]string{ + fmt.Sprintf(`FINCH_DIR=%s`, finchDir), + fmt.Sprintf(`AWS_DIR=%s`, awsDir), + }, cmdArr...) + } + + profileFilePath := fmt.Sprintf("%s/.bashrc", limaVMHomeDir) + profBuf, err := afero.ReadFile(fs, profileFilePath) + if err != nil { + return fmt.Errorf("failed to read config file %q: %w", profileFilePath, err) + } + profStr := string(profBuf) + for _, element := range cmdArr { + profStr, err = addLineToBashrc(fs, profileFilePath, profStr, element) + if err != nil { + return err + } + } + + return nil +} + // updateNerdctlConfig reads from the nerdctl config and updates values. -func updateNerdctlConfig(fs afero.Fs, user string, rootless bool) error { - nerdctlRootlessCfgPath := fmt.Sprintf("/home/%s.linux/.config/nerdctl/nerdctl.toml", user) +func updateNerdctlConfig(fs afero.Fs, homeDir string, rootless bool) error { + nerdctlRootlessCfgPath := fmt.Sprintf("%s/.config/nerdctl/nerdctl.toml", homeDir) var cfgPath string if rootless { @@ -67,12 +132,12 @@ func updateNerdctlConfig(fs afero.Fs, user string, rootless bool) error { cfgPath = nerdctlRootfulCfgPath } - if err := fs.MkdirAll(path.Dir(cfgPath), 0o755); err != nil { + if err := fs.MkdirAll(path.Dir(cfgPath), 0o700); err != nil { return fmt.Errorf("failed to create config dir (dir(filepath)) %s: %w", cfgPath, err) } if _, err := fs.Stat(cfgPath); errors.Is(err, afero.ErrFileNotFound) { - if err := afero.WriteFile(fs, cfgPath, []byte{}, 0o644); err != nil { + if err := afero.WriteFile(fs, cfgPath, []byte{}, 0o600); err != nil { return fmt.Errorf("failed to create %q with afero.WriteFile: %w", cfgPath, err) } } @@ -80,68 +145,56 @@ func updateNerdctlConfig(fs afero.Fs, user string, rootless bool) error { var cfg Nerdctl cfgBuf, err := afero.ReadFile(fs, cfgPath) if err != nil { - return fmt.Errorf("failed to read config file %s: %w", cfgPath, err) + return fmt.Errorf("failed to read config file %q: %w", cfgPath, err) } if err := toml.Unmarshal(cfgBuf, &cfg); err != nil { - return fmt.Errorf("failed to unmarshal config file %s: %w", cfgPath, err) + return fmt.Errorf("failed to unmarshal config file %q: %w", cfgPath, err) } cfg.Namespace = nerdctlNamespace updatedCfg, err := toml.Marshal(cfg) if err != nil { - return fmt.Errorf("failed to marshal config file %s: %w", cfgPath, err) + return fmt.Errorf("failed to marshal config file %q: %w", cfgPath, err) } - if err := afero.WriteFile(fs, cfgPath, updatedCfg, 0o644); err != nil { - return fmt.Errorf("failed to write to config file %s: %w", cfgPath, err) + if err := afero.WriteFile(fs, cfgPath, updatedCfg, 0o600); err != nil { + return fmt.Errorf("failed to write to config file %q: %w", cfgPath, err) } return nil } -// updateEnvironment adds variables to the user's shell's environment. Currently it uses ~/.bashrc because -// Bash is the default shell and Bash will not load ~/.profile if ~/.bash_profile exists (which it does). -// ~/.bash_profile sources ~/.bashrc, so ~/.bashrc is currently the best place to define additional variables. -// The [GNU docs for Bash] explain how these files work together in more details. -// The default location of DOCKER_CONFIG is ~/.docker/config.json. This config change sets the location to -// ~/.finch/config.json, but from the perspective of macOS (/Users//.finch/config.json). -// The reason that we don't set environment variables inside /root/.bashrc is that the vars inside it are -// not able to be picked up even if we specify `sudo -E`. We have to switch to root user in order to access them, while -// normally we would access the VM as the regular user. -// For more information on the variable, see the registry nerdctl docs. -// -// [GNU docs for Bash]: https://www.gnu.org/software/bash/manual/html_node/Bash-Startup-Files.html -// -// [registry nerdctl docs]: https://github.com/containerd/nerdctl/blob/master/docs/registry.md -func (nca *nerdctlConfigApplier) updateEnvironment(fs afero.Fs) error { - cmdArr := [4]string{ - fmt.Sprintf("export DOCKER_CONFIG=\"/Users/%s/.finch\"", nca.hostUser), - fmt.Sprintf("[ -L /usr/local/bin/docker-credential-ecr-login ] "+ - "|| sudo ln -s /Users/%s/.finch/cred-helpers/docker-credential-ecr-login /usr/local/bin/", nca.hostUser), - fmt.Sprintf("[ -L /root/.aws ] || sudo ln -fs /Users/%s/.aws /root/.aws", nca.hostUser), - } +func getLimaHomeDir(localFs afero.Fs, remoteFs afero.Fs, limaInstanceDir string, cfg *Finch) (string, error) { + var envB []byte + var envErr error - limaUser, err := nca.lima.LimaUser(false) - if err != nil { - return fmt.Errorf("failed to get lima user: %w", err) + if *cfg.VMType == "wsl2" { + envB, envErr = afero.ReadFile(localFs, filepath.Join(limaInstanceDir, "cidata", "lima.env")) + } else { + envB, envErr = afero.ReadFile(remoteFs, "/mnt/lima-cidata/lima.env") } - profileFilePath := fmt.Sprintf("/home/%s.linux/.bashrc", limaUser.Username) - profBuf, err := afero.ReadFile(fs, profileFilePath) - if err != nil { - return fmt.Errorf("failed to read config file: %w", err) + if envErr != nil { + return "", envErr } - profStr := string(profBuf) - for _, element := range cmdArr { - profStr, err = addLineToBashrc(fs, profileFilePath, profStr, element) - if err != nil { - return err + + var user, home string + lines := strings.Split(string(envB), "\n") + for _, l := range lines { + if strings.Contains(l, "LIMA_CIDATA_USER") { + user = strings.Split(l, "=")[1] + } else if strings.Contains(l, "LIMA_CIDATA_HOME") { + home = strings.Split(l, "=")[1] } } - return nil + if home != "" { + return home, nil + } + + return "/home/" + user + ".linux", nil } // Apply gets SSH and SFTP clients and uses them to update the nerdctl config. @@ -164,12 +217,17 @@ func (nca *nerdctlConfigApplier) Apply(remoteAddr string) error { sftpFs := sftpfs.New(sftpClient) + limaHomeDir, err := getLimaHomeDir(nca.fs, sftpFs, nca.limaInstancePath, nca.fc) + if err != nil { + return fmt.Errorf("failed to get lima home dir: %w", err) + } + // rootless hardcoded to false for now to match our finch.yaml file - if err := updateNerdctlConfig(sftpFs, user, false); err != nil { + if err := updateNerdctlConfig(sftpFs, limaHomeDir, false); err != nil { return fmt.Errorf("failed to update the nerdctl config file: %w", err) } - if err := nca.updateEnvironment(sftpFs); err != nil { + if err := updateEnvironment(sftpFs, nca.fc, nca.finchDir, nca.homeDir, limaHomeDir); err != nil { return fmt.Errorf("failed to update the user's .profile file: %w", err) } return nil diff --git a/pkg/config/nerdctl_config_applier_test.go b/pkg/config/nerdctl_config_applier_test.go index fe1e944b9..882107c24 100644 --- a/pkg/config/nerdctl_config_applier_test.go +++ b/pkg/config/nerdctl_config_applier_test.go @@ -7,13 +7,14 @@ import ( "errors" "fmt" "io/fs" - "os/user" + "path/filepath" "testing" "github.com/golang/mock/gomock" "github.com/spf13/afero" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/xorcare/pointer" "github.com/runfinch/finch/pkg/mocks" ) @@ -29,106 +30,99 @@ TZ6coT6ILioXcs0kX17JAAAAI2FsdmFqdXNAODg2NjVhMGJmN2NhLmFudC5hbWF6b24uY2 9tAQI= -----END OPENSSH PRIVATE KEY-----` -func TestNerdctlConfigApplier_updateEnvironment(t *testing.T) { +func Test_updateEnvironment(t *testing.T) { t.Parallel() - testCases := []struct { - name string - hostUser string - mockSvc func(t *testing.T, fs afero.Fs, lima *mocks.MockLimaWrapper) - postRunCheck func(t *testing.T, fs afero.Fs) - want error + name string + cfg *Finch + finchDir string + homeDir string + limaVMHomeDir string + mockSvc func(t *testing.T, fs afero.Fs) + postRunCheck func(t *testing.T, fs afero.Fs) + want error }{ { - name: "happy path", - hostUser: "mock_user", - mockSvc: func(t *testing.T, fs afero.Fs, lima *mocks.MockLimaWrapper) { + name: "happy path", + cfg: &Finch{ + VMType: pointer.String("qemu"), + }, + finchDir: "/finch/dir", + homeDir: "/home/dir", + limaVMHomeDir: "/home/mock_user.linux/", + mockSvc: func(t *testing.T, fs afero.Fs) { require.NoError(t, afero.WriteFile(fs, "/home/mock_user.linux/.bashrc", []byte(""), 0o644)) - - mockUser := &user.User{ - Username: "mock_user", - } - lima.EXPECT().LimaUser(false).Return(mockUser, nil).AnyTimes() }, postRunCheck: func(t *testing.T, fs afero.Fs) { fileBytes, err := afero.ReadFile(fs, "/home/mock_user.linux/.bashrc") require.NoError(t, err) assert.Equal(t, - []byte("\nexport DOCKER_CONFIG=\"/Users/mock_user/.finch\""+ - "\n[ -L /usr/local/bin/docker-credential-ecr-login ] || sudo ln -s "+ - "/Users/mock_user/.finch/cred-helpers/docker-credential-ecr-login /usr/local/bin/"+ - "\n"+"[ -L /root/.aws ] || sudo ln -fs /Users/mock_user/.aws /root/.aws"), fileBytes) + string(` +FINCH_DIR=/finch/dir +AWS_DIR=/home/dir/.aws +export DOCKER_CONFIG="$FINCH_DIR" +[ -L /usr/local/bin/docker-credential-ecr-login ] || sudo ln -s "$FINCH_DIR"/cred-helpers/docker-credential-ecr-login /usr/local/bin/ +[ -L /root/.aws ] || sudo ln -fs "$AWS_DIR" /root/.aws`), string(fileBytes)) }, want: nil, }, { - name: "happy path, file already exists and already contains expected variables", - hostUser: "mock_user", - mockSvc: func(t *testing.T, fs afero.Fs, lima *mocks.MockLimaWrapper) { + name: "happy path, file already exists and already contains expected variables", + cfg: &Finch{ + VMType: pointer.String("qemu"), + }, + finchDir: "/finch/dir", + homeDir: "/home/dir", + limaVMHomeDir: "/home/mock_user.linux/", + mockSvc: func(t *testing.T, fs afero.Fs) { require.NoError( t, afero.WriteFile( fs, "/home/mock_user.linux/.bashrc", - []byte("export DOCKER_CONFIG=\"/Users/mock_user/.finch\""+"\n"+"[ -L /usr/local/bin/docker-credential-ecr-login ] "+ - "|| sudo ln -s /Users/mock_user/.finch/cred-helpers/docker-credential-ecr-login /usr/local/bin/"+ - "\n"+"[ -L /root/.aws ] || sudo ln -fs /Users/mock_user/.aws /root/.aws"), + []byte(` +FINCH_DIR=/finch/dir +AWS_DIR=/home/dir/.aws +export DOCKER_CONFIG="$FINCH_DIR" +[ -L /usr/local/bin/docker-credential-ecr-login ] || sudo ln -s "$FINCH_DIR"/cred-helpers/docker-credential-ecr-login /usr/local/bin/ +[ -L /root/.aws ] || sudo ln -fs "$AWS_DIR" /root/.aws)`, + ), 0o644, ), ) - - mockUser := &user.User{ - Username: "mock_user", - } - lima.EXPECT().LimaUser(false).Return(mockUser, nil).AnyTimes() }, postRunCheck: func(t *testing.T, fs afero.Fs) { fileBytes, err := afero.ReadFile(fs, "/home/mock_user.linux/.bashrc") require.NoError(t, err) - assert.Equal(t, []byte(`export DOCKER_CONFIG="/Users/mock_user/.finch"`+"\n"+ - "[ -L /usr/local/bin/docker-credential-ecr-login ] "+ - "|| sudo ln -s /Users/mock_user/.finch/cred-helpers/docker-credential-ecr-login /usr/local/bin/"+ - "\n"+"[ -L /root/.aws ] || sudo ln -fs /Users/mock_user/.aws /root/.aws"), fileBytes) + assert.Equal(t, string(` +FINCH_DIR=/finch/dir +AWS_DIR=/home/dir/.aws +export DOCKER_CONFIG="$FINCH_DIR" +[ -L /usr/local/bin/docker-credential-ecr-login ] || sudo ln -s "$FINCH_DIR"/cred-helpers/docker-credential-ecr-login /usr/local/bin/ +[ -L /root/.aws ] || sudo ln -fs "$AWS_DIR" /root/.aws)`), string(fileBytes)) }, want: nil, }, { - name: ".bashrc file doesn't exist", - hostUser: "mock_user", - mockSvc: func(t *testing.T, fs afero.Fs, lima *mocks.MockLimaWrapper) { - mockUser := &user.User{ - Username: "mock_user", - } - lima.EXPECT().LimaUser(false).Return(mockUser, nil).AnyTimes() + name: ".bashrc file doesn't exist", + cfg: &Finch{ + VMType: pointer.String("qemu"), }, - postRunCheck: func(t *testing.T, fs afero.Fs) {}, + finchDir: "/finch/dir", + homeDir: "/home/dir", + limaVMHomeDir: "/home/mock_user.linux", + mockSvc: func(t *testing.T, fs afero.Fs) {}, + postRunCheck: func(t *testing.T, fs afero.Fs) {}, want: fmt.Errorf( - "failed to read config file: %w", - &fs.PathError{Op: "open", Path: "/home/mock_user.linux/.bashrc", Err: errors.New("file does not exist")}, + "failed to read config file %q: %w", + filepath.ToSlash(filepath.Join(string(filepath.Separator), "home", "mock_user.linux", ".bashrc")), + &fs.PathError{ + Op: "open", + Path: filepath.Join(string(filepath.Separator), "home", "mock_user.linux", ".bashrc"), + Err: errors.New("file does not exist"), + }, ), }, - { - name: "host user is not a valid linux username", - hostUser: "invalid.user", - mockSvc: func(t *testing.T, fs afero.Fs, lima *mocks.MockLimaWrapper) { - require.NoError(t, afero.WriteFile(fs, "/home/lima.linux/.bashrc", []byte(""), 0o644)) - - mockUser := &user.User{ - Username: "lima", - } - lima.EXPECT().LimaUser(false).Return(mockUser, nil).AnyTimes() - }, - postRunCheck: func(t *testing.T, fs afero.Fs) { - fileBytes, err := afero.ReadFile(fs, "/home/lima.linux/.bashrc") - require.NoError(t, err) - assert.Equal(t, - []byte("\nexport DOCKER_CONFIG=\"/Users/invalid.user/.finch\""+ - "\n[ -L /usr/local/bin/docker-credential-ecr-login ] || sudo ln -s "+ - "/Users/invalid.user/.finch/cred-helpers/docker-credential-ecr-login /usr/local/bin/"+ - "\n"+"[ -L /root/.aws ] || sudo ln -fs /Users/invalid.user/.aws /root/.aws"), fileBytes) - }, - want: nil, - }, } for _, tc := range testCases { @@ -136,14 +130,10 @@ func TestNerdctlConfigApplier_updateEnvironment(t *testing.T) { t.Run(tc.name, func(t *testing.T) { t.Parallel() - ctrl := gomock.NewController(t) fs := afero.NewMemMapFs() - d := mocks.NewDialer(ctrl) - lima := mocks.NewMockLimaWrapper(ctrl) - tc.mockSvc(t, fs, lima) - nca, _ := NewNerdctlApplier(d, fs, "/private-key", tc.hostUser, lima).(*nerdctlConfigApplier) - got := nca.updateEnvironment(fs) + tc.mockSvc(t, fs) + got := updateEnvironment(fs, tc.cfg, tc.finchDir, tc.homeDir, tc.limaVMHomeDir) require.Equal(t, tc.want, got) tc.postRunCheck(t, fs) @@ -156,7 +146,7 @@ func Test_updateNerdctlConfig(t *testing.T) { testCases := []struct { name string - user string + homeDir string rootless bool mockSvc func(t *testing.T, fs afero.Fs) postRunCheck func(t *testing.T, fs afero.Fs) @@ -164,7 +154,7 @@ func Test_updateNerdctlConfig(t *testing.T) { }{ { name: "happy path, rootless", - user: "mock_user", + homeDir: "/home/mock_user.linux", rootless: true, mockSvc: func(t *testing.T, fs afero.Fs) {}, postRunCheck: func(t *testing.T, fs afero.Fs) { @@ -176,7 +166,7 @@ func Test_updateNerdctlConfig(t *testing.T) { }, { name: "happy path, rootful", - user: "mock_user", + homeDir: "/home/mock_user.linux", rootless: false, mockSvc: func(t *testing.T, fs afero.Fs) {}, postRunCheck: func(t *testing.T, fs afero.Fs) { @@ -188,7 +178,7 @@ func Test_updateNerdctlConfig(t *testing.T) { }, { name: "happy path, config already exists", - user: "mock_user", + homeDir: "/home/mock_user.linux", rootless: true, mockSvc: func(t *testing.T, fs afero.Fs) { err := afero.WriteFile(fs, "/home/mock_user.linux/.config/nerdctl/nerdctl.toml", @@ -204,7 +194,7 @@ func Test_updateNerdctlConfig(t *testing.T) { }, { name: "config contains invalid TOML", - user: "mock_user", + homeDir: "/home/mock_user.linux", rootless: true, mockSvc: func(t *testing.T, fs afero.Fs) { err := afero.WriteFile(fs, "/home/mock_user.linux/.config/nerdctl/nerdctl.toml", []byte("{not toml}"), 0o644) @@ -212,7 +202,7 @@ func Test_updateNerdctlConfig(t *testing.T) { }, postRunCheck: func(t *testing.T, fs afero.Fs) {}, want: fmt.Errorf( - "failed to unmarshal config file %s: %w", + "failed to unmarshal config file %q: %w", "/home/mock_user.linux/.config/nerdctl/nerdctl.toml", errors.New("(1, 1): parsing error: keys cannot contain { character"), ), @@ -227,7 +217,7 @@ func Test_updateNerdctlConfig(t *testing.T) { fs := afero.NewMemMapFs() tc.mockSvc(t, fs) - got := updateNerdctlConfig(fs, tc.user, tc.rootless) + got := updateNerdctlConfig(fs, tc.homeDir, tc.rootless) require.Equal(t, tc.want, got) tc.postRunCheck(t, fs) @@ -236,37 +226,38 @@ func Test_updateNerdctlConfig(t *testing.T) { } func TestNerdctlConfigApplier_Apply(t *testing.T) { + privateKeyPath := filepath.Join(string(filepath.Separator), "private-key") t.Parallel() testCases := []struct { - name string - path string - hostUser string - remoteAddr string - mockSvc func(t *testing.T, fs afero.Fs, d *mocks.Dialer) - want error + name string + path string + remoteAddr string + limaInstanceDir string + cfg *Finch + mockSvc func(t *testing.T, fs afero.Fs, d *mocks.Dialer) + want error }{ { name: "private key path doesn't exist", - path: "/private-key", + path: privateKeyPath, remoteAddr: "", - hostUser: "mock-host-user", mockSvc: func(t *testing.T, fs afero.Fs, d *mocks.Dialer) { }, want: fmt.Errorf( "failed to create ssh client config: %w", fmt.Errorf( "failed to open private key file: %w", - &fs.PathError{Op: "open", Path: "/private-key", Err: errors.New("file does not exist")}, + &fs.PathError{Op: "open", Path: privateKeyPath, Err: errors.New("file does not exist")}, ), ), }, { name: "dialer fails to create the ssh connection", - path: "/private-key", + path: privateKeyPath, remoteAddr: "deadbeef", mockSvc: func(t *testing.T, fs afero.Fs, d *mocks.Dialer) { - err := afero.WriteFile(fs, "/private-key", []byte(fakeSSHKey), 0o600) + err := afero.WriteFile(fs, privateKeyPath, []byte(fakeSSHKey), 0o600) require.NoError(t, err) d.EXPECT().Dial("tcp", "deadbeef", gomock.Any()).Return(nil, fmt.Errorf("some error")) @@ -283,10 +274,9 @@ func TestNerdctlConfigApplier_Apply(t *testing.T) { ctrl := gomock.NewController(t) fs := afero.NewMemMapFs() d := mocks.NewDialer(ctrl) - lima := mocks.NewMockLimaWrapper(ctrl) tc.mockSvc(t, fs, d) - got := NewNerdctlApplier(d, fs, tc.path, tc.hostUser, lima).Apply(tc.remoteAddr) + got := NewNerdctlApplier(d, fs, tc.path, "", "", "", &Finch{}).Apply(tc.remoteAddr) assert.Equal(t, tc.want, got) }) diff --git a/pkg/config/validate.go b/pkg/config/validate_darwin.go similarity index 98% rename from pkg/config/validate.go rename to pkg/config/validate_darwin.go index 9ab3d6cce..141fedffe 100644 --- a/pkg/config/validate.go +++ b/pkg/config/validate_darwin.go @@ -1,6 +1,8 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 +//go:build darwin + package config import ( diff --git a/pkg/config/validate_test.go b/pkg/config/validate_darwin_test.go similarity index 99% rename from pkg/config/validate_test.go rename to pkg/config/validate_darwin_test.go index d355fce96..46830a6bc 100644 --- a/pkg/config/validate_test.go +++ b/pkg/config/validate_darwin_test.go @@ -1,6 +1,8 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 +//go:build darwin + package config import ( diff --git a/pkg/config/validate_windows.go b/pkg/config/validate_windows.go new file mode 100644 index 000000000..0eb78c841 --- /dev/null +++ b/pkg/config/validate_windows.go @@ -0,0 +1,15 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +//go:build windows + +package config + +import ( + "github.com/runfinch/finch/pkg/flog" + "github.com/runfinch/finch/pkg/fmemory" +) + +func validate(_ *Finch, _ flog.Logger, _ LoadSystemDeps, _ fmemory.Memory) error { + return nil +} diff --git a/pkg/dependency/credhelper/cred_helper.go b/pkg/dependency/credhelper/cred_helper.go index a5fc1fab0..7fa94770c 100644 --- a/pkg/dependency/credhelper/cred_helper.go +++ b/pkg/dependency/credhelper/cred_helper.go @@ -6,6 +6,7 @@ package credhelper import ( "fmt" + "path/filepath" "github.com/spf13/afero" @@ -28,10 +29,10 @@ func NewDependencyGroup( fp path.Finch, logger flog.Logger, fc *config.Finch, - user string, + finchDir string, arch string, ) *dependency.Group { - deps := newDeps(execCmdCreator, fs, fp, logger, fc, user, arch) + deps := newDeps(execCmdCreator, fs, fp, logger, fc, finchDir, arch) return dependency.NewGroup(deps, description, errMsg) } @@ -49,7 +50,7 @@ func newDeps( fp path.Finch, logger flog.Logger, fc *config.Finch, - user string, + finchDir string, arch string, ) []dependency.Dependency { var deps []dependency.Dependency @@ -63,8 +64,7 @@ func newDeps( return deps } configs := map[string]helperConfig{} - installFolder := fmt.Sprintf("/Users/%s/.finch/cred-helpers/", user) - finchPath := fmt.Sprintf("/Users/%s/.finch/", user) + installFolder := filepath.Join(finchDir, "cred-helpers") const versionEcr = "0.7.0" const hashARM64 = "sha256:ff14a4da40d28a2d2d81a12a7c9c36294ddf8e6439780c4ccbc96622991f3714" @@ -76,7 +76,7 @@ func newDeps( binaryName: "docker-credential-ecr-login", credHelperURL: credHelperURLEcr, installFolder: installFolder, - finchPath: finchPath, + finchPath: finchDir, } if arch == "arm64" { @@ -89,7 +89,7 @@ func newDeps( for _, helper := range fc.CredsHelpers { if configs[helper] != (helperConfig{}) { - binaries := newCredHelperBinary(fp, fs, execCmdCreator, logger, helper, user, configs[helper]) + binaries := newCredHelperBinary(fp, fs, execCmdCreator, logger, helper, configs[helper]) deps = append(deps, dependency.Dependency(binaries)) } } diff --git a/pkg/dependency/credhelper/cred_helper_binary.go b/pkg/dependency/credhelper/cred_helper_binary.go index f3f2f241e..c19a00074 100644 --- a/pkg/dependency/credhelper/cred_helper_binary.go +++ b/pkg/dependency/credhelper/cred_helper_binary.go @@ -27,14 +27,13 @@ type credhelperbin struct { cmdCreator command.Creator l flog.Logger helper string - user string hcfg helperConfig } var _ dependency.Dependency = (*credhelperbin)(nil) func newCredHelperBinary(fp path.Finch, fs afero.Fs, cmdCreator command.Creator, l flog.Logger, helper string, - user string, hcfg helperConfig, + hcfg helperConfig, ) *credhelperbin { return &credhelperbin{ // TODO: consider replacing fp with only the strings that are used instead of the entire type @@ -43,7 +42,6 @@ func newCredHelperBinary(fp path.Finch, fs afero.Fs, cmdCreator command.Creator, cmdCreator: cmdCreator, l: l, helper: helper, - user: user, hcfg: hcfg, } } @@ -61,6 +59,7 @@ func updateConfigFile(bin *credhelperbin) error { if err != nil { return err } + JSONstr := fmt.Sprintf("{\"credsStore\":\"%s\"}", binCfgName) JSON := []byte(JSONstr) _, err = file.Write(JSON) @@ -84,7 +83,7 @@ func updateConfigFile(bin *credhelperbin) error { } credsStore := cfg.CredentialsStore if credsStore != binCfgName { - file, err := bin.fs.OpenFile(cfgPath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o755) + file, err := bin.fs.OpenFile(cfgPath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o600) if err != nil { return err } diff --git a/pkg/dependency/credhelper/cred_helper_binary_test.go b/pkg/dependency/credhelper/cred_helper_binary_test.go index c132a44a7..279881afb 100644 --- a/pkg/dependency/credhelper/cred_helper_binary_test.go +++ b/pkg/dependency/credhelper/cred_helper_binary_test.go @@ -6,6 +6,7 @@ package credhelper import ( "fmt" "io/fs" + "path/filepath" "testing" "github.com/runfinch/finch/pkg/mocks" @@ -25,7 +26,7 @@ const ( func Test_credHelperConfigName(t *testing.T) { t.Parallel() - got := newCredHelperBinary("", nil, nil, nil, "", "user", + got := newCredHelperBinary("", nil, nil, nil, "", helperConfig{ "docker-credential-cred-helper", "", "", "", "", @@ -36,12 +37,12 @@ func Test_credHelperConfigName(t *testing.T) { func Test_fullInstallPath(t *testing.T) { t.Parallel() - got := newCredHelperBinary("", nil, nil, nil, "", "user", + got := newCredHelperBinary("", nil, nil, nil, "", helperConfig{ "docker-credential-cred-helper", "", "", "/folder/", "", }).fullInstallPath() - assert.Equal(t, "/folder/docker-credential-cred-helper", got) + assert.Equal(t, filepath.Join(string(filepath.Separator), "folder", "docker-credential-cred-helper"), got) } func Test_updateConfigFile(t *testing.T) { @@ -126,7 +127,7 @@ func Test_updateConfigFile(t *testing.T) { "mock_prefix/.finch/", } // hash of an empty file - got := updateConfigFile(newCredHelperBinary(mockFinchPath, mFs, nil, l, "ecr-login", "", hc)) + got := updateConfigFile(newCredHelperBinary(mockFinchPath, mFs, nil, l, "ecr-login", hc)) assert.Equal(t, tc.want, got) tc.postRunCheck(t, mFs) @@ -216,7 +217,7 @@ func TestBinaries_Installed(t *testing.T) { "mock_prefix/.finch/", } // hash of an empty file - got := newCredHelperBinary(mockFinchPath, mFs, nil, l, "", "", hc).Installed() + got := newCredHelperBinary(mockFinchPath, mFs, nil, l, "", hc).Installed() assert.Equal(t, tc.want, got) }) @@ -249,7 +250,7 @@ func TestBinaries_Install(t *testing.T) { creator.EXPECT().Create("curl", "--retry", "5", "--retry-max-time", "30", "--url", "https://amazon-ecr-credential-helper-releases.s3.us-east-2.amazonaws.com"+ "/0.7.0/linux-arm64/docker-credential-ecr-login", "--output", - "mock_prefix/cred-helpers/docker-credential-ecr-login").Return(cmd) + filepath.Join("mock_prefix", "cred-helpers", "docker-credential-ecr-login")).Return(cmd) }, want: nil, postRunCheck: func(t *testing.T, mFs afero.Fs) { @@ -295,7 +296,7 @@ func TestBinaries_Install(t *testing.T) { "mock_prefix/.finch/", } fc := "ecr-login" - got := newCredHelperBinary(mockFinchPath, mFs, creator, l, fc, "", hc).Install() + got := newCredHelperBinary(mockFinchPath, mFs, creator, l, fc, hc).Install() binaryInstalled = origBinaryInstalled assert.Equal(t, tc.want, got) tc.postRunCheck(t, mFs) @@ -306,7 +307,7 @@ func TestBinaries_Install(t *testing.T) { func TestBinaries_RequiresRoot(t *testing.T) { t.Parallel() - got := newCredHelperBinary(mockFinchPath, nil, nil, nil, "", "", + got := newCredHelperBinary(mockFinchPath, nil, nil, nil, "", helperConfig{}).RequiresRoot() assert.Equal(t, false, got) } diff --git a/pkg/dependency/vmnet/binaries.go b/pkg/dependency/vmnet/binaries_unix.go similarity index 99% rename from pkg/dependency/vmnet/binaries.go rename to pkg/dependency/vmnet/binaries_unix.go index 8c65323de..799427ac6 100644 --- a/pkg/dependency/vmnet/binaries.go +++ b/pkg/dependency/vmnet/binaries_unix.go @@ -1,6 +1,8 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 +//go:build !windows + package vmnet import ( diff --git a/pkg/dependency/vmnet/binaries_test.go b/pkg/dependency/vmnet/binaries_unix_test.go similarity index 99% rename from pkg/dependency/vmnet/binaries_test.go rename to pkg/dependency/vmnet/binaries_unix_test.go index af5066455..d0ce34dc2 100644 --- a/pkg/dependency/vmnet/binaries_test.go +++ b/pkg/dependency/vmnet/binaries_unix_test.go @@ -1,6 +1,9 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 +//go:build !windows +// +build !windows + // Ensures that the binaries required for networking are installed in a privileged location. // More information here: https://github.com/lima-vm/socket_vmnet package vmnet diff --git a/pkg/dependency/vmnet/sudoers_file.go b/pkg/dependency/vmnet/sudoers_file_unix.go similarity index 99% rename from pkg/dependency/vmnet/sudoers_file.go rename to pkg/dependency/vmnet/sudoers_file_unix.go index e5f73208e..60f25a80d 100644 --- a/pkg/dependency/vmnet/sudoers_file.go +++ b/pkg/dependency/vmnet/sudoers_file_unix.go @@ -1,6 +1,8 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 +//go:build !windows + package vmnet import ( diff --git a/pkg/dependency/vmnet/sudoers_file_test.go b/pkg/dependency/vmnet/sudoers_file_unix_test.go similarity index 96% rename from pkg/dependency/vmnet/sudoers_file_test.go rename to pkg/dependency/vmnet/sudoers_file_unix_test.go index da3068822..3b635b377 100644 --- a/pkg/dependency/vmnet/sudoers_file_test.go +++ b/pkg/dependency/vmnet/sudoers_file_unix_test.go @@ -1,6 +1,9 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 +//go:build !windows +// +build !windows + // Ensures that output of `lima sudoers` is output to the correct directory. // This is necessary for networking to work without prompting the user // for their root password every time the VM is start / stopped. @@ -12,6 +15,7 @@ import ( "errors" "fmt" "io/fs" + "runtime" "testing" "github.com/runfinch/finch/pkg/mocks" @@ -23,6 +27,9 @@ import ( ) func TestSudoers_path(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip() + } t.Parallel() got := newSudoersFile(nil, nil, nil, nil).path() @@ -30,6 +37,9 @@ func TestSudoers_path(t *testing.T) { } func TestSudoers_Installed(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip() + } t.Parallel() testCases := []struct { @@ -112,6 +122,9 @@ func TestSudoers_Installed(t *testing.T) { } func TestSudoers_Install(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip() + } t.Parallel() testCases := []struct { @@ -208,6 +221,9 @@ func TestSudoers_Install(t *testing.T) { } func TestSudoers_RequiresRoot(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip() + } t.Parallel() got := newSudoersFile(nil, nil, nil, nil).RequiresRoot() diff --git a/pkg/dependency/vmnet/update_override_lima_config.go b/pkg/dependency/vmnet/update_override_lima_config_unix.go similarity index 99% rename from pkg/dependency/vmnet/update_override_lima_config.go rename to pkg/dependency/vmnet/update_override_lima_config_unix.go index 461bebd2b..6d935939a 100644 --- a/pkg/dependency/vmnet/update_override_lima_config.go +++ b/pkg/dependency/vmnet/update_override_lima_config_unix.go @@ -1,6 +1,8 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 +//go:build !windows + package vmnet import ( diff --git a/pkg/dependency/vmnet/update_override_lima_config_test.go b/pkg/dependency/vmnet/update_override_lima_config_unix_test.go similarity index 99% rename from pkg/dependency/vmnet/update_override_lima_config_test.go rename to pkg/dependency/vmnet/update_override_lima_config_unix_test.go index 403ad8e64..ced6b952b 100644 --- a/pkg/dependency/vmnet/update_override_lima_config_test.go +++ b/pkg/dependency/vmnet/update_override_lima_config_unix_test.go @@ -1,6 +1,9 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 +//go:build !windows +// +build !windows + package vmnet import ( diff --git a/pkg/dependency/vmnet/vmnet.go b/pkg/dependency/vmnet/vmnet_unix.go similarity index 99% rename from pkg/dependency/vmnet/vmnet.go rename to pkg/dependency/vmnet/vmnet_unix.go index 55a96b338..695d17d47 100644 --- a/pkg/dependency/vmnet/vmnet.go +++ b/pkg/dependency/vmnet/vmnet_unix.go @@ -1,6 +1,8 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 +//go:build !windows + // Package vmnet handles installation and configuration of dependencies needed for Lima's managed networking // and port-forwarding to work, with minimal user interaction. // diff --git a/pkg/dependency/vmnet/vmnet_test.go b/pkg/dependency/vmnet/vmnet_unix_test.go similarity index 94% rename from pkg/dependency/vmnet/vmnet_test.go rename to pkg/dependency/vmnet/vmnet_unix_test.go index 5f176ab04..f7c46f4aa 100644 --- a/pkg/dependency/vmnet/vmnet_test.go +++ b/pkg/dependency/vmnet/vmnet_unix_test.go @@ -1,6 +1,9 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 +//go:build !windows +// +build !windows + package vmnet import ( diff --git a/pkg/disk/disk.go b/pkg/disk/disk.go index 553c260a0..cb4975668 100644 --- a/pkg/disk/disk.go +++ b/pkg/disk/disk.go @@ -2,40 +2,23 @@ // SPDX-License-Identifier: Apache-2.0 // Package disk manages the persistent disk used to save containerd user data +// +//go:generate mockgen -copyright_file=../../copyright_header -destination=../mocks/pkg_disk_disk.go -package=mocks -mock_names UserDataDiskManager=UserDataDiskManager,diskFS=MockdiskFS -source=disk.go . package disk import ( - "encoding/json" - "errors" - "fmt" - "io/fs" - "path" - - limaStore "github.com/lima-vm/lima/pkg/store" "github.com/spf13/afero" "github.com/runfinch/finch/pkg/command" "github.com/runfinch/finch/pkg/config" + "github.com/runfinch/finch/pkg/flog" fpath "github.com/runfinch/finch/pkg/path" ) -const ( - // diskName must always be consistent with the value under additionalDisks in finch.yaml. - diskName = "finch" - diskSize = "50G" -) - // UserDataDiskManager is used to check the user data disk configuration and create it if needed. type UserDataDiskManager interface { EnsureUserDataDisk() error -} - -type qemuDiskInfo struct { - VirtualSize int `json:"virtual-size"` - Filename string `json:"filename"` - Format string `json:"format"` - ActualSize int `json:"actual-size"` - DirtyFlag bool `json:"dirty-flag"` + DetachUserDataDisk() error } // fs functions required for setting up the user data disk. @@ -50,8 +33,9 @@ type userDataDiskManager struct { ecc command.Creator fs diskFS finch fpath.Finch - homeDir string + rootDir string config *config.Finch + logger flog.Logger } // NewUserDataDiskManager is a constructor for UserDataDiskManager. @@ -60,190 +44,17 @@ func NewUserDataDiskManager( ecc command.Creator, fs diskFS, finch fpath.Finch, - homeDir string, + rootDir string, config *config.Finch, + logger flog.Logger, ) UserDataDiskManager { return &userDataDiskManager{ lcc: lcc, ecc: ecc, fs: fs, finch: finch, - homeDir: homeDir, + rootDir: rootDir, config: config, + logger: logger, } } - -// EnsureUserDataDisk checks the current disk configuration and fixes it if needed. -func (m *userDataDiskManager) EnsureUserDataDisk() error { - if m.limaDiskExists() { - diskPath := m.finch.UserDataDiskPath(m.homeDir) - - if *m.config.VMType == "vz" { - info, err := m.getDiskInfo(diskPath) - if err != nil { - return err - } - - // Convert the persistent disk file to RAW before Lima starts. - // Lima also does this, but since Finch uses a symlink to this file, lima won't create the new RAW file - // in the persistent location, but in its own _disks directory. - if info.Format != "raw" { - if err := m.convertToRaw(diskPath); err != nil { - return err - } - - // since convertToRaw moves the disk, the symlink needs to be recreated - if err := m.attachPersistentDiskToLimaDisk(); err != nil { - return err - } - } - } - - // if the file is not a symlink, loc will be an empty string - // both os.Readlink() and UserDataDiskPath return absolute paths, so they will be equal if equivalent - limaPath := fmt.Sprintf("%s/_disks/%s/datadisk", m.finch.LimaHomePath(), diskName) - loc, err := m.fs.ReadlinkIfPossible(limaPath) - if err != nil { - return err - } - - if loc != diskPath { - if err := m.attachPersistentDiskToLimaDisk(); err != nil { - return err - } - } - } else { - if err := m.createLimaDisk(); err != nil { - return err - } - if err := m.attachPersistentDiskToLimaDisk(); err != nil { - return err - } - } - - if m.limaDiskIsLocked() { - err := m.unlockLimaDisk() - if err != nil { - return err - } - } - - return nil -} - -func (m *userDataDiskManager) persistentDiskExists() bool { - _, err := m.fs.Stat(m.finch.UserDataDiskPath(m.homeDir)) - return err == nil -} - -func (m *userDataDiskManager) limaDiskExists() bool { - cmd := m.lcc.CreateWithoutStdio("disk", "ls", diskName, "--json") - out, err := cmd.Output() - if err != nil { - return false - } - diskListOutput := &limaStore.Disk{} - err = json.Unmarshal(out, diskListOutput) - if err != nil { - return false - } - return diskListOutput.Name == diskName -} - -func (m *userDataDiskManager) getDiskInfo(diskPath string) (*qemuDiskInfo, error) { - out, err := m.ecc.Create( - path.Join(m.finch.QEMUBinDir(), "qemu-img"), - "info", - "--output=json", - diskPath, - ).CombinedOutput() - if err != nil { - return nil, fmt.Errorf("failed to get disk info for disk at %q: %w", diskPath, err) - } - - var diskInfoJSON qemuDiskInfo - if err = json.Unmarshal(out, &diskInfoJSON); err != nil { - return nil, fmt.Errorf("failed to unmarshal disk info JSON for disk at %q: %w", diskPath, err) - } - - return &diskInfoJSON, nil -} - -func (m *userDataDiskManager) convertToRaw(diskPath string) error { - qcowPath := fmt.Sprintf("%s.qcow2", diskPath) - if err := m.fs.Rename(diskPath, qcowPath); err != nil { - return fmt.Errorf("faied to rename disk: %w", err) - } - if _, err := m.ecc.Create( - path.Join(m.finch.QEMUBinDir(), "qemu-img"), - "convert", - "-f", - "qcow2", - "-O", - "raw", - qcowPath, - diskPath, - ).CombinedOutput(); err != nil { - return fmt.Errorf("failed to convert disk %q from qcow2 to raw: %w", diskPath, err) - } - - return nil -} - -func (m *userDataDiskManager) createLimaDisk() error { - cmd := m.lcc.CreateWithoutStdio("disk", "create", diskName, "--size", diskSize, "--format", "raw") - if logs, err := cmd.CombinedOutput(); err != nil { - return fmt.Errorf("failed to create disk, debug logs:\n%s", logs) - } - return nil -} - -func (m *userDataDiskManager) attachPersistentDiskToLimaDisk() error { - limaPath := fmt.Sprintf("%s/_disks/%s/datadisk", m.finch.LimaHomePath(), diskName) - if !m.persistentDiskExists() { - disksDir := path.Dir(m.finch.UserDataDiskPath(m.homeDir)) - _, err := m.fs.Stat(disksDir) - if errors.Is(err, fs.ErrNotExist) { - if err := m.fs.MkdirAll(disksDir, 0o755); err != nil { - return fmt.Errorf("could not create persistent disk directory: %w", err) - } - } - if err = m.fs.Rename(limaPath, m.finch.UserDataDiskPath(m.homeDir)); err != nil { - return fmt.Errorf("could not move data disk to persistent path: %w", err) - } - } - - // if a datadisk already exists in the lima path, SymlinkIfPossible will no-op. - // to ensure that it symlinks properly, we have to delete the disk in the lima path - _, err := m.fs.Stat(limaPath) - if err != nil { - if !errors.Is(err, fs.ErrNotExist) { - return err - } - } else { - err = m.fs.Remove(limaPath) - if err != nil { - return err - } - } - - err = m.fs.SymlinkIfPossible(m.finch.UserDataDiskPath(m.homeDir), limaPath) - if err != nil { - return err - } - return nil -} - -func (m *userDataDiskManager) limaDiskIsLocked() bool { - lockPath := path.Join(m.finch.LimaHomePath(), "_disks", diskName, "in_use_by") - _, err := m.fs.Stat(lockPath) - return err == nil -} - -func (m *userDataDiskManager) unlockLimaDisk() error { - cmd := m.lcc.CreateWithoutStdio("disk", "unlock", diskName) - if logs, err := cmd.CombinedOutput(); err != nil { - return fmt.Errorf("failed to unlock disk, debug logs:\n%s", logs) - } - return nil -} diff --git a/pkg/disk/disk_unix.go b/pkg/disk/disk_unix.go new file mode 100644 index 000000000..dd36ae036 --- /dev/null +++ b/pkg/disk/disk_unix.go @@ -0,0 +1,228 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +//go:build !windows +// +build !windows + +package disk + +import ( + "encoding/json" + "errors" + "fmt" + "io/fs" + "path" + + "github.com/docker/go-units" + limaStore "github.com/lima-vm/lima/pkg/store" +) + +const ( + // diskName must always be consistent with the value set for AdditionalDisks in lima_config_applier.go. + diskName = "finch" + diskSizeStr = "50GB" +) + +type qemuDiskInfo struct { + VirtualSize int `json:"virtual-size"` + Filename string `json:"filename"` + Format string `json:"format"` + ActualSize int `json:"actual-size"` + DirtyFlag bool `json:"dirty-flag"` +} + +// EnsureUserDataDisk checks the current disk configuration and fixes it if needed. +func (m *userDataDiskManager) EnsureUserDataDisk() error { + if m.limaDiskExists() { + diskPath := m.finch.UserDataDiskPath(m.rootDir) + + if *m.config.VMType == "vz" { + info, err := m.getDiskInfo(diskPath) + if err != nil { + return err + } + + // Convert the persistent disk file to RAW before Lima starts. + // Lima also does this, but since Finch uses a symlink to this file, lima won't create the new RAW file + // in the persistent location, but in its own _disks directory. + if info.Format != "raw" { + if err := m.convertToRaw(diskPath); err != nil { + return err + } + + // since convertToRaw moves the disk, the symlink needs to be recreated + if err := m.attachPersistentDiskToLimaDisk(); err != nil { + return err + } + } + } + + // if the file is not a symlink, loc will be an empty string + // both os.Readlink() and UserDataDiskPath return absolute paths, so they will be equal if equivalent + limaPath := fmt.Sprintf("%s/_disks/%s/datadisk", m.finch.LimaHomePath(), diskName) + loc, err := m.fs.ReadlinkIfPossible(limaPath) + if err != nil { + return err + } + + if loc != diskPath { + if err := m.attachPersistentDiskToLimaDisk(); err != nil { + return err + } + } + } else { + if err := m.createLimaDisk(); err != nil { + return err + } + if err := m.attachPersistentDiskToLimaDisk(); err != nil { + return err + } + } + + if m.limaDiskIsLocked() { + err := m.unlockLimaDisk() + if err != nil { + return err + } + } + + return nil +} + +// DetachUserDataDisk is a no-op on Unix because Lima does the detaching. +func (m *userDataDiskManager) DetachUserDataDisk() error { + return nil +} + +func (m *userDataDiskManager) persistentDiskExists() bool { + _, err := m.fs.Stat(m.finch.UserDataDiskPath(m.rootDir)) + return err == nil +} + +func (m *userDataDiskManager) limaDiskExists() bool { + cmd := m.lcc.CreateWithoutStdio("disk", "ls", diskName, "--json") + out, err := cmd.Output() + if err != nil { + return false + } + diskListOutput := &limaStore.Disk{} + err = json.Unmarshal(out, diskListOutput) + if err != nil { + return false + } + return diskListOutput.Name == diskName +} + +func (m *userDataDiskManager) getDiskInfo(diskPath string) (*qemuDiskInfo, error) { + out, err := m.ecc.Create( + path.Join(m.finch.QEMUBinDir(), "qemu-img"), + "info", + "--output=json", + diskPath, + ).CombinedOutput() + if err != nil { + return nil, fmt.Errorf("failed to get disk info for disk at %q: %w", diskPath, err) + } + + var diskInfoJSON qemuDiskInfo + if err = json.Unmarshal(out, &diskInfoJSON); err != nil { + return nil, fmt.Errorf("failed to unmarshal disk info JSON for disk at %q: %w", diskPath, err) + } + + return &diskInfoJSON, nil +} + +func (m *userDataDiskManager) convertToRaw(diskPath string) error { + qcowPath := fmt.Sprintf("%s.qcow2", diskPath) + if err := m.fs.Rename(diskPath, qcowPath); err != nil { + return fmt.Errorf("faied to rename disk: %w", err) + } + if _, err := m.ecc.Create( + path.Join(m.finch.QEMUBinDir(), "qemu-img"), + "convert", + "-f", + "qcow2", + "-O", + "raw", + qcowPath, + diskPath, + ).CombinedOutput(); err != nil { + return fmt.Errorf("failed to convert disk %q from qcow2 to raw: %w", diskPath, err) + } + + return nil +} + +func (m *userDataDiskManager) createLimaDisk() error { + size, err := sizeString() + if err != nil { + return fmt.Errorf("failed to get disk size: %w", err) + } + cmd := m.lcc.CreateWithoutStdio("disk", "create", diskName, "--size", size, "--format", "raw") + if logs, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("failed to create disk, debug logs:\n%s", logs) + } + return nil +} + +func (m *userDataDiskManager) attachPersistentDiskToLimaDisk() error { + limaPath := fmt.Sprintf("%s/_disks/%s/datadisk", m.finch.LimaHomePath(), diskName) + if !m.persistentDiskExists() { + disksDir := path.Dir(m.finch.UserDataDiskPath(m.rootDir)) + _, err := m.fs.Stat(disksDir) + if errors.Is(err, fs.ErrNotExist) { + if err := m.fs.MkdirAll(disksDir, 0o700); err != nil { + return fmt.Errorf("could not create persistent disk directory: %w", err) + } + } + if err = m.fs.Rename(limaPath, m.finch.UserDataDiskPath(m.rootDir)); err != nil { + return fmt.Errorf("could not move data disk to persistent path: %w", err) + } + } + + // if a datadisk already exists in the lima path, SymlinkIfPossible will no-op. + // to ensure that it symlinks properly, we have to delete the disk in the lima path + _, err := m.fs.Stat(limaPath) + if err != nil { + if !errors.Is(err, fs.ErrNotExist) { + return err + } + } else { + err = m.fs.Remove(limaPath) + if err != nil { + return err + } + } + + err = m.fs.SymlinkIfPossible(m.finch.UserDataDiskPath(m.rootDir), limaPath) + if err != nil { + return err + } + return nil +} + +func (m *userDataDiskManager) limaDiskIsLocked() bool { + lockPath := path.Join(m.finch.LimaHomePath(), "_disks", diskName, "in_use_by") + _, err := m.fs.Stat(lockPath) + return err == nil +} + +func (m *userDataDiskManager) unlockLimaDisk() error { + cmd := m.lcc.CreateWithoutStdio("disk", "unlock", diskName) + if logs, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("failed to unlock disk, debug logs:\n%s", logs) + } + return nil +} + +func sizeString() (string, error) { + sizeB, err := units.RAMInBytes(diskSizeStr) + if err != nil { + return "", err + } + if err != nil { + return "", err + } + + return units.BytesSize(float64(sizeB)), nil +} diff --git a/pkg/disk/disk_test.go b/pkg/disk/disk_unix_test.go similarity index 98% rename from pkg/disk/disk_test.go rename to pkg/disk/disk_unix_test.go index 43dfefcf6..802237963 100644 --- a/pkg/disk/disk_test.go +++ b/pkg/disk/disk_unix_test.go @@ -1,6 +1,8 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 +//go:build !windows + package disk import ( @@ -28,7 +30,7 @@ func TestDisk_NewUserDataDiskManager(t *testing.T) { finch := fpath.Finch("mock_finch") homeDir := "mock_home" - NewUserDataDiskManager(lcc, ecc, dfs, finch, homeDir, &config.Finch{}) + NewUserDataDiskManager(lcc, ecc, dfs, finch, homeDir, &config.Finch{}, nil) } func TestUserDataDiskManager_InitializeUserDataDisk(t *testing.T) { @@ -37,10 +39,13 @@ func TestUserDataDiskManager_InitializeUserDataDisk(t *testing.T) { finch := fpath.Finch("mock_finch") homeDir := "mock_home" + size, err := sizeString() + assert.NoError(t, err) + limaPath := path.Join(finch.LimaHomePath(), "_disks", diskName, "datadisk") lockPath := path.Join(finch.LimaHomePath(), "_disks", diskName, "in_use_by") mockListArgs := []string{"disk", "ls", diskName, "--json"} - mockCreateArgs := []string{"disk", "create", diskName, "--size", diskSize, "--format", "raw"} + mockCreateArgs := []string{"disk", "create", diskName, "--size", size, "--format", "raw"} mockUnlockArgs := []string{"disk", "unlock", diskName} mockQemuImgExePath := "mock_finch/lima/bin/qemu-img" mockDiskInfoArgs := []string{ @@ -222,7 +227,7 @@ func TestUserDataDiskManager_InitializeUserDataDisk(t *testing.T) { dfs := mocks.NewMockdiskFS(ctrl) cmd := mocks.NewCommand(ctrl) tc.mockSvc(lcc, dfs, cmd, ecc) - dm := NewUserDataDiskManager(lcc, ecc, dfs, finch, homeDir, tc.cfg) + dm := NewUserDataDiskManager(lcc, ecc, dfs, finch, homeDir, tc.cfg, nil) err := dm.EnsureUserDataDisk() assert.Equal(t, tc.wantErr, err) }) diff --git a/pkg/disk/disk_windows.go b/pkg/disk/disk_windows.go new file mode 100644 index 000000000..aa92a9daf --- /dev/null +++ b/pkg/disk/disk_windows.go @@ -0,0 +1,138 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +//go:build windows + +package disk + +import ( + "archive/zip" + "bytes" + _ "embed" + "errors" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + + "github.com/runfinch/finch/pkg/winutil" +) + +// EnsureUserDataDisk checks the current disk configuration and fixes it if needed. +func (m *userDataDiskManager) EnsureUserDataDisk() error { + diskPath := m.finch.UserDataDiskPath(m.rootDir) + disksDir := filepath.Dir(diskPath) + + m.logger.Debugf("diskPath: %s", diskPath) + m.logger.Debugf("disksDir: %s", disksDir) + + if _, err := m.fs.Stat(disksDir); errors.Is(err, fs.ErrNotExist) { + if err := m.fs.MkdirAll(disksDir, 0o700); err != nil { + return fmt.Errorf("could not create persistent disk directory: %w", err) + } + } else if err != nil { + return fmt.Errorf("error stating disksDir %q: %w", disksDir, err) + } + + if _, err := m.fs.Stat(diskPath); errors.Is(err, fs.ErrNotExist) { + if err := m.createDisk(diskPath); err != nil { + return fmt.Errorf("could not create persistent disk: %w", err) + } + } else if err != nil { + return fmt.Errorf("error stating disksDir %q: %w", diskPath, err) + } + + if err := m.attachDisk(diskPath); err != nil { + return fmt.Errorf("could not attach persistent disk: %w", err) + } + + return nil +} + +// DetachUserDataDisk unmounts the disk in wsl. +func (m *userDataDiskManager) DetachUserDataDisk() error { + cmd := m.ecc.Create( + "wsl.exe", + "--unmount", + `\\?\`+m.finch.UserDataDiskPath(m.rootDir), + ) + + m.logger.Debugf("running detach cmd: %v", cmd) + + out, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("failed to detach disk: %w, command output: %s", err, out) + } + + return nil +} + +// min_win_disk.zip is a zip directory with a single file (disk.vhdx). +// disk.vhdx is a 50G max size, sparse, GPT, vhdx file created by diskpart, which contains +// a single ext4 partition. Since using diskpart requires Administrator privileges, +// we ship a pre-created disk to be used for the persistent data volume. +// +//go:embed min_win_disk.zip +var minWinDisk []byte + +func (m *userDataDiskManager) createDisk(diskPath string) error { + m.logger.Infof("creating persistent disk: %s", diskPath) + r, err := zip.NewReader(bytes.NewReader(minWinDisk), int64(len(minWinDisk))) + if err != nil { + return fmt.Errorf("failed to create zip reader: %w", err) + } + + for _, f := range r.File { + if f.Name == "disk.vhdx" { + compressed, err := f.Open() + if err != nil { + return fmt.Errorf("failed to open file inside zip: %w", err) + } + dest, err := os.OpenFile(filepath.Clean(diskPath), os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode()) + if err != nil { + return fmt.Errorf("failed to open persistent disk file: %w", err) + } + + // avoid go-sec G110 by chunking + for { + _, err := io.CopyN(dest, compressed, 204800) + if err != nil { + if errors.Is(err, io.EOF) { + break + } + return fmt.Errorf("failed copy compressed file to disk: %w", err) + } + } + if err := dest.Close(); err != nil { + return fmt.Errorf("failed close disk file: %w", err) + } + + break + } + } + + return nil +} + +func (m *userDataDiskManager) attachDisk(diskPath string) error { + m.logger.Infof("attaching disk at path: %s", diskPath) + + cmd := m.ecc.Create( + "wsl.exe", + "--mount", + "--bare", + "--vhd", + diskPath, + ) + + m.logger.Debugf("running attach cmd: %v", cmd) + + out, err := cmd.CombinedOutput() + outDecoded, _ := winutil.FromUTF16leToString(bytes.NewBuffer(out)) + if err != nil { + return fmt.Errorf("failed to attach disk: %w, command output: %s", err, outDecoded) + } + + return nil +} diff --git a/pkg/disk/min_win_disk.zip b/pkg/disk/min_win_disk.zip new file mode 100644 index 0000000000000000000000000000000000000000..2c9777d8efb6f41274036e19986e7a421842b861 GIT binary patch literal 1431765 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

f{mDWx*o$x>Hyl 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 zteũ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 literal 0 HcmV?d00001 diff --git a/pkg/flog/formatter_string.go b/pkg/flog/formatter_string.go new file mode 100644 index 000000000..5ca2dad32 --- /dev/null +++ b/pkg/flog/formatter_string.go @@ -0,0 +1,24 @@ +// Code generated by "stringer -type=Formatter"; DO NOT EDIT. + +package flog + +import "strconv" + +func _() { + // An "invalid array index" compiler error signifies that the constant values have changed. + // Re-run the stringer command to generate them again. + var x [1]struct{} + _ = x[Text-0] + _ = x[JSON-1] +} + +const _Formatter_name = "TextJSON" + +var _Formatter_index = [...]uint8{0, 4, 8} + +func (i Formatter) String() string { + if i < 0 || i >= Formatter(len(_Formatter_index)-1) { + return "Formatter(" + strconv.FormatInt(int64(i), 10) + ")" + } + return _Formatter_name[_Formatter_index[i]:_Formatter_index[i+1]] +} diff --git a/pkg/flog/log.go b/pkg/flog/log.go index 30d4fc98e..9f7943389 100644 --- a/pkg/flog/log.go +++ b/pkg/flog/log.go @@ -19,6 +19,14 @@ type Logger interface { Errorf(format string, args ...interface{}) Fatal(args ...interface{}) SetLevel(level Level) + SetFormatter(formatter Formatter) +} + +// Log defines the properties of every log message. +type Log struct { + Level string `json:"level,omitempty"` + Message string `json:"msg,omitempty"` + Time string `json:"time,omitempty"` } // Level denotes a log level. Check the constants below for more information. @@ -29,3 +37,12 @@ const ( Debug Level = iota Panic ) + +// Formatter denotes a log formatter. Check the constants below for more information. +type Formatter int + +//go:generate stringer -type=Formatter +const ( + Text Formatter = iota + JSON +) diff --git a/pkg/flog/logrus.go b/pkg/flog/logrus.go index 977211757..4cc091529 100644 --- a/pkg/flog/logrus.go +++ b/pkg/flog/logrus.go @@ -3,7 +3,9 @@ package flog -import "github.com/sirupsen/logrus" +import ( + "github.com/sirupsen/logrus" +) // Logrus implements the Logger interface. type Logrus struct{} @@ -74,3 +76,13 @@ func (l *Logrus) SetLevel(level Level) { logrus.SetLevel(logrus.PanicLevel) } } + +// SetFormatter sets the formatter of the logger. +func (l *Logrus) SetFormatter(formatter Formatter) { + switch formatter { + case Text: + logrus.SetFormatter(&logrus.TextFormatter{}) + case JSON: + logrus.SetFormatter(&logrus.JSONFormatter{}) + } +} diff --git a/pkg/fssh/fssh_test.go b/pkg/fssh/fssh_test.go index 647eb2844..1326ef1f8 100644 --- a/pkg/fssh/fssh_test.go +++ b/pkg/fssh/fssh_test.go @@ -8,6 +8,7 @@ import ( "fmt" "io/fs" "net" + "path/filepath" "testing" "github.com/spf13/afero" @@ -18,7 +19,6 @@ import ( func Test_hostKeyCallback(t *testing.T) { t.Parallel() - testCases := []struct { name string remote net.Addr @@ -54,7 +54,8 @@ func Test_hostKeyCallback(t *testing.T) { func TestNewClientConfig(t *testing.T) { t.Parallel() - + // Handles OS specific path delimiter + pkPath := filepath.Join(string(filepath.Separator), "private_key") testCases := []struct { name string user string @@ -66,9 +67,9 @@ func TestNewClientConfig(t *testing.T) { { name: "happy path", user: "test", - privateKeyPath: "/private_key", + privateKeyPath: pkPath, mockSvc: func(t *testing.T, fs afero.Fs) { - err := afero.WriteFile(fs, "/private_key", []byte(` + err := afero.WriteFile(fs, pkPath, []byte(` -----BEGIN OPENSSH PRIVATE KEY----- b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW QyNTUxOQAAACAfR367TtAGV+abvj4pRDcFdU2enKE+iC4qF3LNJF9eyQAAAKjEIxhXxCMY @@ -87,24 +88,24 @@ TZ6coT6ILioXcs0kX17JAAAAI2FsdmFqdXNAODg2NjVhMGJmN2NhLmFudC5hbWF6b24uY2 { name: "private key file doesn't exist", user: "test", - privateKeyPath: "/private_key", + privateKeyPath: pkPath, mockSvc: func(t *testing.T, fs afero.Fs) {}, want: nil, wantErr: fmt.Errorf( "failed to open private key file: %w", - &fs.PathError{Op: "open", Path: "/private_key", Err: errors.New("file does not exist")}, + &fs.PathError{Op: "open", Path: pkPath, Err: errors.New("file does not exist")}, ), }, { name: "invalid private key file contents", user: "test", - privateKeyPath: "/private_key", + privateKeyPath: pkPath, mockSvc: func(t *testing.T, fs afero.Fs) { - err := afero.WriteFile(fs, "/private_key", []byte(`not a private key`), 0o644) + err := afero.WriteFile(fs, pkPath, []byte(`not a private key`), 0o644) require.NoError(t, err) }, want: nil, - wantErr: fmt.Errorf("failed to parse private key from %s: %w", "/private_key", fmt.Errorf("ssh: no key found")), + wantErr: fmt.Errorf("failed to parse private key from %s: %w", pkPath, fmt.Errorf("ssh: no key found")), }, } diff --git a/pkg/lima/lima.go b/pkg/lima/lima.go index 52dd9fd45..c539ebfc1 100644 --- a/pkg/lima/lima.go +++ b/pkg/lima/lima.go @@ -30,7 +30,8 @@ const ( Unknown QEMU VMType = "qemu" VZ VMType = "vz" - NonexistentVMType VMType = "nonexistant" + WSL VMType = "wsl2" + NonexistentVMType VMType = "nonexistent" UnknownVMType VMType = "unknown" ) @@ -81,6 +82,8 @@ func toVMType(vmType string, logger flog.Logger) (VMType, error) { return QEMU, nil case "vz": return VZ, nil + case "wsl2": + return WSL, nil default: return UnknownVMType, errors.New("unrecognized VMType") } diff --git a/pkg/lima/lima_test.go b/pkg/lima/lima_test.go index 10c6c4796..33968defd 100644 --- a/pkg/lima/lima_test.go +++ b/pkg/lima/lima_test.go @@ -124,6 +124,16 @@ func TestGetVMType(t *testing.T) { logger.EXPECT().Debugf("VMType of virtual machine: %s", "vz") }, }, + { + name: "wsl VM", + want: lima.WSL, + wantErr: nil, + mockSvc: func(creator *mocks.LimaCmdCreator, logger *mocks.Logger, cmd *mocks.Command) { + creator.EXPECT().CreateWithoutStdio(mockArgs).Return(cmd) + cmd.EXPECT().Output().Return([]byte("wsl2"), nil) + logger.EXPECT().Debugf("VMType of virtual machine: %s", "wsl2") + }, + }, { name: "nonexistent VM", want: lima.NonexistentVMType, diff --git a/pkg/mocks/command_command.go b/pkg/mocks/command_command.go index c9006d599..cb2642b5f 100644 --- a/pkg/mocks/command_command.go +++ b/pkg/mocks/command_command.go @@ -143,6 +143,21 @@ func (mr *CommandMockRecorder) Start() *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Start", reflect.TypeOf((*Command)(nil).Start)) } +// StdinPipe mocks base method. +func (m *Command) StdinPipe() (io.WriteCloser, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "StdinPipe") + ret0, _ := ret[0].(io.WriteCloser) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// StdinPipe indicates an expected call of StdinPipe. +func (mr *CommandMockRecorder) StdinPipe() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "StdinPipe", reflect.TypeOf((*Command)(nil).StdinPipe)) +} + // Wait mocks base method. func (m *Command) Wait() error { m.ctrl.T.Helper() diff --git a/pkg/mocks/finch_finder_deps.go b/pkg/mocks/finch_finder_deps.go index 545cdff6d..5fe0e0e63 100644 --- a/pkg/mocks/finch_finder_deps.go +++ b/pkg/mocks/finch_finder_deps.go @@ -97,3 +97,18 @@ func (mr *FinchFinderDepsMockRecorder) FilePathJoin(arg0 ...interface{}) *gomock mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FilePathJoin", reflect.TypeOf((*FinchFinderDeps)(nil).FilePathJoin), arg0...) } + +// GetUserHome mocks base method. +func (m *FinchFinderDeps) GetUserHome() (string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetUserHome") + ret0, _ := ret[0].(string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetUserHome indicates an expected call of GetUserHome. +func (mr *FinchFinderDepsMockRecorder) GetUserHome() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserHome", reflect.TypeOf((*FinchFinderDeps)(nil).GetUserHome)) +} diff --git a/pkg/mocks/logger.go b/pkg/mocks/logger.go index 36f172250..9cd2b14f0 100644 --- a/pkg/mocks/logger.go +++ b/pkg/mocks/logger.go @@ -168,6 +168,18 @@ func (mr *LoggerMockRecorder) Infoln(arg0 ...interface{}) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Infoln", reflect.TypeOf((*Logger)(nil).Infoln), arg0...) } +// SetFormatter mocks base method. +func (m *Logger) SetFormatter(arg0 flog.Formatter) { + m.ctrl.T.Helper() + m.ctrl.Call(m, "SetFormatter", arg0) +} + +// SetFormatter indicates an expected call of SetFormatter. +func (mr *LoggerMockRecorder) SetFormatter(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetFormatter", reflect.TypeOf((*Logger)(nil).SetFormatter), arg0) +} + // SetLevel mocks base method. func (m *Logger) SetLevel(arg0 flog.Level) { m.ctrl.T.Helper() diff --git a/pkg/mocks/nerdctl_cmd_system_deps.go b/pkg/mocks/nerdctl_cmd_system_deps.go index 68a6942f4..1fc2d540b 100644 --- a/pkg/mocks/nerdctl_cmd_system_deps.go +++ b/pkg/mocks/nerdctl_cmd_system_deps.go @@ -36,6 +36,68 @@ func (m *NerdctlCommandSystemDeps) EXPECT() *NerdctlCommandSystemDepsMockRecorde return m.recorder } +// FilePathAbs mocks base method. +func (m *NerdctlCommandSystemDeps) FilePathAbs(elem string) (string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "FilePathAbs", elem) + ret0, _ := ret[0].(string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// FilePathAbs indicates an expected call of FilePathAbs. +func (mr *NerdctlCommandSystemDepsMockRecorder) FilePathAbs(elem interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FilePathAbs", reflect.TypeOf((*NerdctlCommandSystemDeps)(nil).FilePathAbs), elem) +} + +// FilePathJoin mocks base method. +func (m *NerdctlCommandSystemDeps) FilePathJoin(elem ...string) string { + m.ctrl.T.Helper() + varargs := []interface{}{} + for _, a := range elem { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "FilePathJoin", varargs...) + ret0, _ := ret[0].(string) + return ret0 +} + +// FilePathJoin indicates an expected call of FilePathJoin. +func (mr *NerdctlCommandSystemDepsMockRecorder) FilePathJoin(elem ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FilePathJoin", reflect.TypeOf((*NerdctlCommandSystemDeps)(nil).FilePathJoin), elem...) +} + +// FilePathToSlash mocks base method. +func (m *NerdctlCommandSystemDeps) FilePathToSlash(elem string) string { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "FilePathToSlash", elem) + ret0, _ := ret[0].(string) + return ret0 +} + +// FilePathToSlash indicates an expected call of FilePathToSlash. +func (mr *NerdctlCommandSystemDepsMockRecorder) FilePathToSlash(elem interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FilePathToSlash", reflect.TypeOf((*NerdctlCommandSystemDeps)(nil).FilePathToSlash), elem) +} + +// GetWd mocks base method. +func (m *NerdctlCommandSystemDeps) GetWd() (string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetWd") + ret0, _ := ret[0].(string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetWd indicates an expected call of GetWd. +func (mr *NerdctlCommandSystemDepsMockRecorder) GetWd() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetWd", reflect.TypeOf((*NerdctlCommandSystemDeps)(nil).GetWd)) +} + // LookupEnv mocks base method. func (m *NerdctlCommandSystemDeps) LookupEnv(key string) (string, bool) { m.ctrl.T.Helper() diff --git a/pkg/mocks/pkg_disk_disk.go b/pkg/mocks/pkg_disk_disk.go index 66cab6001..f88a869b9 100644 --- a/pkg/mocks/pkg_disk_disk.go +++ b/pkg/mocks/pkg_disk_disk.go @@ -1,5 +1,8 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + // Code generated by MockGen. DO NOT EDIT. -// Source: pkg/disk/disk.go +// Source: disk.go // Package mocks is a generated GoMock package. package mocks @@ -13,31 +16,45 @@ import ( afero "github.com/spf13/afero" ) -// MockUserDataDiskManager is a mock of UserDataDiskManager interface. -type MockUserDataDiskManager struct { +// UserDataDiskManager is a mock of UserDataDiskManager interface. +type UserDataDiskManager struct { ctrl *gomock.Controller - recorder *MockUserDataDiskManagerMockRecorder + recorder *UserDataDiskManagerMockRecorder } -// MockUserDataDiskManagerMockRecorder is the mock recorder for MockUserDataDiskManager. -type MockUserDataDiskManagerMockRecorder struct { - mock *MockUserDataDiskManager +// UserDataDiskManagerMockRecorder is the mock recorder for UserDataDiskManager. +type UserDataDiskManagerMockRecorder struct { + mock *UserDataDiskManager } -// NewMockUserDataDiskManager creates a new mock instance. -func NewMockUserDataDiskManager(ctrl *gomock.Controller) *MockUserDataDiskManager { - mock := &MockUserDataDiskManager{ctrl: ctrl} - mock.recorder = &MockUserDataDiskManagerMockRecorder{mock} +// NewUserDataDiskManager creates a new mock instance. +func NewUserDataDiskManager(ctrl *gomock.Controller) *UserDataDiskManager { + mock := &UserDataDiskManager{ctrl: ctrl} + mock.recorder = &UserDataDiskManagerMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. -func (m *MockUserDataDiskManager) EXPECT() *MockUserDataDiskManagerMockRecorder { +func (m *UserDataDiskManager) EXPECT() *UserDataDiskManagerMockRecorder { return m.recorder } +// DetachUserDataDisk mocks base method. +func (m *UserDataDiskManager) DetachUserDataDisk() error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DetachUserDataDisk") + ret0, _ := ret[0].(error) + return ret0 +} + +// DetachUserDataDisk indicates an expected call of DetachUserDataDisk. +func (mr *UserDataDiskManagerMockRecorder) DetachUserDataDisk() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DetachUserDataDisk", reflect.TypeOf((*UserDataDiskManager)(nil).DetachUserDataDisk)) +} + // EnsureUserDataDisk mocks base method. -func (m *MockUserDataDiskManager) EnsureUserDataDisk() error { +func (m *UserDataDiskManager) EnsureUserDataDisk() error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "EnsureUserDataDisk") ret0, _ := ret[0].(error) @@ -45,9 +62,9 @@ func (m *MockUserDataDiskManager) EnsureUserDataDisk() error { } // EnsureUserDataDisk indicates an expected call of EnsureUserDataDisk. -func (mr *MockUserDataDiskManagerMockRecorder) EnsureUserDataDisk() *gomock.Call { +func (mr *UserDataDiskManagerMockRecorder) EnsureUserDataDisk() *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "EnsureUserDataDisk", reflect.TypeOf((*MockUserDataDiskManager)(nil).EnsureUserDataDisk)) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "EnsureUserDataDisk", reflect.TypeOf((*UserDataDiskManager)(nil).EnsureUserDataDisk)) } // MockdiskFS is a mock of diskFS interface. diff --git a/pkg/path/finch.go b/pkg/path/finch.go index ff8d0013f..32a9877dd 100644 --- a/pkg/path/finch.go +++ b/pkg/path/finch.go @@ -7,6 +7,8 @@ package path import ( "crypto/sha256" "fmt" + "path/filepath" + "runtime" "github.com/runfinch/finch/pkg/system" ) @@ -14,56 +16,65 @@ import ( // Finch provides a set of methods that calculate paths relative to the Finch path. type Finch string +// FinchDir returns the path to the Finch config directory. +func (Finch) FinchDir(rootDir string) string { + return filepath.Join(rootDir, ".finch") +} + // ConfigFilePath returns the path to Finch config file. -func (Finch) ConfigFilePath(homeDir string) string { - return fmt.Sprintf("%s/.finch/finch.yaml", homeDir) +func (Finch) ConfigFilePath(rootDir string) string { + return filepath.Join(rootDir, ".finch", "finch.yaml") } // UserDataDiskPath returns the path to the permanent storage location of the Finch // user data disk. -func (w Finch) UserDataDiskPath(homeDir string) string { - return fmt.Sprintf("%s/.finch/.disks/%s", homeDir, w.generatePathSum()) +func (w Finch) UserDataDiskPath(rootDir string) string { + disksPath := filepath.Join(rootDir, ".finch", ".disks") + if runtime.GOOS == "windows" { + return filepath.Join(disksPath, w.generatePathSum()+".vhdx") + } + return filepath.Join(disksPath, w.generatePathSum()) } // LimaHomePath returns the path that should be set to LIMA_HOME for Finch. func (w Finch) LimaHomePath() string { - return fmt.Sprintf("%s/lima/data", w) + return filepath.Join(string(w), "lima", "data") } // LimaInstancePath returns the path to the Lima instance of the Finch VM. func (w Finch) LimaInstancePath() string { - return fmt.Sprintf("%s/lima/data/finch", w) + return filepath.Join(string(w), "lima", "data", "finch") } // LimactlPath returns the limactl path. func (w Finch) LimactlPath() string { - return fmt.Sprintf("%s/lima/bin/limactl", w) + return filepath.Join(string(w), "lima", "bin", "limactl") } // QEMUBinDir returns the path to the directory that contains all the binaries QEMU depends on. // It's used to enable users to always use the pinned versions of the binaries. func (w Finch) QEMUBinDir() string { - return fmt.Sprintf("%s/lima/bin", w) + return filepath.Join(string(w), "lima", "bin") } // BaseYamlFilePath returns the base yaml file path. func (w Finch) BaseYamlFilePath() string { - return fmt.Sprintf("%s/os/finch.yaml", w) + return filepath.Join(string(w), "os", "finch.yaml") } // LimaConfigDirectoryPath returns the lima config directory path. func (w Finch) LimaConfigDirectoryPath() string { - return fmt.Sprintf("%s/lima/data/_config", w) + return filepath.Join(string(w), "lima", "data", "_config") } // LimaOverrideConfigPath returns the lima override config file path. func (w Finch) LimaOverrideConfigPath() string { - return fmt.Sprintf("%s/lima/data/_config/override.yaml", w) + return filepath.Join(string(w), "lima", "data", "_config", "override.yaml") } // LimaSSHPrivateKeyPath returns the lima user key path. func (w Finch) LimaSSHPrivateKeyPath() string { - return fmt.Sprintf("%s/lima/data/_config/user", w) + return filepath.Join(string(w), "lima", "data", "_config", "user") } func (w Finch) generatePathSum() string { @@ -79,6 +90,7 @@ type FinchFinderDeps interface { system.ExecutableFinder system.FilePathJoiner system.EnvGetter + system.UserHomeDir } // FindFinch finds the installation path of Finch. @@ -93,6 +105,6 @@ func FindFinch(deps FinchFinderDeps) (Finch, error) { } // The directory structure is finch_home/bin/finch, // where the last path comment (i.e., finch) is the executable that starts this process. - res := deps.FilePathJoin(realPath, "../../") + res := deps.FilePathJoin(realPath, "..", "..") return Finch(res), nil } diff --git a/pkg/path/finch_test.go b/pkg/path/finch_test.go index f37b5a55c..81b541bab 100644 --- a/pkg/path/finch_test.go +++ b/pkg/path/finch_test.go @@ -6,6 +6,8 @@ package path import ( "errors" "fmt" + "path/filepath" + "runtime" "testing" "github.com/runfinch/finch/pkg/mocks" @@ -20,70 +22,74 @@ func TestFinch_ConfigFilePath(t *testing.T) { t.Parallel() res := mockFinch.ConfigFilePath("homeDir") - assert.Equal(t, res, "homeDir/.finch/finch.yaml") + assert.Equal(t, res, filepath.Join("homeDir", ".finch", "finch.yaml")) } func TestFinch_UserDataDiskPath(t *testing.T) { t.Parallel() res := mockFinch.UserDataDiskPath("homeDir") - assert.Equal(t, res, fmt.Sprintf("homeDir/.finch/.disks/%s", mockFinch.generatePathSum())) + if runtime.GOOS == "windows" { + assert.Equal(t, res, filepath.Join("homeDir", ".finch", ".disks", mockFinch.generatePathSum()+".vhdx")) + } else { + assert.Equal(t, res, filepath.Join("homeDir", ".finch", ".disks", mockFinch.generatePathSum())) + } } func TestFinch_LimaHomePath(t *testing.T) { t.Parallel() res := mockFinch.LimaHomePath() - assert.Equal(t, res, "mock_finch/lima/data") + assert.Equal(t, res, filepath.Join("mock_finch", "lima", "data")) } func TestFinch_LimaInstancePath(t *testing.T) { t.Parallel() res := mockFinch.LimaInstancePath() - assert.Equal(t, res, "mock_finch/lima/data/finch") + assert.Equal(t, res, filepath.Join("mock_finch", "lima", "data", "finch")) } func TestFinch_LimactlPath(t *testing.T) { t.Parallel() res := mockFinch.LimactlPath() - assert.Equal(t, res, "mock_finch/lima/bin/limactl") + assert.Equal(t, res, filepath.Join("mock_finch", "lima", "bin", "limactl")) } func TestFinch_BaseYamlFilePath(t *testing.T) { t.Parallel() res := mockFinch.BaseYamlFilePath() - assert.Equal(t, res, "mock_finch/os/finch.yaml") + assert.Equal(t, res, filepath.Join("mock_finch", "os", "finch.yaml")) } func TestFinch_LimaConfigDirectoryPath(t *testing.T) { t.Parallel() res := mockFinch.LimaConfigDirectoryPath() - assert.Equal(t, res, "mock_finch/lima/data/_config") + assert.Equal(t, res, filepath.Join("mock_finch", "lima", "data", "_config")) } func TestFinch_LimaOverrideConfigPath(t *testing.T) { t.Parallel() res := mockFinch.LimaOverrideConfigPath() - assert.Equal(t, res, "mock_finch/lima/data/_config/override.yaml") + assert.Equal(t, res, filepath.Join("mock_finch", "lima", "data", "_config", "override.yaml")) } func TestFinch_LimaSSHPrivateKeyPath(t *testing.T) { t.Parallel() res := mockFinch.LimaSSHPrivateKeyPath() - assert.Equal(t, res, "mock_finch/lima/data/_config/user") + assert.Equal(t, res, filepath.Join("mock_finch", "lima", "data", "_config", "user")) } func TestFinch_QemuBinDir(t *testing.T) { t.Parallel() res := mockFinch.QEMUBinDir() - assert.Equal(t, res, "mock_finch/lima/bin") + assert.Equal(t, res, filepath.Join("mock_finch", "lima", "bin")) } func TestFindFinch(t *testing.T) { @@ -102,7 +108,7 @@ func TestFindFinch(t *testing.T) { mockSvc: func(deps *mocks.FinchFinderDeps) { deps.EXPECT().Executable().Return("/bin/path", nil) deps.EXPECT().EvalSymlinks("/bin/path").Return("/real/bin/path", nil) - deps.EXPECT().FilePathJoin("/real/bin/path", "../../").Return("/real") + deps.EXPECT().FilePathJoin("/real/bin/path", "..", "..").Return("/real") }, }, { diff --git a/pkg/path/finch_unix.go b/pkg/path/finch_unix.go new file mode 100644 index 000000000..164018a54 --- /dev/null +++ b/pkg/path/finch_unix.go @@ -0,0 +1,17 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +//go:build !windows +// +build !windows + +package path + +// FinchRootDir returns the path to the Finch root directory, which is $HOME on UNIX. +func (Finch) FinchRootDir(ffd FinchFinderDeps) (string, error) { + home, err := ffd.GetUserHome() + if err != nil { + return "", err + } + + return home, nil +} diff --git a/pkg/path/finch_windows.go b/pkg/path/finch_windows.go new file mode 100644 index 000000000..599401824 --- /dev/null +++ b/pkg/path/finch_windows.go @@ -0,0 +1,31 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +//go:build windows +// +build windows + +package path + +import ( + "fmt" + "strings" + + "golang.org/x/sys/windows/registry" +) + +// FinchRootDir returns the path to the Finch root directory, which is %LOCALAPPDATA% on Windows. +// It also canonicalizes any environment variables that may be unexpanded in the path so that all +// paths based on it can be passed safely to other programs which may execute outside of the user's context. +func (Finch) FinchRootDir(ffd FinchFinderDeps) (string, error) { + appDir := ffd.Env("LOCALAPPDATA") + expandedPath, err := registry.ExpandString(appDir) + if err != nil { + return "", err + } + // reject any paths that contain unexpected characters + if strings.Contains(expandedPath, "&") || strings.Contains(expandedPath, `"`) { + return "", fmt.Errorf("unexpected LOCALAPPDATA path %q", expandedPath) + } + + return expandedPath, nil +} diff --git a/pkg/path/finch_windows_test.go b/pkg/path/finch_windows_test.go new file mode 100644 index 000000000..1f66ee197 --- /dev/null +++ b/pkg/path/finch_windows_test.go @@ -0,0 +1,59 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +//go:build windows +// +build windows + +package path + +import ( + "fmt" + "testing" + + "github.com/golang/mock/gomock" + "github.com/stretchr/testify/assert" + + "github.com/runfinch/finch/pkg/mocks" +) + +func TestFinch_RootDir(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + mockSvc func(*mocks.FinchFinderDeps) + wantErr error + want Finch + }{ + { + name: "happy path", + wantErr: nil, + want: Finch(`C:\Users\User\AppData\`), + mockSvc: func(deps *mocks.FinchFinderDeps) { + deps.EXPECT().Env("LOCALAPPDATA").Return(`C:\Users\User\AppData\`) + }, + }, + { + name: "failed to find the executable path", + want: "", + wantErr: fmt.Errorf("unexpected LOCALAPPDATA path %q", `\\network.path\home\test\"&someDir`), + mockSvc: func(deps *mocks.FinchFinderDeps) { + deps.EXPECT().Env("LOCALAPPDATA").Return(`\\network.path\home\test\"&someDir`) + }, + }, + } + + for _, tc := range testCases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + deps := mocks.NewFinchFinderDeps(ctrl) + tc.mockSvc(deps) + res, err := mockFinch.FinchRootDir(deps) + assert.Equal(t, Finch(res), tc.want) + assert.Equal(t, err, tc.wantErr) + }) + } +} diff --git a/pkg/support/config.go b/pkg/support/config.go index b9c2076ef..a1d970432 100644 --- a/pkg/support/config.go +++ b/pkg/support/config.go @@ -5,6 +5,8 @@ package support import ( "path" + "path/filepath" + "runtime" fpath "github.com/runfinch/finch/pkg/path" ) @@ -29,11 +31,15 @@ func NewBundleConfig(finch fpath.Finch, homeDir string) BundleConfig { } func (bc *bundleConfig) LogFiles() []string { - return []string{ - path.Join(bc.finch.LimaInstancePath(), "ha.stderr.log"), - path.Join(bc.finch.LimaInstancePath(), "ha.stdout.log"), - path.Join(bc.finch.LimaInstancePath(), "serial.log"), + files := []string{ + filepath.Join(bc.finch.LimaInstancePath(), "ha.stderr.log"), + filepath.Join(bc.finch.LimaInstancePath(), "ha.stdout.log"), + } + + if runtime.GOOS != "windows" { + files = append(files, filepath.Join(bc.finch.LimaInstancePath(), "serial.log")) } + return files } func (bc *bundleConfig) ConfigFiles() []string { diff --git a/pkg/support/config_test.go b/pkg/support/config_test.go index d0f3f5c23..e140f0d40 100644 --- a/pkg/support/config_test.go +++ b/pkg/support/config_test.go @@ -4,7 +4,8 @@ package support import ( - "path" + "path/filepath" + "runtime" "testing" "github.com/stretchr/testify/assert" @@ -23,23 +24,38 @@ func TestNewBundleConfig(t *testing.T) { func TestBundleConfig_LogFiles(t *testing.T) { t.Parallel() - finch := fpath.Finch("/mockfinch") - homeDir := "/mockhome" + var homeDir string + var finch fpath.Finch + if runtime.GOOS == "windows" { + finch = fpath.Finch("C:\\mockfinch") + homeDir = "C:\\mockhome" + } else { + finch = fpath.Finch("/mockfinch") + homeDir = "/mockhome" + } + config := NewBundleConfig(finch, homeDir) for _, fileName := range config.LogFiles() { - assert.True(t, path.IsAbs(fileName)) + assert.True(t, filepath.IsAbs(fileName)) } } func TestBundleConfig_ConfigFiles(t *testing.T) { t.Parallel() - finch := fpath.Finch("/mockfinch") - homeDir := "/mockhome" + var homeDir string + var finch fpath.Finch + if runtime.GOOS == "windows" { + finch = fpath.Finch("C:\\mockfinch") + homeDir = "C:\\mockhome" + } else { + finch = fpath.Finch("/mockfinch") + homeDir = "/mockhome" + } config := NewBundleConfig(finch, homeDir) for _, fileName := range config.ConfigFiles() { - assert.True(t, path.IsAbs(fileName)) + assert.True(t, filepath.IsAbs(fileName)) } } diff --git a/pkg/support/redact.go b/pkg/support/redact.go index 1e20bee13..b26c4f33b 100644 --- a/pkg/support/redact.go +++ b/pkg/support/redact.go @@ -4,13 +4,14 @@ package support import ( + "path/filepath" "regexp" "github.com/runfinch/finch/pkg/path" ) func redactFinchInstall(content []byte, finch path.Finch) (redacted []byte, err error) { - finchInstallMatcher, err := regexp.Compile(string(finch)) + finchInstallMatcher, err := regexp.Compile(filepath.ToSlash(string(finch))) if err != nil { return nil, err } diff --git a/pkg/support/support.go b/pkg/support/support.go index 09d9986aa..1a09d4ab9 100644 --- a/pkg/support/support.go +++ b/pkg/support/support.go @@ -13,6 +13,7 @@ import ( "io" "path" "path/filepath" + "runtime" "strings" "time" @@ -86,7 +87,7 @@ func (bb *bundleBuilder) GenerateSupportBundle(additionalFiles []string, exclude return "", err } - zipPrefix := strings.TrimSuffix(zipFileName, path.Ext(zipFileName)) + zipPrefix := strings.TrimSuffix(zipFileName, filepath.Ext(zipFileName)) writer := zip.NewWriter(zipFile) @@ -139,7 +140,7 @@ func (bb *bundleBuilder) GenerateSupportBundle(additionalFiles []string, exclude continue } bb.logger.Debugf("Copying %s...", file) - err = bb.copyFileFromVMOrLocal(writer, file, path.Join(zipPrefix, additionalPrefix)) + err = bb.copyFileFromVMOrLocal(writer, file, filepath.Join(zipPrefix, additionalPrefix)) if err != nil { bb.logger.Warnf("Could not add additional file %s. Error: %s", file, err) } @@ -208,7 +209,7 @@ func (bb *bundleBuilder) copyInFile(writer *zip.Writer, fileName string, prefix return err } - baseName := path.Base(fileName) + baseName := filepath.Base(fileName) zipCopy, err := writer.Create(path.Join(prefix, baseName)) if err != nil { return err @@ -219,7 +220,6 @@ func (bb *bundleBuilder) copyInFile(writer *zip.Writer, fileName string, prefix if err != nil { return err } - return bb.copyAndRedactFile(zipCopy, buf) } @@ -250,7 +250,7 @@ func (bb *bundleBuilder) streamFileFromVM(writer *zip.Writer, filename, prefix s waitStatus <- err }() - baseName := path.Base(filename) + baseName := filepath.Base(filename) zipCopy, err := writer.Create(path.Join(prefix, baseName)) if err != nil { return err @@ -290,14 +290,18 @@ func (bb *bundleBuilder) getPlatformData() (*PlatformData, error) { } func (bb *bundleBuilder) getOSVersion() (string, error) { - cmd := bb.ecc.Create("sw_vers", "-productVersion") + var cmd command.Command + if runtime.GOOS == "windows" { + cmd = bb.ecc.Create("cmd", "/c", "ver") + } else { + cmd = bb.ecc.Create("sw_vers", "-productVersion") + } out, err := cmd.Output() if err != nil { return "", err } os := strings.TrimSuffix(string(out), "\n") - return os, nil } @@ -358,7 +362,7 @@ func fileShouldBeExcluded(filename string, exclude []string) bool { if fileAbs == excludeAbs { return true } - if path.Base(realFilename) == excludeFile { + if filepath.Base(realFilename) == excludeFile { return true } } diff --git a/pkg/support/support_test.go b/pkg/support/support_test.go index cfca25974..6aa86b888 100644 --- a/pkg/support/support_test.go +++ b/pkg/support/support_test.go @@ -7,6 +7,7 @@ import ( "archive/zip" "io" "os/user" + "runtime" "testing" "time" @@ -69,7 +70,11 @@ func TestSupportBundleBuilder_GenerateSupportBundle(t *testing.T) { logger.EXPECT().Debugf("Creating %s...", gomock.Any()) logger.EXPECT().Debugln("Gathering platform data...") - ecc.EXPECT().Create("sw_vers", "-productVersion").Return(cmd) + if runtime.GOOS == "windows" { + ecc.EXPECT().Create("cmd", "/c", "ver").Return(cmd) + } else { + ecc.EXPECT().Create("sw_vers", "-productVersion").Return(cmd) + } cmd.EXPECT().Output().Return([]byte("1.2.3\n"), nil) ecc.EXPECT().Create("uname", "-m").Return(cmd) cmd.EXPECT().Output().Return([]byte("arch\n"), nil) @@ -111,7 +116,11 @@ func TestSupportBundleBuilder_GenerateSupportBundle(t *testing.T) { logger.EXPECT().Debugf("Creating %s...", gomock.Any()) logger.EXPECT().Debugln("Gathering platform data...") - ecc.EXPECT().Create("sw_vers", "-productVersion").Return(cmd) + if runtime.GOOS == "windows" { + ecc.EXPECT().Create("cmd", "/c", "ver").Return(cmd) + } else { + ecc.EXPECT().Create("sw_vers", "-productVersion").Return(cmd) + } cmd.EXPECT().Output().Return([]byte("1.2.3\n"), nil) ecc.EXPECT().Create("uname", "-m").Return(cmd) cmd.EXPECT().Output().Return([]byte("arch\n"), nil) @@ -150,7 +159,11 @@ func TestSupportBundleBuilder_GenerateSupportBundle(t *testing.T) { logger.EXPECT().Debugf("Creating %s...", gomock.Any()) logger.EXPECT().Debugln("Gathering platform data...") - ecc.EXPECT().Create("sw_vers", "-productVersion").Return(cmd) + if runtime.GOOS == "windows" { + ecc.EXPECT().Create("cmd", "/c", "ver").Return(cmd) + } else { + ecc.EXPECT().Create("sw_vers", "-productVersion").Return(cmd) + } cmd.EXPECT().Output().Return([]byte("1.2.3\n"), nil) ecc.EXPECT().Create("uname", "-m").Return(cmd) cmd.EXPECT().Output().Return([]byte("arch\n"), nil) @@ -188,7 +201,11 @@ func TestSupportBundleBuilder_GenerateSupportBundle(t *testing.T) { logger.EXPECT().Debugf("Creating %s...", gomock.Any()) logger.EXPECT().Debugln("Gathering platform data...") - ecc.EXPECT().Create("sw_vers", "-productVersion").Return(cmd) + if runtime.GOOS == "windows" { + ecc.EXPECT().Create("cmd", "/c", "ver").Return(cmd) + } else { + ecc.EXPECT().Create("sw_vers", "-productVersion").Return(cmd) + } cmd.EXPECT().Output().Return([]byte("1.2.3\n"), nil) ecc.EXPECT().Create("uname", "-m").Return(cmd) cmd.EXPECT().Output().Return([]byte("arch\n"), nil) @@ -226,7 +243,11 @@ func TestSupportBundleBuilder_GenerateSupportBundle(t *testing.T) { logger.EXPECT().Debugf("Creating %s...", gomock.Any()) logger.EXPECT().Debugln("Gathering platform data...") - ecc.EXPECT().Create("sw_vers", "-productVersion").Return(cmd) + if runtime.GOOS == "windows" { + ecc.EXPECT().Create("cmd", "/c", "ver").Return(cmd) + } else { + ecc.EXPECT().Create("sw_vers", "-productVersion").Return(cmd) + } cmd.EXPECT().Output().Return([]byte("1.2.3\n"), nil) ecc.EXPECT().Create("uname", "-m").Return(cmd) cmd.EXPECT().Output().Return([]byte("arch\n"), nil) @@ -265,7 +286,11 @@ func TestSupportBundleBuilder_GenerateSupportBundle(t *testing.T) { logger.EXPECT().Debugf("Creating %s...", gomock.Any()) logger.EXPECT().Debugln("Gathering platform data...") - ecc.EXPECT().Create("sw_vers", "-productVersion").Return(cmd) + if runtime.GOOS == "windows" { + ecc.EXPECT().Create("cmd", "/c", "ver").Return(cmd) + } else { + ecc.EXPECT().Create("sw_vers", "-productVersion").Return(cmd) + } cmd.EXPECT().Output().Return([]byte("1.2.3\n"), nil) ecc.EXPECT().Create("uname", "-m").Return(cmd) cmd.EXPECT().Output().Return([]byte("arch\n"), nil) diff --git a/pkg/system/stdlib.go b/pkg/system/stdlib.go index 33b29eb64..73d7947ea 100644 --- a/pkg/system/stdlib.go +++ b/pkg/system/stdlib.go @@ -77,3 +77,19 @@ func (s *StdLib) Arch() string { func (s *StdLib) OS() string { return runtime.GOOS } + +func (s *StdLib) GetUserHome() (string, error) { + return os.UserHomeDir() +} + +func (s *StdLib) GetWd() (string, error) { + return os.Getwd() +} + +func (s *StdLib) FilePathAbs(elem string) (string, error) { + return filepath.Abs(elem) +} + +func (s *StdLib) FilePathToSlash(elem string) string { + return filepath.ToSlash(elem) +} diff --git a/pkg/system/system.go b/pkg/system/system.go index 8143f88c8..3722c1822 100644 --- a/pkg/system/system.go +++ b/pkg/system/system.go @@ -91,3 +91,23 @@ type RuntimeArchGetter interface { type RuntimeOSGetter interface { OS() string } + +// UserHomeDir mocks out os.UserHomeDir. +type UserHomeDir interface { + GetUserHome() (string, error) +} + +// WorkingDirectory mocks out os.GetWd. +type WorkingDirectory interface { + GetWd() (string, error) +} + +// AbsFilePath mocks out filepath.Abs. +type AbsFilePath interface { + FilePathAbs(elem string) (string, error) +} + +// FilePathToSlash mocks out filepath.ToSlash. +type FilePathToSlash interface { + FilePathToSlash(elem string) string +} diff --git a/pkg/tools.go b/pkg/tools.go index 18cc4a66b..0b770f31f 100644 --- a/pkg/tools.go +++ b/pkg/tools.go @@ -12,5 +12,6 @@ package pkg import ( _ "github.com/golang/mock/mockgen" _ "github.com/google/go-licenses" + _ "github.com/tc-hib/go-winres" _ "golang.org/x/tools/cmd/stringer" ) diff --git a/pkg/winutil/io.go b/pkg/winutil/io.go new file mode 100644 index 000000000..29733c2d0 --- /dev/null +++ b/pkg/winutil/io.go @@ -0,0 +1,31 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Package winutil provides provides utility functions to run commands with +// elevated Administrator privileges. +package winutil + +import ( + "io" + + "golang.org/x/text/encoding/unicode" + "golang.org/x/text/transform" +) + +// FromUTF16le returns an io.Reader for UTF16le data. +// Windows uses little endian by default, use unicode.UseBOM policy to retrieve BOM from the text, +// and unicode.LittleEndian as a fallback. +func FromUTF16le(r io.Reader) io.Reader { + o := transform.NewReader(r, unicode.UTF16(unicode.LittleEndian, unicode.UseBOM).NewDecoder()) + return o +} + +// FromUTF16leToString reads from Unicode 16 LE encoded data from an io.Reader and returns a string. +func FromUTF16leToString(r io.Reader) (string, error) { + out, err := io.ReadAll(FromUTF16le(r)) + if err != nil { + return "", err + } + + return string(out), nil +} diff --git a/pkg/winutil/io_test.go b/pkg/winutil/io_test.go new file mode 100644 index 000000000..b90c987e6 --- /dev/null +++ b/pkg/winutil/io_test.go @@ -0,0 +1,122 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package winutil + +import ( + "bytes" + "errors" + "io" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestFromUTF16le(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + r io.Reader + wantErr error + postRunCheck func(t *testing.T, r io.Reader) + }{ + { + name: "happy path", + r: bytes.NewBuffer([]byte{ + // UTF16le BOM + 0xff, + 0xfe, + // Test\n + 'T', + 0x00, + 'e', + 0x00, + 's', + 0x00, + 't', + 0x00, + '\n', + 0x00, + }), + postRunCheck: func(t *testing.T, r io.Reader) { + str, err := io.ReadAll(r) + require.NoError(t, err) + assert.Equal(t, []byte("Test\n"), str) + }, + }, + } + + for _, tc := range testCases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + tc.postRunCheck(t, FromUTF16le(tc.r)) + }) + } +} + +func TestFromUTF16leToString(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + r io.Reader + postRunCheck func(t *testing.T, r string) + wantErr error + }{ + { + name: "happy path", + r: bytes.NewBuffer([]byte{ + // UTF16le BOM + 0xff, + 0xfe, + // Test\n + 'T', + 0x00, + 'e', + 0x00, + 's', + 0x00, + 't', + 0x00, + '\n', + 0x00, + }), + postRunCheck: func(t *testing.T, str string) { + assert.Equal(t, "Test\n", str) + }, + wantErr: nil, + }, + { + name: "error reading buffer", + r: newErrorReader("read error"), + postRunCheck: func(t *testing.T, str string) {}, + wantErr: errors.New("read error"), + }, + } + + for _, tc := range testCases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + out, err := FromUTF16leToString(tc.r) + assert.Equal(t, tc.wantErr, err) + tc.postRunCheck(t, out) + }) + } +} + +type errReader struct { + errMsg string +} + +func (er errReader) Read(_ []byte) (n int, err error) { + return 0, errors.New(er.errMsg) +} + +func newErrorReader(errMsg string) errReader { + return errReader{errMsg: errMsg} +} diff --git a/scripts/gen-code-windows.ps1 b/scripts/gen-code-windows.ps1 new file mode 100644 index 000000000..ef131c2aa --- /dev/null +++ b/scripts/gen-code-windows.ps1 @@ -0,0 +1,21 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 + +# gen-code-windows.ps1 + +# existing logic for codegen installs codegen tools locally +# to "tools_bin" and prepends $PATH with "tools_bin". This syntax to +# specify a PATH= before command in Windows Make / git-bash is broken; +# this script is a workaround to perform the codegen. + +# tools_bin is created in root of finch project. +$GOBIN = Join-Path $PSScriptRoot "../tools_bin" +$env:GOBIN=$GOBIN + +# Install the required Go tools specifying GOBIN +go install github.com/golang/mock/mockgen +go install golang.org/x/tools/cmd/stringer + +# Update the PATH environment variable and then run 'go generate' +$env:PATH = "${GOBIN};${env:PATH}" +go generate ./... . diff --git a/winres/winres.json b/winres/winres.json new file mode 100644 index 000000000..02c09d53d --- /dev/null +++ b/winres/winres.json @@ -0,0 +1,58 @@ +{ + "RT_GROUP_ICON": { + "APP": { + "0000": "./../msi-builder/finch.ico" + } + }, + "RT_MANIFEST": { + "#1": { + "0409": { + "identity": { + "name": "", + "version": "" + }, + "description": "An open source client for container development", + "minimum-os": "win10", + "execution-level": "", + "ui-access": false, + "auto-elevate": false, + "dpi-awareness": "system", + "disable-theming": false, + "disable-window-filtering": false, + "high-resolution-scrolling-aware": false, + "ultra-high-resolution-scrolling-aware": false, + "long-path-aware": false, + "printer-driver-isolation": false, + "gdi-scaling": false, + "segment-heap": false, + "use-common-controls-v6": false + } + } + }, + "RT_VERSION": { + "#1": { + "0000": { + "fixed": { + "file_version": "0.0.0.0", + "product_version": "0.0.0.0" + }, + "info": { + "0409": { + "Comments": "", + "CompanyName": "RunFinch", + "FileDescription": "Finch", + "FileVersion": "", + "InternalName": "runfinch/finch", + "LegalCopyright": "Amazon.com, Inc. or its affiliates. All Rights Reserved", + "LegalTrademarks": "", + "OriginalFilename": "finch.exe", + "PrivateBuild": "", + "ProductName": "Finch", + "ProductVersion": "", + "SpecialBuild": "" + } + } + } + } + } + } \ No newline at end of file