diff --git a/.github/workflows/benchmark.yaml b/.github/workflows/benchmark.yaml index 4cf291fb2..cfe0a978a 100644 --- a/.github/workflows/benchmark.yaml +++ b/.github/workflows/benchmark.yaml @@ -69,5 +69,9 @@ jobs: tool: 'go' benchmark-data-dir-path: "dev/bench/macOS/${{ env.OS_VERSION }}/${{ env.ARCH }}" output-file-path: benchmark.txt + alert-threshold: "200%" + fail-on-alert: true + comment-on-alert: true + alert-comment-cc-users: '@runfinch/maintainers' - name: Push benchmark result run: git push 'https://github.com/runfinch/finch.git' gh-pages:gh-pages diff --git a/.github/workflows/test-cred-security.yaml b/.github/workflows/test-cred-security.yaml new file mode 100644 index 000000000..01cae22b6 --- /dev/null +++ b/.github/workflows/test-cred-security.yaml @@ -0,0 +1,593 @@ +name: test-cred-security +on: + pull_request: + branches: + - main + - os-native-credstore + schedule: + - cron: '0 8 * * *' + workflow_dispatch: + inputs: + ref_name: + description: "name of git ref for which to run security tests" + required: false + type: string + +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 + # This is required for actions/checkout + contents: read + +env: + GO_VERSION: '1.24.6' + +jobs: + # taken from finch/.github/workflows/build-and-test-pkg.yaml for consistent testing + get-tag-name: + name: Get tag name + runs-on: ubuntu-latest + permissions: + contents: read + timeout-minutes: 2 + outputs: + tag: ${{ steps.check-tag.outputs.tag }} + commit: ${{ steps.export-commit.outputs.commit }} + steps: + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + fetch-depth: 0 + persist-credentials: false + submodules: true + - name: Export commit and tag info + id: check-tag + run: | + commit=$(git rev-parse HEAD) + if [ -n "${{ inputs.ref_name }}" ]; then + tag=${{ inputs.ref_name }} + else + tag=${{ github.head_ref || github.ref_name }} + fi + echo "using tag=${tag}" + echo "using commit=${commit}" + echo "tag=$tag" >> ${GITHUB_OUTPUT} + echo "commit=$commit" >> ${GITHUB_OUTPUT} + - name: Export commit hash + id: export-commit + run: | + commit=$(git rev-parse HEAD) + echo "using commit=${commit}" + echo "commit=$commit" >> ${GITHUB_OUTPUT} + + macos-security-test: + strategy: + fail-fast: false + matrix: + arch: [arm64, amd64] + version: [14, 15] + include: + - arch: arm64 + output_arch: aarch64 + - arch: amd64 + output_arch: x86_64 + needs: get-tag-name + runs-on: [self-hosted, macos, "${{ matrix.arch }}", "${{ matrix.version }}", test] + timeout-minutes: 30 + steps: + - name: Clean workspace + run: | + setopt NULL_GLOB && rm -rf ${{ github.workspace }}/* + shell: zsh {0} + + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + ref: ${{ needs.get-tag-name.outputs.commit }} + fetch-depth: 0 + persist-credentials: false + submodules: true + + - uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0 + with: + go-version: ${{ env.GO_VERSION }} + cache: false + + - name: Create test users + run: | + # Create finchUser (primary test user) + sudo dscl . -create /Users/finchUser + sudo dscl . -create /Users/finchUser UserShell /bin/zsh + sudo dscl . -create /Users/finchUser RealName "Finch User" + sudo dscl . -create /Users/finchUser UniqueID 1001 + sudo dscl . -create /Users/finchUser PrimaryGroupID 20 + sudo dscl . -create /Users/finchUser NFSHomeDirectory /Users/finchUser + sudo createhomedir -c -u finchUser + + # Create otherUser (security boundary test user) + sudo dscl . -create /Users/otherUser + sudo dscl . -create /Users/otherUser UserShell /bin/zsh + sudo dscl . -create /Users/otherUser RealName "Other User" + sudo dscl . -create /Users/otherUser UniqueID 1002 + sudo dscl . -create /Users/otherUser PrimaryGroupID 20 + sudo dscl . -create /Users/otherUser NFSHomeDirectory /Users/otherUser + sudo createhomedir -c -u otherUser + + # Give finchUser sudo access for installation + echo "finchUser ALL=(ALL) NOPASSWD: ALL" | sudo tee -a /etc/sudoers + shell: zsh {0} + + - name: Build Finch as finchUser + run: | + # Copy workspace to finchUser's directory + sudo cp -R ${{ github.workspace }} /Users/finchUser/finch-build + sudo chown -R finchUser:staff /Users/finchUser/finch-build + + # Setup Homebrew for finchUser and install dependencies + sudo -u finchUser /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" + sudo -u finchUser zsh -c 'echo "eval \$(/opt/homebrew/bin/brew shellenv)" >> ~/.zshrc' + sudo -u finchUser zsh -c 'source ~/.zshrc && cd /Users/finchUser/finch-build && brew install lz4 automake autoconf libtool yq llvm httpd' + sudo -u finchUser zsh -c 'cd /Users/finchUser/finch-build && git clean -f -d' + sudo -u finchUser zsh -c 'cd /Users/finchUser/finch-build && make clean' + sudo -u finchUser zsh -c 'cd /Users/finchUser/finch-build && make download-licenses || true' + sudo -u finchUser zsh -c 'cd /Users/finchUser/finch-build && make FINCH_OS_IMAGE_LOCATION_ROOT=/Applications/Finch' + shell: zsh {0} + + - name: Generate and install local PKG as finchUser + run: | + # Generate local unsigned PKG + sudo -u finchUser zsh -c ' + cd /Users/finchUser/finch-build + mkdir -p ./installer-builder/output/origin + cp -RP ./_output ./installer-builder/output/origin/ + ./installer-builder/tools/build-macos-pkg.sh ${{ matrix.output_arch }} ${{ needs.get-tag-name.outputs.tag }} + ' + + # Install the generated PKG + PKG_PATH="/Users/finchUser/finch-build/installer-builder/output/installer/unsigned/package/artifact/Finch.pkg" + sudo -u finchUser installer -pkg "$PKG_PATH" -target / + + # Initialize VM immediately after installation + sudo -u finchUser finch vm init + shell: zsh {0} + + - name: Validate installation as finchUser + run: | + # Check credential helper files and permissions + sudo -u finchUser ls -lah /Users/finchUser/.finch/cred-helpers/ + sudo -u finchUser ls -lah /Users/finchUser/.finch/config.json + sudo -u finchUser ls -lah /Users/finchUser/.finch/finch.yaml + + # Check /Applications/Finch installation (system-wide, not user-specific) + sudo -u finchUser ls -lah /Applications/Finch/finch-credhelper/ + sudo -u finchUser ls -lah /Applications/Finch/bin/ + + # Check LaunchAgent plist file + sudo -u finchUser ls -lah /Users/finchUser/Library/LaunchAgents/ | grep finch || echo ".plist present" + sudo -u finchUser launchctl list | grep finch + + # Test LaunchAgent stop/start cycle + sudo -u finchUser /Applications/Finch/finch-credhelper/native-creds-agent-stop.sh + sudo -u finchUser ls -lah /Users/finchUser/Library/LaunchAgents/ | grep finch || echo ".plist removed" + sudo -u finchUser launchctl list | grep finch || echo "LaunchAgent stopped" + + sudo -u finchUser /Applications/Finch/finch-credhelper/native-creds-agent-start.sh + sudo -u finchUser ls -lah /Users/finchUser/Library/LaunchAgents/ | grep finch || echo ".plist present" + sudo -u finchUser launchctl list | grep finch + shell: zsh {0} + + - name: Set up local private registry with "registry" image + run: | + # Pull registry image (tests uncredentialed pull) + sudo -u finchUser finch pull registry:2 + sudo -u finchUser finch image ls + + # Create htpasswd file for registry authentication + sudo -u finchUser mkdir -p /Users/finchUser/registry-auth + sudo -u finchUser htpasswd -Bbn finchUser finchPass > /Users/finchUser/registry-auth/htpasswd + + # Start private registry with authentication + sudo -u finchUser finch run -d \ + --name test-registry \ + -p 5000:5000 \ + -v /Users/finchUser/registry-auth:/auth \ + -e REGISTRY_AUTH=htpasswd \ + -e REGISTRY_AUTH_HTPASSWD_REALM="Test Registry Realm" \ + -e REGISTRY_AUTH_HTPASSWD_PATH=/auth/htpasswd \ + registry:2 + + # Wait for registry to start + sleep 5 + + # Verify registry is running + curl -f http://localhost:5000/v2/ || echo "Registry not accessible without auth (expected)" + shell: zsh {0} + + - name: Test credential security boundaries + run: | + # Login to private registry as finchUser (stores credentials in keychain) + sudo -u finchUser finch login localhost:5000 -u finchUser -p finchPass + + # Test credential enumeration as finchUser (should work) + echo "=== Testing credential enumeration as finchUser ===" + sudo -u finchUser /Users/finchUser/.finch/cred-helpers/docker-credential-osxkeychain list || echo "List failed for finchUser" + + # Verify finchUser can access their own credentials + echo '{"ServerURL":"localhost:5000"}' | sudo -u finchUser /Users/finchUser/.finch/cred-helpers/docker-credential-osxkeychain get || echo "Get failed for finchUser" + + # Test cross-user access as otherUser (should fail) + echo "=== Testing cross-user credential access as otherUser ===" + sudo -u otherUser /Users/finchUser/.finch/cred-helpers/docker-credential-osxkeychain list 2>&1 || echo "Cross-user list blocked (expected)" + + # Test if otherUser can access finchUser's credential helper at all + echo '{"ServerURL":"localhost:5000"}' | sudo -u otherUser /Users/finchUser/.finch/cred-helpers/docker-credential-osxkeychain get 2>&1 || echo "Cross-user get blocked (expected)" + + # Test root access (should also fail - keychain is user-specific) + echo "=== Testing root credential access ===" + sudo /Users/finchUser/.finch/cred-helpers/docker-credential-osxkeychain list 2>&1 || echo "Root list blocked (expected)" + echo '{"ServerURL":"localhost:5000"}' | sudo /Users/finchUser/.finch/cred-helpers/docker-credential-osxkeychain get 2>&1 || echo "Root get blocked (expected)" + + # Test directory permissions prevent access + echo "=== Testing directory permission boundaries ===" + sudo -u otherUser ls -la /Users/finchUser/.finch/ 2>&1 || echo "Directory access blocked (expected)" + sudo -u otherUser cat /Users/finchUser/.finch/config.json 2>&1 || echo "Config access blocked (expected)" + shell: zsh {0} + + - name: Test concurrent operations and Dockerfile build (comprehensive) + run: | + # Pull nginx:alpine and push to private registry + sudo -u finchUser finch pull nginx:alpine + sudo -u finchUser finch tag nginx:alpine localhost:5000/nginx-base + sudo -u finchUser finch push localhost:5000/nginx-base + + # Create multiple tags for concurrent testing + for suffix in a b c d e; do + sudo -u finchUser finch tag nginx:alpine localhost:5000/nginx-test-$suffix + done + + # Concurrent push operations (tests credential helper under load) + echo "=== Starting concurrent pushes ===" + for suffix in a b c d e; do + sudo -u finchUser finch push localhost:5000/nginx-test-$suffix & + done + wait + + # Simple Dockerfile test (tests build-time credentials) + sudo -u finchUser mkdir -p /Users/finchUser/dockerfile-test + sudo -u finchUser tee /Users/finchUser/dockerfile-test/Dockerfile << 'EOF' + FROM localhost:5000/nginx-base + RUN echo "Build test successful" > /test-result + CMD ["cat", "/test-result"] + EOF + + # Build using private registry base image + sudo -u finchUser finch build -t localhost:5000/nginx-custom /Users/finchUser/dockerfile-test/ + sudo -u finchUser finch push localhost:5000/nginx-custom + + # Clean and verify + sudo -u finchUser finch system prune -af + curl -u finchUser:finchPass http://localhost:5000/v2/_catalog + + # Final verification for integrity after push and pull + sudo -u finchUser finch pull localhost:5000/nginx-custom + sudo -u finchUser finch run --rm localhost:5000/nginx-custom + shell: zsh {0} + + + - name: Cleanup and uninstall + if: ${{ always() }} + run: | + # Stop registry container + sudo -u finchUser finch stop test-registry || true + sudo -u finchUser finch rm test-registry || true + + # Run uninstall script as finchUser + sudo -u finchUser /Applications/Finch/uninstall.sh || echo "Uninstall script not found or failed" + + # Verify LaunchAgent is removed + sudo -u finchUser launchctl list | grep finch || echo "LaunchAgent removed (expected)" + sudo -u finchUser ls -lah /Users/finchUser/Library/LaunchAgents/ | grep finch || echo "Plist file removed (expected)" + + # Verify /Applications/Finch is removed + ls -la /Applications/Finch/ || echo "/Applications/Finch removed (expected)" + + # Clean up test users + sudo dscl . -delete /Users/finchUser || true + sudo dscl . -delete /Users/otherUser || true + sudo rm -rf /Users/finchUser || true + sudo rm -rf /Users/otherUser || true + + # Remove from sudoers + sudo sed -i '' '/finchUser ALL=(ALL) NOPASSWD: ALL/d' /etc/sudoers || true + shell: zsh {0} + + windows-cred-manager: + needs: get-tag-name + runs-on: [self-hosted, windows, amd64, test] + timeout-minutes: 30 + steps: + - name: Clean workspace + run: | + Remove-Item -Path "${{ github.workspace }}\*" -Recurse -Force -ErrorAction SilentlyContinue + shell: powershell + + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + ref: ${{ needs.get-tag-name.outputs.commit }} + fetch-depth: 0 + persist-credentials: false + submodules: true + + - uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0 + with: + go-version: ${{ env.GO_VERSION }} + cache: false + + - name: Create test users + run: | + # Create finchUser (primary test user) + $Password = ConvertTo-SecureString "FinchTestPass123!" -AsPlainText -Force + New-LocalUser -Name "finchUser" -Password $Password -Description "Finch Test User" -PasswordNeverExpires + Add-LocalGroupMember -Group "Users" -Member "finchUser" + Add-LocalGroupMember -Group "Administrators" -Member "finchUser" + + # Create otherUser (security boundary test user) + $OtherPassword = ConvertTo-SecureString "OtherTestPass123!" -AsPlainText -Force + New-LocalUser -Name "otherUser" -Password $OtherPassword -Description "Other Test User" -PasswordNeverExpires + Add-LocalGroupMember -Group "Users" -Member "otherUser" + shell: powershell + + - name: Configure git CRLF settings + run: | + git config --global core.autocrlf false + git config --global core.eol lf + shell: powershell + + - name: Clean up previous files + run: | + Remove-Item C:\Users\finchUser\.finch -Recurse -ErrorAction Ignore + Remove-Item C:\Users\finchUser\AppData\Local\.finch -Recurse -ErrorAction Ignore + make clean + shell: powershell + + - name: Build Finch as finchUser + run: | + # Build Finch using standard process + make FINCH_OS_IMAGE_LOCATION_ROOT="C:\Program Files\Finch" + shell: powershell + + - name: Generate and install MSI as finchUser + run: | + # Generate MSI using standard build process + $version = "${{ needs.get-tag-name.outputs.tag }}".TrimStart('v') + if ($version -notmatch '^[0-9]+\.[0-9]+\.[0-9]+$') { + $version = "0.0.1" + } + + powershell .\msi-builder\BuildFinchMSI.ps1 -Version $version + + # Install the generated MSI + $msiPath = ".\msi-builder\build\Finch-$version.msi" + Start-Process msiexec.exe -ArgumentList "/i", $msiPath, "/quiet", "/norestart" -Wait + + # Add Finch to PATH + $env:PATH += ";C:\Program Files\Finch\bin" + + # Initialize VM immediately after installation + & "C:\Program Files\Finch\bin\finch.exe" vm init + shell: powershell + + - name: Validate installation as finchUser + run: | + # Check credential helper files and permissions + Get-ChildItem "C:\Users\finchUser\.finch\cred-helpers\" -Force + Get-ChildItem "C:\Users\finchUser\.finch\config.json" -Force + Get-ChildItem "C:\Users\finchUser\.finch\finch.yaml" -Force + + # Check Program Files installation + Get-ChildItem "C:\Program Files\Finch\finch-credhelper\" -Force + Get-ChildItem "C:\Program Files\Finch\bin\" -Force + + # Check Windows Service + Get-Service -Name "FinchCredentialService" -ErrorAction SilentlyContinue + + # Test service stop/start cycle + & "C:\Program Files\Finch\finch-credhelper\native-creds-service-stop.ps1" + Start-Sleep -Seconds 2 + Get-Service -Name "FinchCredentialService" -ErrorAction SilentlyContinue | Where-Object {$_.Status -eq "Stopped"} + + & "C:\Program Files\Finch\finch-credhelper\native-creds-service-start.ps1" + Start-Sleep -Seconds 2 + Get-Service -Name "FinchCredentialService" | Where-Object {$_.Status -eq "Running"} + shell: powershell + + - name: Set up local private registry with "registry" image + run: | + # Pull registry image (tests uncredentialed pull) + & "C:\Program Files\Finch\bin\finch.exe" pull registry:2 + & "C:\Program Files\Finch\bin\finch.exe" image ls + + # Create registry auth directory and htpasswd file + $authDir = "C:\Users\finchUser\registry-auth" + New-Item -ItemType Directory -Path $authDir -Force + + # Create htpasswd entry (using basic auth format) + $htpasswdContent = "finchUser:`$2y`$10`$abcdefghijklmnopqrstuvwxyz1234567890ABCDEFGHIJKLMNOP" + Set-Content -Path "$authDir\htpasswd" -Value $htpasswdContent + + # Start private registry with authentication + & "C:\Program Files\Finch\bin\finch.exe" run -d ` + --name test-registry ` + -p 5000:5000 ` + -v "${authDir}:/auth" ` + -e REGISTRY_AUTH=htpasswd ` + -e "REGISTRY_AUTH_HTPASSWD_REALM=Test Registry Realm" ` + -e REGISTRY_AUTH_HTPASSWD_PATH=/auth/htpasswd ` + registry:2 + + # Wait for registry to start + Start-Sleep -Seconds 5 + + # Verify registry is running + try { + Invoke-WebRequest -Uri "http://localhost:5000/v2/" -ErrorAction Stop + } catch { + Write-Host "Registry not accessible without auth (expected)" + } + shell: powershell + + - name: Test credential security boundaries + run: | + # Login to private registry as finchUser (stores credentials in Windows Credential Manager) + & "C:\Program Files\Finch\bin\finch.exe" login localhost:5000 -u finchUser -p finchPass + + # Test credential enumeration as finchUser (should work) + Write-Host "=== Testing credential enumeration as finchUser ===" + try { + & "C:\Users\finchUser\.finch\cred-helpers\docker-credential-wincred.exe" list + } catch { + Write-Host "List failed for finchUser: $_" + } + + # Verify finchUser can access their own credentials + try { + '{"ServerURL":"localhost:5000"}' | & "C:\Users\finchUser\.finch\cred-helpers\docker-credential-wincred.exe" get + } catch { + Write-Host "Get failed for finchUser: $_" + } + + # Test cross-user access as otherUser (should fail) + Write-Host "=== Testing cross-user credential access as otherUser ===" + + # Create credential for otherUser to test isolation + $otherUserCreds = New-Object System.Management.Automation.PSCredential("otherUser", (ConvertTo-SecureString "OtherTestPass123!" -AsPlainText -Force)) + + try { + Start-Process -FilePath "C:\Users\finchUser\.finch\cred-helpers\docker-credential-wincred.exe" -ArgumentList "list" -Credential $otherUserCreds -Wait -NoNewWindow -RedirectStandardError "error.txt" + Write-Host "Cross-user list blocked (expected)" + } catch { + Write-Host "Cross-user access properly blocked: $_" + } + + # Test Administrator access (should work but access different credential store) + Write-Host "=== Testing Administrator credential access ===" + try { + & "C:\Users\finchUser\.finch\cred-helpers\docker-credential-wincred.exe" list + } catch { + Write-Host "Admin access result: $_" + } + + # Test directory permissions prevent access + Write-Host "=== Testing directory permission boundaries ===" + try { + Start-Process -FilePath "cmd.exe" -ArgumentList "/c", "dir", "C:\Users\finchUser\.finch\" -Credential $otherUserCreds -Wait -NoNewWindow -RedirectStandardError "dir_error.txt" + Write-Host "Directory access blocked (expected)" + } catch { + Write-Host "Directory access blocked: $_" + } + shell: powershell + + - name: Test concurrent operations and Dockerfile build (comprehensive) + run: | + # Pull nginx and push to private registry + & "C:\Program Files\Finch\bin\finch.exe" pull nginx:alpine + & "C:\Program Files\Finch\bin\finch.exe" tag nginx:alpine localhost:5000/nginx-base + & "C:\Program Files\Finch\bin\finch.exe" push localhost:5000/nginx-base + + # Create multiple tags for concurrent testing + $suffixes = @("a", "b", "c", "d", "e") + foreach ($suffix in $suffixes) { + & "C:\Program Files\Finch\bin\finch.exe" tag nginx:alpine "localhost:5000/nginx-test-$suffix" + } + + # Concurrent push operations (tests credential helper under load) + Write-Host "=== Starting concurrent pushes ===" + $jobs = @() + foreach ($suffix in $suffixes) { + $jobs += Start-Job -ScriptBlock { + param($suffix) + & "C:\Program Files\Finch\bin\finch.exe" push "localhost:5000/nginx-test-$suffix" + } -ArgumentList $suffix + } + $jobs | Wait-Job | Receive-Job + + # Simple Dockerfile test (tests build-time credentials) + $dockerfileDir = "C:\Users\finchUser\dockerfile-test" + New-Item -ItemType Directory -Path $dockerfileDir -Force + + # Create simple Dockerfile content + "FROM localhost:5000/nginx-base" | Out-File -FilePath "$dockerfileDir\Dockerfile" -Encoding ASCII + "RUN echo 'Build test successful' > /test-result" | Out-File -FilePath "$dockerfileDir\Dockerfile" -Append -Encoding ASCII + "CMD ['cat', '/test-result']" | Out-File -FilePath "$dockerfileDir\Dockerfile" -Append -Encoding ASCII + + # Build using private registry base image + & "C:\Program Files\Finch\bin\finch.exe" build -t localhost:5000/nginx-custom $dockerfileDir + & "C:\Program Files\Finch\bin\finch.exe" push localhost:5000/nginx-custom + + # Clean and verify + & "C:\Program Files\Finch\bin\finch.exe" system prune -af + + # Verify registry catalog + $authHeader = [Convert]::ToBase64String([Text.Encoding]::ASCII.GetBytes("finchUser:finchPass")) + try { + Invoke-WebRequest -Uri "http://localhost:5000/v2/_catalog" -Headers @{Authorization="Basic $authHeader"} + } catch { + Write-Host "Registry catalog check failed: $_" + } + + # Final verification for integrity after push and pull + & "C:\Program Files\Finch\bin\finch.exe" pull localhost:5000/nginx-custom + & "C:\Program Files\Finch\bin\finch.exe" run --rm localhost:5000/nginx-custom + shell: powershell + + - name: Cleanup and uninstall + if: ${{ always() }} + run: | + # Stop registry container + try { + & "C:\Program Files\Finch\bin\finch.exe" stop test-registry + & "C:\Program Files\Finch\bin\finch.exe" rm test-registry + } catch { + Write-Host "Registry cleanup failed or already stopped" + } + + # Run uninstall (MSI uninstall) + $productCode = Get-WmiObject -Class Win32_Product | Where-Object { $_.Name -like "*Finch*" } | Select-Object -ExpandProperty IdentifyingNumber + if ($productCode) { + msiexec /x $productCode /qn + } + + # Verify service is removed + try { + Get-Service -Name "FinchCredentialService" -ErrorAction Stop + Write-Host "Service still exists (unexpected)" + } catch { + Write-Host "Service removed (expected)" + } + + # Verify Program Files is removed + if (Test-Path "C:\Program Files\Finch") { + Write-Host "Program Files directory still exists (unexpected)" + } else { + Write-Host "Program Files directory removed (expected)" + } + shell: powershell + + - name: Remove Finch VM and Clean Up Previous Environment + if: ${{ always() }} + timeout-minutes: 5 + shell: pwsh + run: | + ./scripts/cleanup_wsl.ps1 + make clean + + # Clean up test users + try { + Remove-LocalUser -Name "finchUser" -ErrorAction SilentlyContinue + Remove-LocalUser -Name "otherUser" -ErrorAction SilentlyContinue + Remove-Item -Path "C:\Users\finchUser" -Recurse -Force -ErrorAction SilentlyContinue + Remove-Item -Path "C:\Users\otherUser" -Recurse -Force -ErrorAction SilentlyContinue + } catch { + Write-Host "User cleanup completed with warnings: $_" + } + exit 0 # Cleanup may set the exit code; just ignore + diff --git a/.gitignore b/.gitignore index 709377370..1c0d1160e 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,4 @@ test-coverage.* *.syso msi-builder/build/ contrib/packaging/rpm/rpmbuild +*.log diff --git a/Dockerfile.test-creds b/Dockerfile.test-creds new file mode 100644 index 000000000..99ca34bb4 --- /dev/null +++ b/Dockerfile.test-creds @@ -0,0 +1,3 @@ +FROM 299170649678.dkr.ecr.us-west-2.amazonaws.com/test:nginx +# Test dockerfile for credential bridge functionality +# This pulls from private ECR to test credential access during build \ No newline at end of file diff --git a/Makefile b/Makefile index 6ce060aa7..7c5be1e3e 100644 --- a/Makefile +++ b/Makefile @@ -39,6 +39,8 @@ MIN_MACOS_VERSION ?= 11.0 FINCH_DAEMON_LOCATION_ROOT ?= $(FINCH_OS_IMAGE_LOCATION_ROOT)/finch-daemon FINCH_DAEMON_LOCATION ?= $(FINCH_DAEMON_LOCATION_ROOT)/finch-daemon FINCH_DAEMON_CREDHELPER_LOCATION ?= $(FINCH_DAEMON_LOCATION_ROOT)/docker-credential-finch +FINCH_CREDHELPER_DIR ?= $(OUTDIR)/finch-credhelper +FINCH_CREDHELPER_SOCKET_LOCATION ?= $(FINCH_CREDHELPER_DIR)/native-creds.sock GOOS ?= $(shell $(GO) env GOOS) ifeq ($(GOOS),windows) @@ -79,7 +81,10 @@ endif FINCH_CORE_DIR := $(CURDIR)/deps/finch-core -remote-all: arch-test finch install.finch-core-dependencies finch.yaml networks.yaml config.yaml $(OUTDIR)/finch-daemon/finch@.service +# Include credential helper targets +include Makefile.creds + +remote-all: arch-test finch make-creds install.finch-core-dependencies finch.yaml networks.yaml config.yaml $(OUTDIR)/finch-daemon/finch@.service ifeq ($(BUILD_OS), Windows_NT) include Makefile.windows @@ -260,6 +265,8 @@ download-licenses: mkdir -p "$(LICENSEDIR)/github.com/lima-vm/lima" curl https://raw.githubusercontent.com/lima-vm/lima/master/LICENSE --output "$(LICENSEDIR)/github.com/lima-vm/lima/LICENSE" + mkdir -p "$(LICENSEDIR)/github.com/docker/docker-credential-helpers" + curl https://raw.githubusercontent.com/docker/docker-credential-helpers/master/LICENSE --output "$(LICENSEDIR)/github.com/docker/docker-credential-helpers/LICENSE" ### system-level dependencies - end ### @@ -406,6 +413,14 @@ mdlint: mdlint-ctr: $(BINARYNAME) run --rm -v "$(shell pwd):/repo:ro" -w /repo avtodev/markdown-lint:v1 --ignore CHANGELOG.md '**/*.md' +.PHONY: dev-clean +dev-clean: + -@rm -rf $(OUTDIR) 2>/dev/null || true + -@$(MAKE) -C $(FINCH_CORE_DIR) clean + -@rm ./*.tar.gz 2>/dev/null || true + -@rm ./*.qcow2 2>/dev/null || true + -@rm ./test-coverage.* 2>/dev/null || true + .PHONY: clean ifeq ($(GOOS),windows) clean: diff --git a/Makefile.creds b/Makefile.creds new file mode 100644 index 000000000..83c6b265c --- /dev/null +++ b/Makefile.creds @@ -0,0 +1,122 @@ +# Credential helper configuration +CRED_HELPER_BASE_URL := https://github.com/docker/docker-credential-helpers/releases/download/v0.9.4 + +# Platform-specific artifacts and checksums +ifeq ($(BUILD_OS), Darwin) + ifeq ($(ARCH), arm64) + CRED_HELPER_ARTIFACT := docker-credential-osxkeychain-v0.9.4.darwin-arm64 + CRED_HELPER_DIGEST := 8db5b7cbcbe0870276e56aa416416161785e450708af64cda0f1be4c392dc2e5 + else + CRED_HELPER_ARTIFACT := docker-credential-osxkeychain-v0.9.4.darwin-amd64 + CRED_HELPER_DIGEST := ad76d1a1e03def49edfa57fdb2874adf2c468cfa0438aae1b2589434796f7c01 + endif + CRED_HELPER_NAME := docker-credential-osxkeychain +else ifeq ($(BUILD_OS), Windows_NT) + ifeq ($(ARCH), arm64) + CRED_HELPER_ARTIFACT := docker-credential-wincred-v0.9.4.windows-arm64.exe + CRED_HELPER_DIGEST := 80a6ddbbabc51a8952308acf4d03c044308357cf217300461c44df066c57fe03 + else + CRED_HELPER_ARTIFACT := docker-credential-wincred-v0.9.4.windows-amd64.exe + CRED_HELPER_DIGEST := 66fdf4b50c83aeb04a9ea04af960abaf1a7b739ab263115f956b98bb0d16aa7e + endif + CRED_HELPER_NAME := docker-credential-wincred.exe +endif + +CRED_HELPER_URL := $(CRED_HELPER_BASE_URL)/$(CRED_HELPER_ARTIFACT) +CRED_HELPER_OUTPUT := $(HOME)/.finch/cred-helpers/$(CRED_HELPER_NAME) + +# Build finch credential bridge +.PHONY: finch-cred-bridge +finch-cred-bridge: + mkdir -p $(FINCH_CREDHELPER_DIR) + $(GO) build -ldflags $(LDFLAGS) -tags "$(GO_BUILD_TAGS)" -o $(FINCH_CREDHELPER_DIR)/finch-cred-bridge $(PACKAGE)/cmd/finch-credhelper + chmod 700 $(FINCH_CREDHELPER_DIR)/finch-cred-bridge + +# Download and verify credential helper +.PHONY: docker-credential-helper +docker-credential-helper: +ifeq ($(BUILD_OS), Linux) + @echo "No credential helper needed for Linux" +else + mkdir -p $(dir $(CRED_HELPER_OUTPUT)) + curl -L $(CRED_HELPER_URL) -o $(CRED_HELPER_OUTPUT) + @echo "Verifying SHA256 checksum..." + @if echo "$(CRED_HELPER_DIGEST) $(CRED_HELPER_OUTPUT)" | $(if $(findstring Darwin,$(BUILD_OS)),shasum -a 256,sha256sum) -c -; then \ + echo "Checksum verification passed"; \ + else \ + echo "Checksum verification failed" && exit 1; \ + fi + chmod 700 $(CRED_HELPER_OUTPUT) +endif + +# macOS LaunchAgent management +ifeq ($(BUILD_OS), Darwin) +PLIST_NAME := com.runfinch.cred-bridge.plist +PLIST_TEMPLATE := installer-builder/templates/$(PLIST_NAME).template +PLIST_DEST := $(HOME)/Library/LaunchAgents/$(PLIST_NAME) + +.PHONY: install-launch-agent +install-launch-agent: + @echo "Installing LaunchAgent..." + mkdir -p $(dir $(PLIST_DEST)) + sed -e "s|\$$(FINCH_CREDHELPER_DIR)/finch-cred-bridge|$(FINCH_CREDHELPER_DIR)/finch-cred-bridge|g" \ + -e "s|\$$(FINCH_CREDHELPER_SOCKET_LOCATION)|$(FINCH_CREDHELPER_SOCKET_LOCATION)|g" \ + $(PLIST_TEMPLATE) > $(PLIST_DEST) + -launchctl bootout gui/$$(id -u)/com.runfinch.cred-bridge 2>/dev/null || true + launchctl bootstrap gui/$$(id -u) $(PLIST_DEST) + @echo "LaunchAgent installed and loaded" + +.PHONY: uninstall-launch-agent +uninstall-launch-agent: + @echo "Uninstalling LaunchAgent..." + -launchctl unload $(PLIST_DEST) 2>/dev/null || true + -rm $(PLIST_DEST) 2>/dev/null || true + @echo "LaunchAgent uninstalled" + +.PHONY: setup-cred-bridge +ifeq ($(INSTALLED), true) +setup-cred-bridge: finch-cred-bridge + @echo "Built credential bridge binary - service installation deferred to installer" +else +setup-cred-bridge: finch-cred-bridge install-launch-agent + @echo "Credential bridge setup complete" +endif + +else ifeq ($(BUILD_OS), Windows_NT) +# Windows Service management +.PHONY: install-service +install-service: + @echo "Installing Windows Service..." + sc create "FinchCredBridge" binPath= "$(FINCH_CREDHELPER_DIR)\finch-cred-bridge.exe" start= demand + sc description "FinchCredBridge" "Finch Credential Bridge Service" + @echo "Windows Service installed" + +.PHONY: uninstall-service +uninstall-service: + @echo "Uninstalling Windows Service..." + -sc stop "FinchCredBridge" 2>nul + -sc delete "FinchCredBridge" 2>nul + @echo "Windows Service uninstalled" + +.PHONY: setup-cred-bridge +ifeq ($(INSTALLED), true) +setup-cred-bridge: finch-cred-bridge + @echo "Built credential bridge binary - service installation deferred to installer" +else +setup-cred-bridge: finch-cred-bridge install-service + @echo "Credential bridge setup complete" +endif + +else +.PHONY: install-launch-agent uninstall-launch-agent setup-cred-bridge install-service uninstall-service +install-launch-agent uninstall-launch-agent setup-cred-bridge install-service uninstall-service: + @echo "Service management is platform-specific" +endif + +.PHONY: make-creds +ifeq ($(BUILD_OS), Linux) +make-creds: + @echo "No credential helpers needed for Linux" +else +make-creds: finch-cred-bridge docker-credential-helper setup-cred-bridge +endif diff --git a/Makefile.darwin b/Makefile.darwin index 3f0b9b6cb..0ca4b2913 100644 --- a/Makefile.darwin +++ b/Makefile.darwin @@ -46,6 +46,7 @@ $(OS_OUTDIR)/finch.yaml: $(OS_OUTDIR) finch.yaml.d/common.yaml finch.yaml.d/mac. sed -i.bak -e "s||$(FINCH_DAEMON_LOCATION_ROOT)|g" finch.yaml.temp sed -i.bak -e "s||$(FINCH_DAEMON_LOCATION)|g" finch.yaml.temp sed -i.bak -e "s||$(FINCH_DAEMON_CREDHELPER_LOCATION)|g" finch.yaml.temp + sed -i.bak -e "s||$(FINCH_CREDHELPER_SOCKET_LOCATION)|g" finch.yaml.temp sed -i.bak -e "s||$(RUNC_OVERRIDE_AARCH64_LOCATION)|g" finch.yaml.temp sed -i.bak -e "s//$(RUNC_OVERRIDE_AARCH64_DIGEST)/g" finch.yaml.temp sed -i.bak -e "s||$(RUNC_OVERRIDE_X86_64_LOCATION)|g" finch.yaml.temp diff --git a/cmd/finch-credhelper/helper-utils.go b/cmd/finch-credhelper/helper-utils.go new file mode 100644 index 000000000..728daaf07 --- /dev/null +++ b/cmd/finch-credhelper/helper-utils.go @@ -0,0 +1,114 @@ +package main + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" +) + +const ( + maxBufferSize = 4096 + credHelpersDir = "cred-helpers" + finchConfigDir = ".finch" +) + +var credentialHelperNames = map[string]string{ + "darwin": "docker-credential-osxkeychain", + "windows": "docker-credential-wincred.exe", +} + +// Parsing JSON requests +func parseCredstoreRequest(request string) (command, input string, err error) { + lines := strings.Split(strings.TrimSpace(request), "\n") + if len(lines) == 0 { + return "", "", fmt.Errorf("empty request") + } + + command = strings.TrimSpace(lines[0]) + if command == "list" { + return command, "", nil + } + if len(lines) < 2 { + return "", "", fmt.Errorf("command %s requires input", command) + } + + return command, strings.TrimSpace(lines[1]), nil +} + +// Determining and validating the credential helper path +func getCredentialHelperPath() (string, error) { + helperName, exists := credentialHelperNames[runtime.GOOS] + if !exists { + return "", fmt.Errorf("credential helper not supported on this platform") + } + + homeDir, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("failed to get home directory") + } + + path := filepath.Join(homeDir, finchConfigDir, credHelpersDir, helperName) + if _, err := os.Stat(path); err != nil { + return "", fmt.Errorf("credential helper not found") + } + + return path, nil +} + +// Invoking the platform-specific credential helper binary +func executeCredentialHelper(command, input string) (string, error) { + credHelperPath, err := getCredentialHelperPath() + if err != nil { + return "", err + } + + cmd := exec.Command(credHelperPath, command) + if input != "" { + cmd.Stdin = strings.NewReader(input) + } + cmd.Env = os.Environ() + + output, err := cmd.CombinedOutput() + response := strings.TrimSpace(string(output)) + + // Handling errors, with special case for "get" command requiring empty cred. JSON + if err != nil { + if command == "get" { + return createEmptyCredentials(input), nil + } + return "", fmt.Errorf("credential helper failed") + } + + return response, nil +} + +// Creates default credentials when credentials are not found +func createEmptyCredentials(serverURL string) string { + return fmt.Sprintf(`{"ServerURL":"%s","Username":"","Secret":""}`, serverURL) +} + +// Process inbound credential requests from Lima VM bridge +func processCredentialRequest(conn interface{ Read([]byte) (int, error); Write([]byte) (int, error) }) error { + buffer := make([]byte, maxBufferSize) + n, err := conn.Read(buffer) + if err != nil { + return fmt.Errorf("failed to read request") + } + + request := strings.TrimSpace(string(buffer[:n])) + command, input, err := parseCredstoreRequest(request) + if err != nil { + return err + } + + response, err := executeCredentialHelper(command, input) + if err != nil { + return err + } + + _, err = conn.Write([]byte(response)) + return err +} diff --git a/cmd/finch-credhelper/mac-creds.go b/cmd/finch-credhelper/mac-creds.go new file mode 100644 index 000000000..e7a7f00ed --- /dev/null +++ b/cmd/finch-credhelper/mac-creds.go @@ -0,0 +1,29 @@ +//go:build darwin + +package main + +import ( + "fmt" + "net" + "os" +) + +// handleCredstoreRequest processes credential requests via socket activation +func handleCredstoreRequest() error { + conn, err := net.FileConn(os.Stdin) + if err != nil { + return fmt.Errorf("failed to create connection from stdin: %w", err) + } + defer conn.Close() + + return processCredentialRequest(conn) +} + +func main() { + // macOS credential helper using socket activation via launchd + // launchd passes the socket connection through stdin + if err := handleCredstoreRequest(); err != nil { + fmt.Fprintf(os.Stderr, "credential helper failed\n") + os.Exit(1) + } +} diff --git a/cmd/finch-credhelper/windows-creds.go b/cmd/finch-credhelper/windows-creds.go new file mode 100644 index 000000000..bc1e36c3b --- /dev/null +++ b/cmd/finch-credhelper/windows-creds.go @@ -0,0 +1,58 @@ +//go:build windows + +package main + +import ( + "fmt" + "log" + "net" + "os" + "path/filepath" +) + +// Windows socket server (for WSL2 socket forwarding) +func startWindowsCredentialServer() error { + + userProfile := os.Getenv("USERPROFILE") + if userProfile == "" { + return fmt.Errorf("USERPROFILE not set") + } + socketPath := filepath.Join(userProfile, ".finch", "native-creds.sock") + os.Remove(socketPath) + + listener, err := net.Listen("unix", socketPath) + if err != nil { + return fmt.Errorf("failed to create socket: %w", err) + } + + // set socket file permissions to owner only + if err := os.Chmod(socketPath, 0600); err != nil { + return fmt.Errorf("failed to set socket permissions: %w", err) + } + + defer listener.Close() + + // Accept connections + for { + conn, err := listener.Accept() + if err != nil { + log.Printf("Failed to accept connection") + continue + } + + // Handle each connection + go func(c net.Conn) { + defer c.Close() + if err := processCredentialRequest(c); err != nil { + log.Printf("Error processing credential request") + } + }(conn) + } +} + +func main() { + if err := startWindowsCredentialServer(); err != nil { + log.Printf("Windows credential server failed") + os.Exit(1) + } +} diff --git a/cmd/finch/main_native_test.go b/cmd/finch/main_native_test.go index 79c6743c7..93b5d2d5b 100644 --- a/cmd/finch/main_native_test.go +++ b/cmd/finch/main_native_test.go @@ -88,6 +88,7 @@ func TestXmain(t *testing.T) { } for _, tc := range testCases { + tc := tc t.Run(tc.name, func(t *testing.T) { t.Parallel() diff --git a/cmd/finch/nerdctl_native_test.go b/cmd/finch/nerdctl_native_test.go index c055d3076..d4400ef1e 100644 --- a/cmd/finch/nerdctl_native_test.go +++ b/cmd/finch/nerdctl_native_test.go @@ -43,6 +43,7 @@ func TestNerdctlCommand_runAdaptor(t *testing.T) { } for _, tc := range testCases { + tc := tc t.Run(tc.name, func(t *testing.T) { t.Parallel() @@ -159,6 +160,7 @@ func TestNerdctlCommand_run(t *testing.T) { } for _, tc := range testCases { + tc := tc t.Run(tc.name, func(t *testing.T) { t.Parallel() diff --git a/cmd/finch/support_bundle.go b/cmd/finch/support_bundle.go index f77db0ef7..5d3b1c20d 100644 --- a/cmd/finch/support_bundle.go +++ b/cmd/finch/support_bundle.go @@ -33,19 +33,15 @@ func newSupportBundleGenerateCommand(logger flog.Logger, builder support.BundleB RunE: newGenerateSupportBundleAction(logger, builder, ncc).runAdapter, } - includeUsage := `additional files to include in the support bundle, specified by absolute or relative path.` + - `To include journal logs for a service, prefix the file path with "service:".` + includeUsage := "additional files to include in the support bundle, specified by absolute or relative path." if runtime.GOOS != "linux" { - includeUsage += ` To include a file from the VM, prefix the file path with "vm:"` + includeUsage += `To include a file from the VM, prefix the file path with "vm:"` } - excludeUsage := `files to exclude from the support bundle. ` + - `If you specify a base name, all files matching that base name will be excluded. ` + - `If you specify an absolute or relative path, only exact matches will be excluded.` + - `To exclude journal logs for a service, prefix the file path with "service":".` + - `To exclude all journal logs, use "service:all"` supportBundleGenerateCommand.Flags().StringArray("include", []string{}, includeUsage) - supportBundleGenerateCommand.Flags().StringArray("exclude", []string{}, excludeUsage) + supportBundleGenerateCommand.Flags().StringArray("exclude", []string{}, + //nolint:lll // usage string + "files to exclude from the support bundle. if you specify a base name, all files matching that base name will be excluded. if you specify an absolute or relative path, only exact matches will be excluded") return supportBundleGenerateCommand } diff --git a/cmd/finch/version_native_test.go b/cmd/finch/version_native_test.go index ec7b6d39d..a9fd7f996 100644 --- a/cmd/finch/version_native_test.go +++ b/cmd/finch/version_native_test.go @@ -115,6 +115,7 @@ func TestVersionAction_runAdaptor(t *testing.T) { } for _, tc := range testCases { + tc := tc t.Run(tc.name, func(t *testing.T) { t.Parallel() @@ -294,6 +295,7 @@ func TestVersionAction_run(t *testing.T) { } for _, tc := range testCases { + tc := tc t.Run(tc.name, func(t *testing.T) { t.Parallel() diff --git a/contrib/packaging/rpm/finch.spec b/contrib/packaging/rpm/finch.spec index f8a289448..7c60e72ec 100644 --- a/contrib/packaging/rpm/finch.spec +++ b/contrib/packaging/rpm/finch.spec @@ -4,7 +4,40 @@ %global debug_package %{nil} %global pkg_config ../config -# finch +# default versions and commit ids +# actual values are passed as build options to rpmbuild in contrib/packaging/rpm/build.sh +%global _finch_release 1.2.3 +%global _finch_commit b84b424926d5f4e2d2abf0c51507856a73221e9d +%global _buildkit_release 0.15.1 +%global _buildkit_commit 979542e90f2cb38077c808e0867d8d2c16ed10b8 +%global _soci_release 0.7.0 +%global _soci_commit 7c6fae2c3848fe8ad161ce35d3423898cea5fde8 +%global _finch_daemon_release 0.19.1 +%global _finch_daemon_commit 7ee991cb3be01fdb0013649b9e8fc6b5e3c5a35d +%global _cosign_release 2.4.0 +%global _cosign_commit b5e7dc123a272080f4af4554054797296271e902 +%global _min_containerd_version >=1.7.24 +%global _min_nerdctl_version >=2.1.5 +%global _min_cni_plugins_version >=1.7.0 + +# build_latest takes precendence because build_local is for debugging +%if %{undefined build_latest} && %{undefined build_local} +# if neither is defined, fall back to default values +%global finch_release %{_finch_release} +%global finch_commit %{_finch_commit} +%global buildkit_release %{_buildkit_release} +%global buildkit_commit %{_buildkit_commit} +%global soci_release %{_soci_release} +%global soci_commit %{_soci_commit} +%global finch_daemon_release %{_finch_daemon_release} +%global finch_daemon_commit %{_finch_daemon_commit} +%global cosign_release %{_cosign_release} +%global cosign_commit %{_cosign_commit} +%global min_containerd_version %{_min_containerd_version} +%global min_nerdctl_version %{_min_nerdctl_version} +%global min_cni_plugins_version %{_min_cni_plugins_version} +%endif + %global finch_package github.com/runfinch/finch %global finch_src finch-%{finch_commit} %global finch_rpm_version %(r=%finch_release; echo ${r%%%%-*}) diff --git a/deps/finch-core b/deps/finch-core index a5e68a074..58bc4df92 160000 --- a/deps/finch-core +++ b/deps/finch-core @@ -1 +1 @@ -Subproject commit a5e68a07486a186d21880139a652105c77495fc3 +Subproject commit 58bc4df9208124d5c5c00759ccfb96956df74e51 diff --git a/dev-prod-setup.sh b/dev-prod-setup.sh new file mode 100755 index 000000000..b493f59fb --- /dev/null +++ b/dev-prod-setup.sh @@ -0,0 +1,101 @@ +#!/bin/bash +set -e + +echo "๐Ÿš€ Finch Development Setup Script" +echo "==================================" + +# Check if we're on macOS +if [[ "$OSTYPE" != "darwin"* ]]; then + echo "โŒ This script is for macOS only" + exit 1 +fi + +# Build what we need +echo "๐Ÿ“ฆ Building Finch and credential bridge..." +echo " Building finch binary..." +make finch 2>/dev/null || echo " โš ๏ธ Finch build had warnings (likely OK)" +echo " Building credential bridge..." +make finch-cred-bridge 2>/dev/null || echo " โš ๏ธ Credential bridge build had warnings (likely OK)" + +# Check if binaries exist +if [ ! -f "_output/bin/finch" ]; then + echo "โŒ finch binary not found. Please check build." + exit 1 +fi +if [ ! -f "_output/bin/finch-cred-bridge" ]; then + echo "โŒ finch-cred-bridge binary not found. Please check build." + exit 1 +fi +echo " โœ… Binaries built successfully" + +echo "" +echo "๐Ÿ”— Setting up development symlinks..." +echo " (This makes your dev build appear as a production installation)" + +# Create symlinks (requires sudo) +if [ ! -L "/Applications/Finch/bin/finch" ]; then + echo " Creating finch binary symlink..." + sudo mkdir -p /Applications/Finch/bin/ + sudo ln -sf "$(pwd)/_output/bin/finch" /Applications/Finch/bin/finch +else + echo " โœ… Finch binary symlink already exists" +fi + +if [ ! -L "/Applications/Finch/bin/finch-cred-bridge" ]; then + echo " Creating credential bridge symlink..." + sudo ln -sf "$(pwd)/_output/bin/finch-cred-bridge" /Applications/Finch/bin/finch-cred-bridge +else + echo " โœ… Credential bridge symlink already exists" +fi + +# Set up LaunchAgent for credential bridge +echo "" +echo "๐Ÿ”ง Setting up credential bridge LaunchAgent..." +if ! launchctl list | grep -q com.runfinch.cred-bridge; then + make install-plist + echo " โœ… LaunchAgent installed and loaded" +else + echo " โœ… LaunchAgent already loaded" +fi + +# Initialize VM if not exists +echo "" +echo "๐Ÿ–ฅ๏ธ Setting up Finch VM..." +if ! finch vm status &>/dev/null || finch vm status | grep -q "Nonexistent\|Stopped"; then + echo " Initializing VM (this may take a few minutes)..." + finch vm init + echo " Starting VM..." + finch vm start +else + echo " โœ… VM already running" +fi + +# Test credential bridge +echo "" +echo "๐Ÿงช Testing credential bridge..." +if echo -e 'list\n' | nc -U ~/.finch/creds.sock &>/dev/null; then + echo " โœ… Credential bridge is working" +else + echo " โš ๏ธ Credential bridge test failed - check logs at ~/.finch/cred-bridge.log" +fi + +echo "" +echo "โœ… Setup complete!" +echo "" +echo "๐Ÿ“ What was set up:" +echo " โ€ข Built finch and finch-cred-bridge binaries" +echo " โ€ข Created symlinks so 'finch' command uses your dev build" +echo " โ€ข Installed LaunchAgent for credential bridge" +echo " โ€ข Initialized and started Finch VM" +echo "" +echo "๐ŸŽฏ You can now use:" +echo " finch login docker.io" +echo " finch logout docker.io" +echo " finch run hello-world" +echo "" +echo "๐Ÿ” To view credential bridge logs:" +echo " tail -f ~/.finch/cred-bridge.log" +echo "" +echo "๐Ÿงน To clean up later:" +echo " make dev-uninstall" +echo " make uninstall-plist" \ No newline at end of file diff --git a/dev-setup.sh b/dev-setup.sh new file mode 100755 index 000000000..69e8dd939 --- /dev/null +++ b/dev-setup.sh @@ -0,0 +1,8 @@ +#!/bin/bash +set -e + +git submodule update --init --recursive +unset GOSUMDB +make clean +make +./_output/bin/finch vm init \ No newline at end of file diff --git a/docs/cmd/finch_support-bundle_generate.md b/docs/cmd/finch_support-bundle_generate.md index 3633b58ed..e2974168f 100644 --- a/docs/cmd/finch_support-bundle_generate.md +++ b/docs/cmd/finch_support-bundle_generate.md @@ -9,7 +9,7 @@ Generates a collection of logs and configs that can be uploaded to a Github issu ## Options ```text - --exclude stringArray files to exclude from the support bundle. If you specify a base name, all files matching that base name will be excluded. If you specify an absolute or relative path, only exact matches will be excluded. To exclude journal logs for a service, prefix the file path with "service":. To exclude all journal logs, use "service:all" + --exclude stringArray files to exclude from the support bundle. if you specify a base name, all files matching that base name will be excluded. if you specify an absolute or relative path, only exact matches will be excluded -h, --help help for generate - --include stringArray additional files to include in the support bundle, specified by absolute or relative path. To include journal logs for a service, prefix the file path with "service:". To include a file from the VM, prefix the file path with "vm:" + --include stringArray additional files to include in the support bundle, specified by absolute or relative path. to include a file from the VM, prefix the file path with "vm:" ``` diff --git a/e2e/vm/support_bundle_remote_test.go b/e2e/vm/support_bundle_remote_test.go index 8761c7d49..0f2dfdbe1 100644 --- a/e2e/vm/support_bundle_remote_test.go +++ b/e2e/vm/support_bundle_remote_test.go @@ -6,7 +6,6 @@ package vm import ( "archive/zip" "fmt" - "io" "os" "path" "path/filepath" @@ -23,29 +22,22 @@ import ( var testSupportBundle = func(o *option.Option) { ginkgo.Describe("Support bundles", func() { ginkgo.It("Should generate a support bundle", func() { - cmd := command.Run(o, "support-bundle", "generate") - out := string(cmd.Wait().Err.Contents()) - zipName := getZipName(out) - gomega.Expect(zipName).ShouldNot(gomega.BeEmpty()) - - bundlePath := filepath.Join(".", zipName) - _, err := os.Stat(bundlePath) - gomega.Expect(err).Should(gomega.BeNil()) - - reader, err := zip.OpenReader(bundlePath) - gomega.Expect(err).Should(gomega.BeNil()) - - zipBaseName := filepath.Base(bundlePath) - zipPrefix := strings.TrimSuffix(zipBaseName, filepath.Ext(zipBaseName)) - r, err := reader.Open(path.Join(zipPrefix, "logs", "journalctl", "containerd")) - gomega.Expect(err).Should(gomega.BeNil()) - b, err := io.ReadAll(r) // just to make sure logs are populated - gomega.Expect(err).Should(gomega.BeNil()) - gomega.Expect(b).ShouldNot(gomega.BeEmpty()) - gomega.Expect(reader.Close()).Should(gomega.BeNil()) - - err = os.Remove(bundlePath) - gomega.Expect(err).Should(gomega.BeNil()) + command.Run(o, "support-bundle", "generate") + entries, err := os.ReadDir(".") + gomega.Expect(err).Should(gomega.BeNil()) + bundleExists := false + for _, dirEntry := range entries { + if strings.Contains(dirEntry.Name(), "finch-support") { + _, err := os.Stat(dirEntry.Name()) + if err == nil { + bundleExists = true + } + + err = os.Remove(dirEntry.Name()) + gomega.Expect(err).Should(gomega.BeNil()) + } + } + gomega.Expect(bundleExists).Should(gomega.BeTrue()) }) 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")) @@ -58,26 +50,31 @@ var testSupportBundle = func(o *option.Option) { gomega.Expect(err).Should(gomega.BeNil()) }() - cmd := command.Run(o, "support-bundle", "generate", "--include", includeFilename) - out := string(cmd.Wait().Err.Contents()) - zipName := getZipName(out) - gomega.Expect(zipName).ShouldNot(gomega.BeEmpty()) - - bundlePath := filepath.Join(".", zipName) - _, err = os.Stat(bundlePath) - gomega.Expect(err).Should(gomega.BeNil()) - - reader, err := zip.OpenReader(bundlePath) - gomega.Expect(err).Should(gomega.BeNil()) - - zipBaseName := filepath.Base(bundlePath) - 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(bundlePath) - gomega.Expect(err).Should(gomega.BeNil()) + command.Run(o, "support-bundle", "generate", "--include", includeFilename) + entries, err := os.ReadDir(".") + gomega.Expect(err).Should(gomega.BeNil()) + bundleExists := false + for _, dirEntry := range entries { + if strings.Contains(dirEntry.Name(), "finch-support") { + _, err := os.Stat(dirEntry.Name()) + if err == nil { + bundleExists = true + } + + reader, err := zip.OpenReader(dirEntry.Name()) + gomega.Expect(err).Should(gomega.BeNil()) + + 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()) + } + } + gomega.Expect(bundleExists).Should(gomega.BeTrue()) }) 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")) @@ -94,94 +91,86 @@ var testSupportBundle = func(o *option.Option) { gomega.Expect(err).Should(gomega.BeNil()) includeAbsPath := filepath.Join(dir, includeFilename) - cmd := command.Run(o, "support-bundle", "generate", "--include", includeAbsPath) - out := string(cmd.Wait().Err.Contents()) - zipName := getZipName(out) - gomega.Expect(zipName).ShouldNot(gomega.BeEmpty()) - - bundlePath := filepath.Join(".", zipName) - _, err = os.Stat(bundlePath) - gomega.Expect(err).Should(gomega.BeNil()) - - reader, err := zip.OpenReader(bundlePath) - gomega.Expect(err).Should(gomega.BeNil()) - - zipBaseName := filepath.Base(bundlePath) - 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(bundlePath) - gomega.Expect(err).Should(gomega.BeNil()) - }) - ginkgo.It("Should generate a support bundle with an extra journal log included with --include", func() { - includeService := "dummy" - cmd := command.Run(o, "support-bundle", "generate", "--include", fmt.Sprintf("service:%s", includeService)) - out := string(cmd.Wait().Err.Contents()) - zipName := getZipName(out) - gomega.Expect(zipName).ShouldNot(gomega.BeEmpty()) - - bundlePath := filepath.Join(".", zipName) - _, err := os.Stat(bundlePath) - gomega.Expect(err).Should(gomega.BeNil()) - - reader, err := zip.OpenReader(bundlePath) - gomega.Expect(err).Should(gomega.BeNil()) - - zipBaseName := filepath.Base(bundlePath) - zipPrefix := strings.TrimSuffix(zipBaseName, filepath.Ext(zipBaseName)) - _, err = reader.Open(path.Join(zipPrefix, "misc", includeService)) - gomega.Expect(err).Should(gomega.BeNil()) - gomega.Expect(reader.Close()).Should(gomega.BeNil()) - - err = os.Remove(bundlePath) - gomega.Expect(err).Should(gomega.BeNil()) + command.Run(o, "support-bundle", "generate", "--include", includeAbsPath) + entries, err := os.ReadDir(".") + gomega.Expect(err).Should(gomega.BeNil()) + bundleExists := false + for _, dirEntry := range entries { + if strings.Contains(dirEntry.Name(), "finch-support") { + _, err := os.Stat(dirEntry.Name()) + if err == nil { + bundleExists = true + } + + reader, err := zip.OpenReader(dirEntry.Name()) + gomega.Expect(err).Should(gomega.BeNil()) + + 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()) + } + } + gomega.Expect(bundleExists).Should(gomega.BeTrue()) }) ginkgo.It("Should generate a support bundle with no extra file included with --include flag but an invalid path", func() { fakeFileName := "test123+fakefile" - cmd := command.Run(o, "support-bundle", "generate", "--include", fakeFileName) - out := string(cmd.Wait().Err.Contents()) - zipName := getZipName(out) - gomega.Expect(zipName).ShouldNot(gomega.BeEmpty()) - - bundlePath := filepath.Join(".", zipName) - _, err := os.Stat(bundlePath) - gomega.Expect(err).Should(gomega.BeNil()) - - reader, err := zip.OpenReader(bundlePath) - gomega.Expect(err).Should(gomega.BeNil()) - - zipBaseName := filepath.Base(bundlePath) - 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(bundlePath) - gomega.Expect(err).Should(gomega.BeNil()) + command.Run(o, "support-bundle", "generate", "--include", fakeFileName) + entries, err := os.ReadDir(".") + gomega.Expect(err).Should(gomega.BeNil()) + bundleExists := false + for _, dirEntry := range entries { + if strings.Contains(dirEntry.Name(), "finch-support") { + _, err := os.Stat(dirEntry.Name()) + if err == nil { + bundleExists = true + } + + reader, err := zip.OpenReader(dirEntry.Name()) + gomega.Expect(err).Should(gomega.BeNil()) + + 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()) + } + } + gomega.Expect(bundleExists).Should(gomega.BeTrue()) }) ginkgo.It("Should generate a support bundle with a default file excluded with --exclude flag by basename", func() { - cmd := command.Run(o, "support-bundle", "generate", "--exclude", "finch.yaml") - out := string(cmd.Wait().Err.Contents()) - zipName := getZipName(out) - gomega.Expect(zipName).ShouldNot(gomega.BeEmpty()) - - bundlePath := filepath.Join(".", zipName) - _, err := os.Stat(bundlePath) - gomega.Expect(err).Should(gomega.BeNil()) - - reader, err := zip.OpenReader(bundlePath) - gomega.Expect(err).Should(gomega.BeNil()) - - zipBaseName := filepath.Base(bundlePath) - zipPrefix := strings.TrimSuffix(zipBaseName, path.Ext(zipBaseName)) - _, 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(bundlePath) - gomega.Expect(err).Should(gomega.BeNil()) + command.Run(o, "support-bundle", "generate", "--exclude", "finch.yaml") + entries, err := os.ReadDir(".") + gomega.Expect(err).Should(gomega.BeNil()) + bundleExists := false + for _, dirEntry := range entries { + if strings.Contains(dirEntry.Name(), "finch-support") { + _, err := os.Stat(dirEntry.Name()) + if err == nil { + bundleExists = true + } + + reader, err := zip.OpenReader(dirEntry.Name()) + gomega.Expect(err).Should(gomega.BeNil()) + + zipBaseName := filepath.Base(dirEntry.Name()) + zipPrefix := strings.TrimSuffix(zipBaseName, path.Ext(zipBaseName)) + _, 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()) + } + } + gomega.Expect(bundleExists).Should(gomega.BeTrue()) }) 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")) @@ -196,26 +185,31 @@ var testSupportBundle = func(o *option.Option) { absPath, err := filepath.Abs(includeFilename) gomega.Expect(err).Should(gomega.BeNil()) - cmd := command.Run(o, "support-bundle", "generate", "--include", includeFilename, "--exclude", absPath) - out := string(cmd.Wait().Err.Contents()) - zipName := getZipName(out) - gomega.Expect(zipName).ShouldNot(gomega.BeEmpty()) - - bundlePath := filepath.Join(".", zipName) - _, err = os.Stat(bundlePath) - gomega.Expect(err).Should(gomega.BeNil()) - - reader, err := zip.OpenReader(bundlePath) - gomega.Expect(err).Should(gomega.BeNil()) - - zipBaseName := filepath.Base(bundlePath) - 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(bundlePath) - gomega.Expect(err).Should(gomega.BeNil()) + command.Run(o, "support-bundle", "generate", "--include", includeFilename, "--exclude", absPath) + entries, err := os.ReadDir(".") + gomega.Expect(err).Should(gomega.BeNil()) + bundleExists := false + for _, dirEntry := range entries { + if strings.Contains(dirEntry.Name(), "finch-support") { + _, err := os.Stat(dirEntry.Name()) + if err == nil { + bundleExists = true + } + + reader, err := zip.OpenReader(dirEntry.Name()) + gomega.Expect(err).Should(gomega.BeNil()) + + 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()) + } + } + gomega.Expect(bundleExists).Should(gomega.BeTrue()) }) 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")) @@ -228,94 +222,59 @@ var testSupportBundle = func(o *option.Option) { gomega.Expect(err).Should(gomega.BeNil()) }() - cmd := command.Run(o, "support-bundle", "generate", "--include", includeFilename, "--exclude", filepath.Join(".", includeFilename)) - out := string(cmd.Wait().Err.Contents()) - zipName := getZipName(out) - gomega.Expect(zipName).ShouldNot(gomega.BeEmpty()) - - bundlePath := filepath.Join(".", zipName) - _, err = os.Stat(bundlePath) - gomega.Expect(err).Should(gomega.BeNil()) - - reader, err := zip.OpenReader(bundlePath) - gomega.Expect(err).Should(gomega.BeNil()) - - zipBaseName := filepath.Base(bundlePath) - 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(bundlePath) - gomega.Expect(err).Should(gomega.BeNil()) + command.Run(o, "support-bundle", "generate", "--include", includeFilename, "--exclude", filepath.Join(".", includeFilename)) + entries, err := os.ReadDir(".") + gomega.Expect(err).Should(gomega.BeNil()) + bundleExists := false + for _, dirEntry := range entries { + if strings.Contains(dirEntry.Name(), "finch-support") { + _, err := os.Stat(dirEntry.Name()) + if err == nil { + bundleExists = true + } + + reader, err := zip.OpenReader(dirEntry.Name()) + gomega.Expect(err).Should(gomega.BeNil()) + + 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()) + } + } + gomega.Expect(bundleExists).Should(gomega.BeTrue()) }) ginkgo.It("Should generate a support bundle with no file excluded with --exclude flag with invalid path", func() { fakeFileName := "test123+fakefile" - cmd := command.Run(o, "support-bundle", "generate", "--exclude", fakeFileName) - out := string(cmd.Wait().Err.Contents()) - zipName := getZipName(out) - gomega.Expect(zipName).ShouldNot(gomega.BeEmpty()) - - bundlePath := filepath.Join(".", zipName) - _, err := os.Stat(bundlePath) - gomega.Expect(err).Should(gomega.BeNil()) - - reader, err := zip.OpenReader(bundlePath) - gomega.Expect(err).Should(gomega.BeNil()) - - zipBaseName := filepath.Base(bundlePath) - 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(bundlePath) - gomega.Expect(err).Should(gomega.BeNil()) - }) - ginkgo.It("Should generate a support bundle with a default journal log excluded with --exclude", func() { - excludeService := "containerd" - cmd := command.Run(o, "support-bundle", "generate", "--exclude", fmt.Sprintf("service:%s", excludeService)) - out := string(cmd.Wait().Err.Contents()) - zipName := getZipName(out) - gomega.Expect(zipName).ShouldNot(gomega.BeEmpty()) - - bundlePath := filepath.Join(".", zipName) - _, err := os.Stat(bundlePath) - gomega.Expect(err).Should(gomega.BeNil()) - - reader, err := zip.OpenReader(bundlePath) - gomega.Expect(err).Should(gomega.BeNil()) - - zipBaseName := filepath.Base(bundlePath) - zipPrefix := strings.TrimSuffix(zipBaseName, filepath.Ext(zipBaseName)) - _, err = reader.Open(path.Join(zipPrefix, "logs", "journalctl", excludeService)) - gomega.Expect(err).ShouldNot(gomega.BeNil()) - gomega.Expect(reader.Close()).Should(gomega.BeNil()) - - err = os.Remove(bundlePath) - gomega.Expect(err).Should(gomega.BeNil()) - }) - ginkgo.It("Should generate a support bundle with no journal logs with --exclude service:all", func() { - cmd := command.Run(o, "support-bundle", "generate", "--exclude", "service:all") - out := string(cmd.Wait().Err.Contents()) - zipName := getZipName(out) - gomega.Expect(zipName).ShouldNot(gomega.BeEmpty()) - - bundlePath := filepath.Join(".", zipName) - _, err := os.Stat(bundlePath) - gomega.Expect(err).Should(gomega.BeNil()) - - reader, err := zip.OpenReader(bundlePath) - gomega.Expect(err).Should(gomega.BeNil()) - - zipBaseName := filepath.Base(bundlePath) - zipPrefix := strings.TrimSuffix(zipBaseName, filepath.Ext(zipBaseName)) - _, err = reader.Open(path.Join(zipPrefix, "logs", "journalctl")) - gomega.Expect(err).ShouldNot(gomega.BeNil()) - gomega.Expect(reader.Close()).Should(gomega.BeNil()) - - err = os.Remove(bundlePath) - gomega.Expect(err).Should(gomega.BeNil()) + command.Run(o, "support-bundle", "generate", "--exclude", fakeFileName) + entries, err := os.ReadDir(".") + gomega.Expect(err).Should(gomega.BeNil()) + bundleExists := false + for _, dirEntry := range entries { + if strings.Contains(dirEntry.Name(), "finch-support") { + _, err := os.Stat(dirEntry.Name()) + if err == nil { + bundleExists = true + } + + reader, err := zip.OpenReader(dirEntry.Name()) + gomega.Expect(err).Should(gomega.BeNil()) + + 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()) + } + } + gomega.Expect(bundleExists).Should(gomega.BeTrue()) }) 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")) @@ -328,26 +287,31 @@ var testSupportBundle = func(o *option.Option) { gomega.Expect(err).Should(gomega.BeNil()) }() - cmd := command.Run(o, "support-bundle", "generate", "--include", includeFilename, "--exclude", includeFilename) - out := string(cmd.Wait().Err.Contents()) - zipName := getZipName(out) - gomega.Expect(zipName).ShouldNot(gomega.BeEmpty()) - - bundlePath := filepath.Join(".", zipName) - _, err = os.Stat(bundlePath) - gomega.Expect(err).Should(gomega.BeNil()) - - reader, err := zip.OpenReader(bundlePath) - gomega.Expect(err).Should(gomega.BeNil()) - - zipBaseName := filepath.Base(bundlePath) - 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(bundlePath) - gomega.Expect(err).Should(gomega.BeNil()) + command.Run(o, "support-bundle", "generate", "--include", includeFilename, "--exclude", includeFilename) + entries, err := os.ReadDir(".") + gomega.Expect(err).Should(gomega.BeNil()) + bundleExists := false + for _, dirEntry := range entries { + if strings.Contains(dirEntry.Name(), "finch-support") { + _, err := os.Stat(dirEntry.Name()) + if err == nil { + bundleExists = true + } + + reader, err := zip.OpenReader(dirEntry.Name()) + gomega.Expect(err).Should(gomega.BeNil()) + + 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()) + } + } + gomega.Expect(bundleExists).Should(gomega.BeTrue()) }) ginkgo.It("Should fail to generate a support bundle when the VM is nonexistent", func() { if runtime.GOOS == "linux" { @@ -359,26 +323,23 @@ var testSupportBundle = func(o *option.Option) { time.Sleep(1 * time.Second) defer command.New(o, "vm", "init").WithoutCheckingExitCode().WithTimeoutInSeconds(160).Run() - cmd := command.New(o, "support-bundle", "generate").WithoutSuccessfulExit().Run() - out := string(cmd.Wait().Err.Contents()) - zipName := getZipName(out) - gomega.Expect(zipName).Should(gomega.BeEmpty()) - }) - }) -} - -func getZipName(cmdOutput string) string { - for _, line := range strings.Split(cmdOutput, "\n") { - if strings.Contains(line, "Bundle created: finch-support") { - bundleLine := strings.Split(line, "\"") - gomega.Expect(bundleLine).To(gomega.HaveLen(5)) + command.New(o, "support-bundle", "generate").WithoutSuccessfulExit().Run() - zipName := strings.Split(bundleLine[3], ": ") - gomega.Expect(zipName).To(gomega.HaveLen(2)) - gomega.Expect(strings.HasPrefix(zipName[1], "finch-support")).To(gomega.BeTrue()) + entries, err := os.ReadDir(".") + gomega.Expect(err).Should(gomega.BeNil()) + bundleExists := false + for _, dirEntry := range entries { + if strings.Contains(dirEntry.Name(), "finch-support") { + _, err := os.Stat(dirEntry.Name()) + if err == nil { + bundleExists = true + } - return strings.TrimSpace(zipName[1]) - } - } - return "" + err = os.Remove(dirEntry.Name()) + gomega.Expect(err).Should(gomega.BeNil()) + } + } + gomega.Expect(bundleExists).Should(gomega.BeFalse()) + }) + }) } diff --git a/finch.yaml.d/mac.yaml b/finch.yaml.d/mac.yaml index 1be4e99ee..c3a235f83 100644 --- a/finch.yaml.d/mac.yaml +++ b/finch.yaml.d/mac.yaml @@ -7,6 +7,9 @@ provision: - mode: boot script: | modprobe virtiofs + - mode: boot + script: | + dnf install -y socat # port this to common.yaml after windows socket forwarding is added - mode: user script: | @@ -57,3 +60,6 @@ hostResolver: portForwards: - guestSocket: "/run/finch.sock" hostSocket: "{{.Dir}}/sock/finch.sock" +- guestSocket: "/tmp/native-creds.sock" + hostSocket: "" + reverse: true \ No newline at end of file diff --git a/finch.yaml.d/windows.yaml b/finch.yaml.d/windows.yaml index a8a7ddd1e..37abd8ce4 100644 --- a/finch.yaml.d/windows.yaml +++ b/finch.yaml.d/windows.yaml @@ -1,2 +1,2 @@ vmType: wsl2 -mountType: wsl2 +mountType: wsl2 \ No newline at end of file diff --git a/go.mod b/go.mod index 753c7d6ec..842f1bc22 100644 --- a/go.mod +++ b/go.mod @@ -59,7 +59,7 @@ require ( github.com/containerd/ttrpc v1.2.7 // indirect github.com/containerd/typeurl/v2 v2.2.3 // indirect github.com/containernetworking/cni v1.3.0 // indirect - github.com/containernetworking/plugins v1.9.0 // indirect + github.com/containernetworking/plugins v1.8.0 // indirect github.com/containers/ocicrypt v1.2.1 // indirect github.com/coreos/go-semver v0.3.1 // indirect github.com/coreos/go-systemd/v22 v22.6.0 // indirect diff --git a/go.sum b/go.sum index 9d26cec13..cc2e916b7 100644 --- a/go.sum +++ b/go.sum @@ -67,8 +67,8 @@ github.com/containerd/typeurl/v2 v2.2.3 h1:yNA/94zxWdvYACdYO8zofhrTVuQY73fFU1y++ github.com/containerd/typeurl/v2 v2.2.3/go.mod h1:95ljDnPfD3bAbDJRugOiShd/DlAAsxGtUBhJxIn7SCk= github.com/containernetworking/cni v1.3.0 h1:v6EpN8RznAZj9765HhXQrtXgX+ECGebEYEmnuFjskwo= github.com/containernetworking/cni v1.3.0/go.mod h1:Bs8glZjjFfGPHMw6hQu82RUgEPNGEaBb9KS5KtNMnJ4= -github.com/containernetworking/plugins v1.9.0 h1:Mg3SXBdRGkdXyFC4lcwr6u2ZB2SDeL6LC3U+QrEANuQ= -github.com/containernetworking/plugins v1.9.0/go.mod h1:JG3BxoJifxxHBhG3hFyxyhid7JgRVBu/wtooGEvWf1c= +github.com/containernetworking/plugins v1.8.0 h1:WjGbV/0UQyo8A4qBsAh6GaDAtu1hevxVxsEuqtBqUFk= +github.com/containernetworking/plugins v1.8.0/go.mod h1:JG3BxoJifxxHBhG3hFyxyhid7JgRVBu/wtooGEvWf1c= github.com/containers/ocicrypt v1.2.1 h1:0qIOTT9DoYwcKmxSt8QJt+VzMY18onl9jUXsxpVhSmM= github.com/containers/ocicrypt v1.2.1/go.mod h1:aD0AAqfMp0MtwqWgHM1bUwe1anx0VazI108CRrSKINQ= github.com/coreos/go-semver v0.3.1 h1:yi21YpKnrx1gt5R+la8n5WgS0kCrsPp33dmEyHReZr4= diff --git a/installer-builder/darwin/Resources/uninstall.sh b/installer-builder/darwin/Resources/uninstall.sh index 2b358b9e5..8f204dd34 100755 --- a/installer-builder/darwin/Resources/uninstall.sh +++ b/installer-builder/darwin/Resources/uninstall.sh @@ -37,6 +37,24 @@ else echo "[2/4] [ERROR] Could not delete application informations" >&2 fi +#remove credential bridge LaunchAgent +# handle the user who ran sudo ./uninstall.sh, or all users if run directly as root +REAL_USER="${SUDO_USER:-}" +if [ -n "$REAL_USER" ] && [ "$REAL_USER" != "root" ]; then + # Single user cleanup (most common case) + sudo -u "$REAL_USER" launchctl bootout gui/$(id -u "$REAL_USER")/com.runfinch.cred-bridge 2>/dev/null || true + rm -f "/Users/$REAL_USER/Library/LaunchAgents/com.runfinch.cred-bridge.plist" 2>/dev/null || true +else + # Cleanup for all users (fallback) + for user_home in /Users/*; do + if [ -d "$user_home/Library/LaunchAgents" ]; then + username=$(basename "$user_home") + sudo -u "$username" launchctl bootout gui/$(id -u "$username")/com.runfinch.cred-bridge 2>/dev/null || true + rm -f "$user_home/Library/LaunchAgents/com.runfinch.cred-bridge.plist" 2>/dev/null || true + fi + done +fi + #remove application source distribution [ -e "/Applications/Finch" ] && rm -rf /Applications/Finch && rm -rf /opt/finch && rm -rf /private/var/run/finch-lima && rm -rf /private/etc/sudoers.d/finch-lima if [ $? -eq 0 ] diff --git a/installer-builder/darwin/scripts/postinstall b/installer-builder/darwin/scripts/postinstall index 421bf914a..8041d8d71 100755 --- a/installer-builder/darwin/scripts/postinstall +++ b/installer-builder/darwin/scripts/postinstall @@ -33,4 +33,15 @@ pkgutil --pkgs | grep '^org\.Finch\.' | grep -v '^org\.Finch\.__VERSION__' | whi sudo pkgutil --forget "$pkg" done +# Secure credential helper directory after blanket 777 +chmod 750 /Applications/Finch/finch-credhelper +chmod 750 /Applications/Finch/finch-credhelper/finch-cred-bridge +chmod 750 /Applications/Finch/finch-credhelper/native-creds-agent-start.sh +chmod 750 /Applications/Finch/finch-credhelper/native-creds-agent-stop.sh +chmod 640 /Applications/Finch/finch-credhelper/com.runfinch.cred-bridge.plist.template +# Socket will be created by LaunchAgent with correct 600 permissions + +# Create and add native credhelper .plist to LaunchAgents +/Applications/Finch/finch-credhelper/native-creds-agent-start.sh + echo "Post installation process finished." diff --git a/installer-builder/templates/com.runfinch.cred-bridge.plist.template b/installer-builder/templates/com.runfinch.cred-bridge.plist.template new file mode 100644 index 000000000..62a63b7a8 --- /dev/null +++ b/installer-builder/templates/com.runfinch.cred-bridge.plist.template @@ -0,0 +1,34 @@ + + + + + + Label + com.runfinch.cred-bridge + + ProgramArguments + + $(FINCH_CREDHELPER_DIR)/finch-cred-bridge + + + Sockets + + Listeners + + + SockPathName + $(FINCH_CREDHELPER_SOCKET_LOCATION) + SockPathMode + 384 + + + + + inetdCompatibility + + Wait + + + + + \ No newline at end of file diff --git a/installer-builder/tools/build-macos-pkg.sh b/installer-builder/tools/build-macos-pkg.sh index ef267ef24..46ca6fb48 100755 --- a/installer-builder/tools/build-macos-pkg.sh +++ b/installer-builder/tools/build-macos-pkg.sh @@ -40,6 +40,12 @@ buildPkgInstaller() { cp ./installer-builder/darwin/Resources/uninstall.sh $INSTALLER_FULL_PATH/darwinpkg/Applications/Finch sed -i '' -e 's/__VERSION__/'"${VERSION}"'/g' $INSTALLER_FULL_PATH/darwinpkg/Applications/Finch/uninstall.sh + #copy credential helper tools and templates, need to be accessible by postinstall and for re-init on error + mkdir -p $INSTALLER_FULL_PATH/darwinpkg/Applications/Finch/finch-credhelper + cp ./installer-builder/templates/com.runfinch.cred-bridge.plist.template $INSTALLER_FULL_PATH/darwinpkg/Applications/Finch/finch-credhelper/ + cp ./installer-builder/tools/native-creds-agent-start.sh $INSTALLER_FULL_PATH/darwinpkg/Applications/Finch/finch-credhelper/ + cp ./installer-builder/tools/native-creds-agent-stop.sh $INSTALLER_FULL_PATH/darwinpkg/Applications/Finch/finch-credhelper/ + #construct pkg directory mkdir -p $INSTALLER_FULL_PATH/package mkdir -p $INSTALLER_FULL_PATH/signed diff --git a/installer-builder/tools/native-creds-agent-start.sh b/installer-builder/tools/native-creds-agent-start.sh new file mode 100644 index 000000000..70168084a --- /dev/null +++ b/installer-builder/tools/native-creds-agent-start.sh @@ -0,0 +1,18 @@ +#!/bin/bash +set -e + +# Setting relevant paths +FINCH_INSTALL_DIR="/Applications/Finch" +TEMPLATE_PATH="$FINCH_INSTALL_DIR/finch-credhelper/com.runfinch.cred-bridge.plist.template" +PLIST_PATH="$HOME/Library/LaunchAgents/com.runfinch.cred-bridge.plist" +mkdir -p "$HOME/Library/LaunchAgents" + +# Replace placeholders in template +sed -e "s|\$(FINCH_CREDHELPER_DIR)/finch-cred-bridge|$FINCH_INSTALL_DIR/finch-cred-bridge|g" \ + -e "s|\$(FINCH_CREDHELPER_SOCKET_LOCATION)|$HOME/.finch/native-creds.sock|g" \ + "$TEMPLATE_PATH" > "$PLIST_PATH" + +# Load the .plist into LaunchAgent +launchctl bootstrap gui/$(id -u) "$PLIST_PATH" + +echo "Finch credential bridge LaunchAgent installed and loaded" \ No newline at end of file diff --git a/installer-builder/tools/native-creds-agent-stop.sh b/installer-builder/tools/native-creds-agent-stop.sh new file mode 100644 index 000000000..97caf413c --- /dev/null +++ b/installer-builder/tools/native-creds-agent-stop.sh @@ -0,0 +1,12 @@ +#!/bin/bash +set -e + +PLIST_PATH="$HOME/Library/LaunchAgents/com.runfinch.cred-bridge.plist" + +# Unload the LaunchAgent +launchctl bootout gui/$(id -u)/com.runfinch.cred-bridge 2>/dev/null || true + +# Remove the plist file +rm -f "$PLIST_PATH" + +echo "Finch credential bridge LaunchAgent uninstalled" \ No newline at end of file diff --git a/msi-builder/BuildFinchMSI.ps1 b/msi-builder/BuildFinchMSI.ps1 index fce453af0..b708b40c1 100644 --- a/msi-builder/BuildFinchMSI.ps1 +++ b/msi-builder/BuildFinchMSI.ps1 @@ -63,6 +63,11 @@ Copy-Item -Path (Join-Path -Path $scriptDirectory -ChildPath "uninstall.bat") -D Copy-Item -Path (Join-Path -Path $scriptDirectory -ChildPath "removevm.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") + +# Copy credential service management scripts to finch-credhelper directory +$credHelperDir = Join-Path -Path $scriptDirectory -ChildPath "build\Finch\finch-credhelper" +Copy-Item -Path (Join-Path -Path $scriptDirectory -ChildPath "native-creds-service-start.ps1") -Destination $credHelperDir +Copy-Item -Path (Join-Path -Path $scriptDirectory -ChildPath "native-creds-service-stop.ps1") -Destination $credHelperDir Write-Host "Files copied successfully." # 5. Copy WiX template and update resources path and version diff --git a/msi-builder/FinchMSITemplate.wxs b/msi-builder/FinchMSITemplate.wxs index aa1823d17..ae045dabd 100644 --- a/msi-builder/FinchMSITemplate.wxs +++ b/msi-builder/FinchMSITemplate.wxs @@ -89,6 +89,13 @@ + + + + + + + @@ -100,6 +107,7 @@ + diff --git a/msi-builder/install-service.bat b/msi-builder/install-service.bat new file mode 100644 index 000000000..186ac6551 --- /dev/null +++ b/msi-builder/install-service.bat @@ -0,0 +1,22 @@ +@echo off +REM Install Finch Credential Bridge Windows Service + +set SERVICE_NAME=FinchCredBridge +set SERVICE_DISPLAY_NAME=Finch Credential Bridge +set SERVICE_DESCRIPTION=Provides credential bridge functionality for Finch containers +set SERVICE_BINARY=%~dp0finch-credhelper\finch-cred-bridge.exe + +echo Installing %SERVICE_DISPLAY_NAME% service... + +REM Create the service +sc create "%SERVICE_NAME%" binPath= "\"%SERVICE_BINARY%\"" start= demand DisplayName= "%SERVICE_DISPLAY_NAME%" +if %ERRORLEVEL% neq 0 ( + echo Failed to create service + exit /b 1 +) + +REM Set service description +sc description "%SERVICE_NAME%" "%SERVICE_DESCRIPTION%" + +echo %SERVICE_DISPLAY_NAME% service installed successfully +echo Use services.msc to manage the service \ No newline at end of file diff --git a/msi-builder/native-creds-service-start.ps1 b/msi-builder/native-creds-service-start.ps1 new file mode 100644 index 000000000..da8283a69 --- /dev/null +++ b/msi-builder/native-creds-service-start.ps1 @@ -0,0 +1,39 @@ +param( + [string]$InstallDir = $env:ProgramFiles + "\Finch" +) + +$ServiceName = "FinchCredBridge" +$ServiceDisplayName = "Finch Credential Bridge" +$ServiceDescription = "Provides credential bridge functionality for Finch containers" +$ServiceBinary = Join-Path $InstallDir "finch-credhelper\finch-cred-bridge.exe" + +Write-Host "Installing $ServiceDisplayName service..." + +# Check if service already exists +$existingService = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue +if ($existingService) { + Write-Host "Service already exists, stopping and removing..." + Stop-Service -Name $ServiceName -Force -ErrorAction SilentlyContinue + & sc.exe delete $ServiceName + Start-Sleep -Seconds 2 +} + +# Create the service to run as the current user +$currentUser = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name +$result = & sc.exe create $ServiceName binPath= "`"$ServiceBinary`"" start= demand DisplayName= $ServiceDisplayName obj= $currentUser +if ($LASTEXITCODE -ne 0) { + Write-Error "Failed to create service: $result" + exit 1 +} + +# Set service description +& sc.exe description $ServiceName $ServiceDescription + +# Secure credential helper files (Windows equivalent of chmod 700) +$credHelperDir = Join-Path $InstallDir "finch-credhelper" +icacls "$credHelperDir" /inheritance:r /grant:r "$env:USERNAME:(F)" /grant:r "Administrators:(F)" 2>$null +icacls "$credHelperDir\finch-cred-bridge.exe" /inheritance:r /grant:r "$env:USERNAME:(F)" /grant:r "Administrators:(F)" 2>$null +icacls "$credHelperDir\native-creds-service-start.ps1" /inheritance:r /grant:r "$env:USERNAME:(F)" /grant:r "Administrators:(F)" 2>$null +icacls "$credHelperDir\native-creds-service-stop.ps1" /inheritance:r /grant:r "$env:USERNAME:(F)" /grant:r "Administrators:(F)" 2>$null + +Write-Host "$ServiceDisplayName service installed successfully" \ No newline at end of file diff --git a/msi-builder/native-creds-service-stop.ps1 b/msi-builder/native-creds-service-stop.ps1 new file mode 100644 index 000000000..bc714c063 --- /dev/null +++ b/msi-builder/native-creds-service-stop.ps1 @@ -0,0 +1,21 @@ +$ServiceName = "FinchCredBridge" + +Write-Host "Stopping and removing $ServiceName service..." + +# Check if service exists +$existingService = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue +if ($existingService) { + # Stop the service + Stop-Service -Name $ServiceName -Force -ErrorAction SilentlyContinue + + # Remove the service + & sc.exe delete $ServiceName + + if ($LASTEXITCODE -eq 0) { + Write-Host "$ServiceName service removed successfully" + } else { + Write-Warning "Failed to remove service, but continuing..." + } +} else { + Write-Host "$ServiceName service not found, nothing to remove" +} \ No newline at end of file diff --git a/msi-builder/postinstall.bat b/msi-builder/postinstall.bat index e393bffd1..a0527bc5f 100644 --- a/msi-builder/postinstall.bat +++ b/msi-builder/postinstall.bat @@ -1,6 +1,7 @@ @echo off SET InstallDir=%~1 SET FilePath=%InstallDir%\os\finch.yaml +SET CredHelperDir=%InstallDir%\finch-credhelper if exist "%FilePath%" ( powershell -Command "$installPath = '%InstallDir%'.Replace('\', '/'); $content = Get-Content '%FilePath%' -Raw; $content = $content -replace '__INSTALLFOLDER__', $installPath; $content = $content.Replace(\"`r`n\", \"`n\"); $utf8NoBom = New-Object System.Text.UTF8Encoding $false; [System.IO.File]::WriteAllText('%FilePath%', $content, $utf8NoBom)" @@ -8,6 +9,11 @@ if exist "%FilePath%" ( icacls "%InstallDir%\lima\data" /grant Users:(OI)(CI)M +:: Install credential bridge service +if exist "%CredHelperDir%\native-creds-service-start.ps1" ( + powershell -ExecutionPolicy Bypass -File "%CredHelperDir%\native-creds-service-start.ps1" -InstallDir "%InstallDir%" +) + :: 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" diff --git a/msi-builder/uninstall.bat b/msi-builder/uninstall.bat index 42b278102..d5920e22d 100644 --- a/msi-builder/uninstall.bat +++ b/msi-builder/uninstall.bat @@ -1,5 +1,11 @@ @echo off SET InstallDir=%~1 +SET CredHelperDir=%InstallDir%\finch-credhelper + +:: Stop and remove credential bridge service +if exist "%CredHelperDir%\native-creds-service-stop.ps1" ( + powershell -ExecutionPolicy Bypass -File "%CredHelperDir%\native-creds-service-stop.ps1" +) :: Stop and remove any running instance finch.exe vm stop -f ^ & diff --git a/pkg/command/nerdctl_native_test.go b/pkg/command/nerdctl_native_test.go index b8a1afec4..e41a4524d 100644 --- a/pkg/command/nerdctl_native_test.go +++ b/pkg/command/nerdctl_native_test.go @@ -57,6 +57,7 @@ func TestLimaCmdCreator_Create(t *testing.T) { } for _, tc := range testCases { + tc := tc t.Run(tc.name, func(t *testing.T) { t.Parallel() diff --git a/pkg/config/config_native_test.go b/pkg/config/config_native_test.go index 52ae98968..05900c7e9 100644 --- a/pkg/config/config_native_test.go +++ b/pkg/config/config_native_test.go @@ -8,11 +8,10 @@ package config import ( "testing" + "github.com/runfinch/finch/pkg/mocks" "github.com/spf13/afero" "github.com/stretchr/testify/require" "go.uber.org/mock/gomock" - - "github.com/runfinch/finch/pkg/mocks" ) func platformLoadTests(t *testing.T) []loadTestCase { diff --git a/pkg/config/defaults_darwin.go b/pkg/config/defaults_darwin.go index edb0274f3..9a1ed9c17 100644 --- a/pkg/config/defaults_darwin.go +++ b/pkg/config/defaults_darwin.go @@ -59,6 +59,12 @@ func cpuDefault(cfg *Finch, deps LoadSystemDeps) { } } +func credHelperDefault(cfg *Finch) { + if cfg.CredsHelpers == nil || len(cfg.CredsHelpers) == 0 { + cfg.CredsHelpers = []string{"osxkeychain"} + } +} + // applyDefaults sets default configuration options if they are not already set. func applyDefaults( cfg *Finch, @@ -75,6 +81,6 @@ func applyDefaults( } vmDefault(cfg, supportsVz) rosettaDefault(cfg) - + credHelperDefault(cfg) return cfg } diff --git a/pkg/config/defaults_windows.go b/pkg/config/defaults_windows.go index 397a987c8..ccbbd667e 100644 --- a/pkg/config/defaults_windows.go +++ b/pkg/config/defaults_windows.go @@ -18,6 +18,13 @@ func vmDefault(cfg *Finch) { } } +// Different because the other defaults use single values; this uses a slice +func credHelperDefault(cfg *Finch) { + if cfg.CredsHelpers == nil || len(cfg.CredsHelpers) == 0 { + cfg.CredsHelpers = []string{"wincred"} + } +} + // applyDefaults sets default configuration options if they are not already set. func applyDefaults( cfg *Finch, @@ -26,5 +33,6 @@ func applyDefaults( _ command.Creator, ) *Finch { vmDefault(cfg) + credHelperDefault(cfg) return cfg } diff --git a/pkg/config/nerdctl_config_applier.go b/pkg/config/nerdctl_config_applier.go index 55d7f1339..77c072274 100644 --- a/pkg/config/nerdctl_config_applier.go +++ b/pkg/config/nerdctl_config_applier.go @@ -25,6 +25,32 @@ const ( nerdctlRootfulCfgPath = "/etc/nerdctl/nerdctl.toml" ) +const osxkeychainCredHelperScript = `#!/bin/bash +input=$(cat) +printf "%s\n%s\n" "$1" "$input" | socat - UNIX-CONNECT:/tmp/native-creds.sock 2>/dev/null +exit_code=$? +if [ $exit_code -ne 0 ]; then + echo '{"error": "credential helper connection failed"}' + exit 1 +fi +` + +const wincredCredHelperScript = `#!/bin/bash +input=$(cat) +SOCKET_PATH=$(wslpath "$(wslvar USERPROFILE)")/.finch/native-creds.sock +printf "%s\n%s\n" "$1" "$input" | socat - UNIX-CONNECT:"$SOCKET_PATH" 2>/dev/null +exit_code=$? +if [ $exit_code -ne 0 ]; then + echo '{"error": "credential helper connection failed"}' + exit 1 +fi +` + +var helperTemplateScripts = map[string]string{ + "osxkeychain": osxkeychainCredHelperScript, + "wincred": wincredCredHelperScript, +} + type nerdctlConfigApplier struct { dialer fssh.Dialer fs afero.Fs @@ -95,12 +121,27 @@ func updateEnvironment(fs afero.Fs, fc *Finch, finchDir, homeDir, limaVMHomeDir } //nolint:gosec // G101: Potential hardcoded credentials false positive - const configureCredHelperTemplate = `([ -e "$FINCH_DIR"/cred-helpers/docker-credential-%s ] || \ + const generalCredHelperTemplate = `([ -e "$FINCH_DIR"/cred-helpers/docker-credential-%s ] || \ (echo "error: docker-credential-%s not found in $FINCH_DIR/cred-helpers directory.")) && \ ([ -L /usr/local/bin/docker-credential-%s ] || sudo ln -s "$FINCH_DIR"/cred-helpers/docker-credential-%s /usr/local/bin)` + //nolint:gosec // G101: Potential hardcoded credentials false positive + const nativeCredHelperTemplate = `[ -x /usr/local/bin/docker-credential-%s ] || ( + (sudo mkdir -p /usr/local/bin) && \ + (echo '%s' | sudo tee /usr/local/bin/docker-credential-%s > /dev/null) && \ + (sudo chmod 700 /usr/local/bin/docker-credential-%s))` + for _, credHelper := range fc.CredsHelpers { - cmdArr = append(cmdArr, fmt.Sprintf(configureCredHelperTemplate, credHelper, credHelper, credHelper, credHelper)) + // Add the credhelper to config by default, removes need for user to add + cmdArr = append(cmdArr, fmt.Sprintf(`echo '{"credsStore": "%s"}' > "$FINCH_DIR"/config.json`, credHelper)) + + // If using native credstore, use overwrite instead of symlink + if credHelper == "osxkeychain" || credHelper == "wincred" { + helperScript := helperTemplateScripts[credHelper] + cmdArr = append(cmdArr, fmt.Sprintf(nativeCredHelperTemplate, credHelper, helperScript, credHelper, credHelper)) + } else { + cmdArr = append(cmdArr, fmt.Sprintf(generalCredHelperTemplate, credHelper, credHelper, credHelper, credHelper)) + } } awsDir := fmt.Sprintf("%s/.aws", homeDir) diff --git a/pkg/mocks/pkg_support_config.go b/pkg/mocks/pkg_support_config.go index 64836e4b4..84c81429b 100644 --- a/pkg/mocks/pkg_support_config.go +++ b/pkg/mocks/pkg_support_config.go @@ -56,20 +56,6 @@ func (mr *BundleConfigMockRecorder) ConfigFiles() *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ConfigFiles", reflect.TypeOf((*BundleConfig)(nil).ConfigFiles)) } -// JournalServices mocks base method. -func (m *BundleConfig) JournalServices() []string { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "JournalServices") - ret0, _ := ret[0].([]string) - return ret0 -} - -// JournalServices indicates an expected call of JournalServices. -func (mr *BundleConfigMockRecorder) JournalServices() *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "JournalServices", reflect.TypeOf((*BundleConfig)(nil).JournalServices)) -} - // LogFiles mocks base method. func (m *BundleConfig) LogFiles() []string { m.ctrl.T.Helper() diff --git a/pkg/support/config.go b/pkg/support/config.go index dbe5d7af1..832945b00 100644 --- a/pkg/support/config.go +++ b/pkg/support/config.go @@ -3,9 +3,7 @@ package support -import ( - fpath "github.com/runfinch/finch/pkg/path" -) +import fpath "github.com/runfinch/finch/pkg/path" type bundleConfig struct { finch fpath.Finch @@ -18,7 +16,6 @@ type bundleConfig struct { type BundleConfig interface { LogFiles() []string ConfigFiles() []string - JournalServices() []string } // NewBundleConfig creates a new bundleConfig. diff --git a/pkg/support/config_native_linux.go b/pkg/support/config_native_linux.go index 9b7ceb600..6942d6937 100644 --- a/pkg/support/config_native_linux.go +++ b/pkg/support/config_native_linux.go @@ -16,7 +16,3 @@ func (bc *bundleConfig) ConfigFiles() []string { bc.finch.ConfigFilePath(), } } - -func (bc *bundleConfig) JournalServices() []string { - return []string{"service:containerd", "service:finch", "service:buildkit", "service:soci"} -} diff --git a/pkg/support/config_remote.go b/pkg/support/config_remote.go index d592f402e..faaae43dc 100644 --- a/pkg/support/config_remote.go +++ b/pkg/support/config_remote.go @@ -29,7 +29,3 @@ func (bc *bundleConfig) ConfigFiles() []string { bc.finch.ConfigFilePath(bc.rootDir), } } - -func (bc *bundleConfig) JournalServices() []string { - return []string{"service:containerd", "service:finch", "service:buildkit", "service:soci"} -} diff --git a/pkg/support/support.go b/pkg/support/support.go index 47e32ce62..431fda377 100644 --- a/pkg/support/support.go +++ b/pkg/support/support.go @@ -14,7 +14,6 @@ import ( "path" "path/filepath" "runtime" - "slices" "strings" "time" @@ -34,10 +33,8 @@ const ( platformFileName = "platform.yaml" versionFileName = "version-output.txt" logPrefix = "logs" - journalPrefix = "logs/journalctl" configPrefix = "configs" additionalPrefix = "misc" - allServices = "service:all" ) // PlatformData defines the YAML structure for the platform data included in a support bundle. @@ -142,23 +139,6 @@ func (bb *bundleBuilder) GenerateSupportBundle(additionalFiles []string, exclude } } - if slices.Contains(excludeFiles, allServices) { - bb.logger.Info("Excluding all service logs...") - } else { - bb.logger.Debugln("Copying in journal logs...") - for _, file := range bb.config.JournalServices() { - if fileShouldBeExcluded(file, excludeFiles) { - bb.logger.Infof("Excluding %s...", file) - continue - } - bb.logger.Debugf("Copying %s...", file) - err = bb.copyFileFromVMOrLocal(writer, file, path.Join(zipPrefix, journalPrefix)) - if err != nil { - bb.logger.Warnf("Could not copy in %q. Error: %s", file, err) - } - } - } - bb.logger.Debugln("Copying in config files...") for _, file := range bb.config.ConfigFiles() { if fileShouldBeExcluded(file, excludeFiles) { @@ -198,7 +178,7 @@ type bufReader interface { } func (bb *bundleBuilder) copyFileFromVMOrLocal(writer *zip.Writer, filename, zipPath string) error { - if runtime.GOOS != "linux" && (isFileFromVM(filename) || isService(filename)) { + if runtime.GOOS != "linux" && isFileFromVM(filename) { return bb.streamFileFromVM(writer, filename, zipPath) } return bb.copyInFile(writer, filename, zipPath) @@ -239,23 +219,10 @@ func (bb *bundleBuilder) copyAndRedactFile(writer io.Writer, reader bufReader) e } func (bb *bundleBuilder) copyInFile(writer *zip.Writer, fileName string, prefix string) error { - var f io.Reader - if isService(fileName) { - service := strings.TrimPrefix(fileName, "service:") - cmd := bb.ecc.Create("journalctl", "--no-pager", "-xu", service) - out, err := cmd.Output() - if err != nil { - return err - } - f = bytes.NewReader(out) - fileName = service - } else { - var err error - // check filename validity? - f, err = bb.fs.Open(fileName) - if err != nil { - return err - } + // check filename validity? + f, err := bb.fs.Open(fileName) + if err != nil { + return err } baseName := filepath.Base(fileName) @@ -277,14 +244,7 @@ func (bb *bundleBuilder) streamFileFromVM(writer *zip.Writer, filename, prefix s errBuf := new(bytes.Buffer) _, filePathInVM, _ := strings.Cut(filename, ":") - var cmd command.Command - if isService(filename) { - cmd = bb.ncc.CreateWithoutStdio("shell", "finch", "sudo", "journalctl", "--no-pager", "-xu", filePathInVM) - // omit service prefix - filename = filePathInVM - } else { - cmd = bb.ncc.CreateWithoutStdio("shell", "finch", "sudo", "cat", filePathInVM) - } + cmd := bb.ncc.CreateWithoutStdio("shell", "finch", "sudo", "cat", filePathInVM) cmd.SetStdout(pipeWriter) cmd.SetStderr(errBuf) @@ -442,10 +402,6 @@ func isFileFromVM(filename string) bool { return strings.HasPrefix(filename, "vm:") } -func isService(filename string) bool { - return strings.HasPrefix(filename, "service:") -} - func writeVersionOutput(writer *zip.Writer, version, prefix string) error { versionFile, err := writer.Create(path.Join(prefix, versionFileName)) if err != nil { diff --git a/pkg/support/support_test.go b/pkg/support/support_test.go index 30cde950c..acb5865e4 100644 --- a/pkg/support/support_test.go +++ b/pkg/support/support_test.go @@ -5,7 +5,6 @@ package support import ( "archive/zip" - "fmt" "io" "os/user" "runtime" @@ -67,7 +66,7 @@ func TestSupportBundleBuilder_GenerateSupportBundle(t *testing.T) { logger *mocks.Logger, config *mocks.BundleConfig, ecc *mocks.CommandCreator, - ncc *mocks.NerdctlCmdCreator, + _ *mocks.NerdctlCmdCreator, cmd *mocks.Command, lima *mocks.MockLimaWrapper, systemDeps *mocks.SupportSystemDeps, @@ -106,8 +105,6 @@ func TestSupportBundleBuilder_GenerateSupportBundle(t *testing.T) { logger.EXPECT().Debugln("Copying in log files...") logger.EXPECT().Debugf("Copying %s...", "log1") logger.EXPECT().Debugf("Copying %s...", "log2") - checkJournalCmdOutputs(logger, config, ecc, ncc, cmd, lima, mockUser) - logger.EXPECT().Debugln("Copying in config files...") logger.EXPECT().Debugf("Copying %s...", "config1") logger.EXPECT().Debugf("Copying %s...", "config2") @@ -124,7 +121,7 @@ func TestSupportBundleBuilder_GenerateSupportBundle(t *testing.T) { logger *mocks.Logger, config *mocks.BundleConfig, ecc *mocks.CommandCreator, - ncc *mocks.NerdctlCmdCreator, + _ *mocks.NerdctlCmdCreator, cmd *mocks.Command, lima *mocks.MockLimaWrapper, systemDeps *mocks.SupportSystemDeps, @@ -160,8 +157,6 @@ func TestSupportBundleBuilder_GenerateSupportBundle(t *testing.T) { logger.EXPECT().Debugln("Copying in log files...") logger.EXPECT().Debugf("Copying %s...", "log1") - checkJournalCmdOutputs(logger, config, ecc, ncc, cmd, lima, mockUser) - logger.EXPECT().Debugln("Copying in config files...") logger.EXPECT().Debugf("Copying %s...", "config1") logger.EXPECT().Debugln("Copying in additional files...") @@ -178,7 +173,7 @@ func TestSupportBundleBuilder_GenerateSupportBundle(t *testing.T) { logger *mocks.Logger, config *mocks.BundleConfig, ecc *mocks.CommandCreator, - ncc *mocks.NerdctlCmdCreator, + _ *mocks.NerdctlCmdCreator, cmd *mocks.Command, lima *mocks.MockLimaWrapper, systemDeps *mocks.SupportSystemDeps, @@ -214,8 +209,6 @@ func TestSupportBundleBuilder_GenerateSupportBundle(t *testing.T) { logger.EXPECT().Debugln("Copying in log files...") logger.EXPECT().Infof("Excluding %s...", "log1") - checkJournalCmdOutputs(logger, config, ecc, ncc, cmd, lima, mockUser) - logger.EXPECT().Debugln("Copying in config files...") logger.EXPECT().Debugf("Copying %s...", "config1") logger.EXPECT().Debugln("Copying in additional files...") @@ -231,7 +224,7 @@ func TestSupportBundleBuilder_GenerateSupportBundle(t *testing.T) { logger *mocks.Logger, config *mocks.BundleConfig, ecc *mocks.CommandCreator, - ncc *mocks.NerdctlCmdCreator, + _ *mocks.NerdctlCmdCreator, cmd *mocks.Command, lima *mocks.MockLimaWrapper, systemDeps *mocks.SupportSystemDeps, @@ -267,8 +260,6 @@ func TestSupportBundleBuilder_GenerateSupportBundle(t *testing.T) { logger.EXPECT().Debugln("Copying in log files...") logger.EXPECT().Debugf("Copying %s...", "log1") - checkJournalCmdOutputs(logger, config, ecc, ncc, cmd, lima, mockUser) - logger.EXPECT().Debugln("Copying in config files...") logger.EXPECT().Infof("Excluding %s...", "config1") logger.EXPECT().Debugln("Copying in additional files...") @@ -284,7 +275,7 @@ func TestSupportBundleBuilder_GenerateSupportBundle(t *testing.T) { logger *mocks.Logger, config *mocks.BundleConfig, ecc *mocks.CommandCreator, - ncc *mocks.NerdctlCmdCreator, + _ *mocks.NerdctlCmdCreator, cmd *mocks.Command, lima *mocks.MockLimaWrapper, systemDeps *mocks.SupportSystemDeps, @@ -320,8 +311,6 @@ func TestSupportBundleBuilder_GenerateSupportBundle(t *testing.T) { logger.EXPECT().Debugln("Copying in log files...") logger.EXPECT().Debugf("Copying %s...", "log1") - checkJournalCmdOutputs(logger, config, ecc, ncc, cmd, lima, mockUser) - logger.EXPECT().Debugln("Copying in config files...") logger.EXPECT().Debugf("Copying %s...", "config1") logger.EXPECT().Debugln("Copying in additional files...") @@ -377,8 +366,6 @@ func TestSupportBundleBuilder_GenerateSupportBundle(t *testing.T) { logger.EXPECT().Debugln("Copying in log files...") logger.EXPECT().Debugf("Copying %s...", "log1") - checkJournalCmdOutputs(logger, config, ecc, ncc, cmd, lima, mockUser) - logger.EXPECT().Debugln("Copying in config files...") logger.EXPECT().Debugf("Copying %s...", "config1") logger.EXPECT().Debugln("Copying in additional files...") @@ -417,7 +404,7 @@ func TestSupportBundleBuilder_GenerateSupportBundle(t *testing.T) { logger *mocks.Logger, config *mocks.BundleConfig, ecc *mocks.CommandCreator, - ncc *mocks.NerdctlCmdCreator, + _ *mocks.NerdctlCmdCreator, cmd *mocks.Command, lima *mocks.MockLimaWrapper, systemDeps *mocks.SupportSystemDeps, @@ -453,8 +440,6 @@ func TestSupportBundleBuilder_GenerateSupportBundle(t *testing.T) { logger.EXPECT().Debugln("Copying in log files...") logger.EXPECT().Debugf("Copying %s...", "log1") - checkJournalCmdOutputs(logger, config, ecc, ncc, cmd, lima, mockUser) - logger.EXPECT().Debugln("Copying in config files...") logger.EXPECT().Debugf("Copying %s...", "config1") logger.EXPECT().Debugln("Copying in additional files...") @@ -740,39 +725,3 @@ func TestSupport_writeVersionOutput(t *testing.T) { }) } } - -func checkJournalCmdOutputs( - logger *mocks.Logger, - config *mocks.BundleConfig, - ecc *mocks.CommandCreator, - ncc *mocks.NerdctlCmdCreator, - cmd *mocks.Command, - lima *mocks.MockLimaWrapper, - mockUser *user.User, -) { - config.EXPECT().JournalServices().Return([]string{ - "service:containerd", - "service:finch", - "service:buildkit", - "service:soci-snapshotter", - }) - - services := []string{"containerd", "finch", "buildkit", "soci-snapshotter"} - logger.EXPECT().Debugln("Copying in journal logs...") - - for _, service := range services { - switch runtime.GOOS { - case "linux": - ecc.EXPECT().Create("journalctl", "--no-pager", "-xu", service).Return(cmd) - logger.EXPECT().Debugf("Copying %s...", fmt.Sprintf("service:%s", service)) - case "windows", "darwin": - logger.EXPECT().Debugf("Copying %s...", fmt.Sprintf("service:%s", service)) - ncc.EXPECT().CreateWithoutStdio("shell", "finch", "sudo", "journalctl", "--no-pager", "-xu", service).Return(cmd) - cmd.EXPECT().SetStdout(gomock.Any()) - cmd.EXPECT().SetStderr(gomock.Any()) - cmd.EXPECT().Start() - cmd.EXPECT().Wait() - lima.EXPECT().LimaUser(false).Return(mockUser).AnyTimes() - } - } -} diff --git a/test-creds.sh b/test-creds.sh new file mode 100755 index 000000000..16ba08a34 --- /dev/null +++ b/test-creds.sh @@ -0,0 +1,44 @@ +#!/bin/bash +set -e + +echo "Testing credential bridge with concurrent operations..." + +# # Ensure ECR login +# export AWS_ACCOUNT_ID=299170649678 +# export AWS_REGION=us-west-2 +# aws ecr get-login-password --region $AWS_REGION | ./_output/bin/finch login --username AWS --password-stdin $AWS_ACCOUNT_ID.dkr.ecr.$AWS_REGION.amazonaws.com + +# Test 1: Build with private base image (tests credential access during build) +echo "Building image with private base..." +./_output/bin/finch build -f Dockerfile.test-creds -t test-creds-image . + +# Test 2: Tag with multiple names +echo "Tagging image with multiple names..." +./_output/bin/finch tag test-creds-image $AWS_ACCOUNT_ID.dkr.ecr.$AWS_REGION.amazonaws.com/test:A +./_output/bin/finch tag test-creds-image $AWS_ACCOUNT_ID.dkr.ecr.$AWS_REGION.amazonaws.com/test:B +./_output/bin/finch tag test-creds-image $AWS_ACCOUNT_ID.dkr.ecr.$AWS_REGION.amazonaws.com/test:C +./_output/bin/finch tag test-creds-image $AWS_ACCOUNT_ID.dkr.ecr.$AWS_REGION.amazonaws.com/test:D +./_output/bin/finch tag test-creds-image $AWS_ACCOUNT_ID.dkr.ecr.$AWS_REGION.amazonaws.com/test:E + +# Test 3: Push all concurrently (stress test credential bridge) +echo "Pushing all images concurrently..." +./_output/bin/finch push $AWS_ACCOUNT_ID.dkr.ecr.$AWS_REGION.amazonaws.com/test:A & +./_output/bin/finch push $AWS_ACCOUNT_ID.dkr.ecr.$AWS_REGION.amazonaws.com/test:B & +./_output/bin/finch push $AWS_ACCOUNT_ID.dkr.ecr.$AWS_REGION.amazonaws.com/test:C & +./_output/bin/finch push $AWS_ACCOUNT_ID.dkr.ecr.$AWS_REGION.amazonaws.com/test:D & +./_output/bin/finch push $AWS_ACCOUNT_ID.dkr.ecr.$AWS_REGION.amazonaws.com/test:E & + +# Wait for all pushes to complete +wait +echo "โœ… All credential operations completed successfully!" + +# Cleanup +echo "Cleaning up test images..." +./_output/bin/finch image rm test-creds-image +./_output/bin/finch image rm $AWS_ACCOUNT_ID.dkr.ecr.$AWS_REGION.amazonaws.com/test:A +./_output/bin/finch image rm $AWS_ACCOUNT_ID.dkr.ecr.$AWS_REGION.amazonaws.com/test:B +./_output/bin/finch image rm $AWS_ACCOUNT_ID.dkr.ecr.$AWS_REGION.amazonaws.com/test:C +./_output/bin/finch image rm $AWS_ACCOUNT_ID.dkr.ecr.$AWS_REGION.amazonaws.com/test:D +./_output/bin/finch image rm $AWS_ACCOUNT_ID.dkr.ecr.$AWS_REGION.amazonaws.com/test:E + +echo "๐ŸŽ‰ Credential bridge test completed!" \ No newline at end of file