From 2980305653c6c42d18cb5dae07b9b3f27372a1ea Mon Sep 17 00:00:00 2001 From: James Cuzella Date: Tue, 21 Dec 2021 03:24:45 -0700 Subject: [PATCH] Apple m1 opt homebrew support (Fixes #153) (#154) * Add rspec tests for Apple M1 silicon Homebrew path helper * Add Homebrew.install_path() helper for Apple M1 silicon (arm64) * Add HomebrewWrapper class to use install_path() M1 detection function * Convert /usr/local to use install_path() for M1 /opt/homebrew support * Fixing InSpec tests for macOS M1 / arm64 and x86_64 Note: InSpec running as root will not have PATH to Homebrew set up. Thus, package checks will fail if `inspec --sudo` is used * Remove deprecated full option for Homebrew (Breaking upstream change!) Error was: Error: Calling the `--full` switch is disabled! There is no replacement. * Use private macOS Vagrant boxes w/up-to-date Chef Infra & InSpec for testing this cookbook * Add Homebrew.repository_path() helper for Apple M1 silicon (arm64) * Use HomebrewWrapper.repository_path() for homebrew_tap resource idempotency on Apple M1 silicon (arm64) * Revert "Use private macOS Vagrant boxes [...]" for upstream pull request This reverts commit 5a9f4629e78d7eba8c382c203d2fced12a1193f2. * CHANGELOG.md: Update Unreleased section w/Apple M1 changes * CHANGELOG.md: Fix markdownlint issues * Autocorrect Cookstyle issues --- CHANGELOG.md | 10 ++++ kitchen.yml | 1 + libraries/helpers.rb | 31 +++++++++- recipes/default.rb | 6 +- recipes/install_taps.rb | 1 - resources/cask.rb | 2 +- resources/tap.rb | 4 +- spec/recipes/default_spec.rb | 3 +- spec/unit/libraries/homebrew_helpers_spec.rb | 60 ++++++++++++++++++++ test/integration/default/default_spec.rb | 44 ++++++++++++-- 10 files changed, 149 insertions(+), 13 deletions(-) create mode 100644 spec/unit/libraries/homebrew_helpers_spec.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index 9217c04..95f0710 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,16 @@ This file is used to list changes made in each version of the homebrew cookbook. ## Unreleased +- Update to support Apple M1 silicon (arm64) Homebrew install location (`/opt/homebrew`) + - Add HomebrewWrapper.repository_path() for homebrew_tap resource idempotency + - Add HomebrewWrapper.repository_path() helper for Apple M1 silicon (arm64) + - Remove deprecated `--full` option for Homebrew (Breaking upstream CLI change!) + - Add chefspec tests for Apple M1 silicon Homebrew path helper + - Add InSpec tests for macOS M1 / arm64 and x86_64 + - Set `use_sudo: false` for InSpec tests to work properly + - Convert hardcoded /usr/local to use install_path() for M1 /opt/homebrew support + - Add Homebrew.install_path() helper for Apple M1 silicon (arm64) + ## 5.2.2 - *2021-08-30* - Standardise files with files in sous-chefs/repo-management diff --git a/kitchen.yml b/kitchen.yml index 38505f4..12054e3 100644 --- a/kitchen.yml +++ b/kitchen.yml @@ -7,6 +7,7 @@ provisioner: verifier: name: inspec + sudo: false platforms: - name: macos-10.12 diff --git a/libraries/helpers.rb b/libraries/helpers.rb index fd7d292..1699163 100644 --- a/libraries/helpers.rb +++ b/libraries/helpers.rb @@ -27,9 +27,34 @@ class HomebrewUserWrapper module Homebrew extend self + require 'mixlib/shellout' + include Chef::Mixin::ShellOut + + def self.included(base) + base.extend(Homebrew) + end + + def install_path + arm64_test = shell_out('sysctl -n hw.optional.arm64') + if arm64_test.stdout.chomp == '1' + '/opt/homebrew' + else + '/usr/local' + end + end + + def repository_path + arm64_test = shell_out('sysctl -n hw.optional.arm64') + if arm64_test.stdout.chomp == '1' + '/opt/homebrew' + else + '/usr/local/Homebrew' + end + end + def exist? Chef::Log.debug('Checking to see if the homebrew binary exists') - ::File.exist?('/usr/local/bin/brew') + ::File.exist?("#{HomebrewWrapper.new.install_path}/bin/brew") end def owner @@ -68,3 +93,7 @@ def current_user ENV['USER'] end end unless defined?(Homebrew) + +class HomebrewWrapper + include Homebrew +end diff --git a/recipes/default.rb b/recipes/default.rb index b2adf38..b425371 100644 --- a/recipes/default.rb +++ b/recipes/default.rb @@ -39,8 +39,8 @@ execute 'set analytics' do environment lazy { { 'HOME' => ::Dir.home(Homebrew.owner), 'USER' => Homebrew.owner } } user Homebrew.owner - command "/usr/local/bin/brew analytics #{node['homebrew']['enable-analytics'] ? 'on' : 'off'}" - only_if { shell_out('/usr/local/bin/brew analytics state', user: Homebrew.owner).stdout.include?('enabled') != node['homebrew']['enable-analytics'] } + command lazy { "#{HomebrewWrapper.new.install_path}/bin/brew analytics #{node['homebrew']['enable-analytics'] ? 'on' : 'off'}" } + only_if { shell_out("#{HomebrewWrapper.new.install_path}/bin/brew analytics state", user: Homebrew.owner).stdout.include?('enabled') != node['homebrew']['enable-analytics'] } end if node['homebrew']['auto-update'] @@ -51,6 +51,6 @@ execute 'update homebrew from github' do environment lazy { { 'HOME' => ::Dir.home(Homebrew.owner), 'USER' => Homebrew.owner } } user Homebrew.owner - command '/usr/local/bin/brew update || true' + command lazy { "#{HomebrewWrapper.new.install_path}/bin/brew update || true" } end end diff --git a/recipes/install_taps.rb b/recipes/install_taps.rb index 46abe4e..7cb4d0d 100644 --- a/recipes/install_taps.rb +++ b/recipes/install_taps.rb @@ -26,7 +26,6 @@ raise unless tap.key?('tap') homebrew_tap tap['tap'] do url tap['url'] if tap.key?('url') - full tap['full'] if tap.key?('full') end else raise diff --git a/resources/cask.rb b/resources/cask.rb index f72774a..c4d2053 100644 --- a/resources/cask.rb +++ b/resources/cask.rb @@ -24,7 +24,7 @@ property :cask_name, String, regex: %r{^[\w/-]+$}, name_property: true property :options, String property :install_cask, [true, false], default: true -property :homebrew_path, String, default: '/usr/local/bin/brew' +property :homebrew_path, String, default: lazy { "#{HomebrewWrapper.new.install_path}/bin/brew" } property :owner, String, default: lazy { Homebrew.owner } # lazy to prevent breaking compilation on non-macOS platforms action :install do diff --git a/resources/tap.rb b/resources/tap.rb index 599d7b4..8f67124 100644 --- a/resources/tap.rb +++ b/resources/tap.rb @@ -24,7 +24,7 @@ property :tap_name, String, name_property: true, regex: %r{^[\w-]+(?:\/[\w-]+)+$} property :url, String property :full, [true, false], default: false -property :homebrew_path, String, default: '/usr/local/bin/brew' +property :homebrew_path, String, default: lazy { "#{HomebrewWrapper.new.install_path}/bin/brew" } property :owner, String, default: lazy { Homebrew.owner } # lazy to prevent breaking compilation on non-macOS platforms action :tap do @@ -51,5 +51,5 @@ def tapped?(name) tap_dir = name.gsub('/', '/homebrew-') - ::File.directory?("/usr/local/Homebrew/Library/Taps/#{tap_dir}") + ::File.directory?("#{HomebrewWrapper.new.repository_path}/Library/Taps/#{tap_dir}") end diff --git a/spec/recipes/default_spec.rb b/spec/recipes/default_spec.rb index dfd8139..5231eaf 100644 --- a/spec/recipes/default_spec.rb +++ b/spec/recipes/default_spec.rb @@ -4,6 +4,7 @@ before do stubs_for_resource('execute[set analytics]') do |resource| allow(resource).to receive_shell_out('/usr/local/bin/brew analytics state', user: 'vagrant') + allow(resource).to receive_shell_out('/opt/homebrew/bin/brew analytics state', user: 'vagrant') end allow(Homebrew).to receive(:exist?).and_return(true) @@ -33,7 +34,7 @@ end end - context '/usr/local/bin/brew exists' do + context 'brew script exists' do cached(:chef_run) do ChefSpec::SoloRunner.new.converge(described_recipe) end diff --git a/spec/unit/libraries/homebrew_helpers_spec.rb b/spec/unit/libraries/homebrew_helpers_spec.rb new file mode 100644 index 0000000..a5830b2 --- /dev/null +++ b/spec/unit/libraries/homebrew_helpers_spec.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'chef/mixin/shell_out' + +describe Homebrew do + let(:opt_homebrew_path) { '/opt/homebrew' } + let(:usr_local_homebrew_path) { '/usr/local' } + let(:usr_local_repository_path) { '/usr/local/Homebrew' } + + let(:shellout) do + double(command: 'sysctl -n hw.optional.arm64', run_command: nil, error!: nil, stdout: arm64_test_output, + stderr: stderr, exitstatus: exitstatus, live_stream: nil) + end + + before(:each) do + allow(Mixlib::ShellOut).to receive(:new).and_return(shellout) + allow(shellout).to receive(:live_stream=).and_return(nil) + end + + context 'when on Apple arm64 Silicon' do + let(:arm64_test_output) { "1\n" } + let(:exitstatus) { 0 } + let(:stderr) { double(empty?: true) } + + describe '#install_path' do + let(:dummy_class) { Class.new { include Homebrew } } + it 'returns /opt/homebrew path' do + expect(dummy_class.new.install_path).to eq opt_homebrew_path + end + end + + describe '#repository_path' do + let(:dummy_class) { Class.new { include Homebrew } } + it 'returns /opt/homebrew path' do + expect(dummy_class.new.repository_path).to eq opt_homebrew_path + end + end + end + + context 'when on Apple Intel Silicon' do + let(:arm64_test_output) { '' } + let(:exitstatus) { 1 } + let(:stderr) { 'sysctl: unknown oid \'hw.optional.arm64\'' } + + describe '#install_path' do + let(:dummy_class) { Class.new { include Homebrew } } + it 'returns /usr/local path' do + expect(dummy_class.new.install_path).to eq usr_local_homebrew_path + end + end + + describe '#install_path' do + let(:dummy_class) { Class.new { include Homebrew } } + it 'returns /usr/local/Homebrew path' do + expect(dummy_class.new.repository_path).to eq usr_local_repository_path + end + end + end +end diff --git a/test/integration/default/default_spec.rb b/test/integration/default/default_spec.rb index 8ec9587..9cd1c6f 100644 --- a/test/integration/default/default_spec.rb +++ b/test/integration/default/default_spec.rb @@ -1,9 +1,45 @@ -describe command('brew info redis --json=v1 | jq ".[].installed[0].installed_on_request"') do - its('stdout') { should match('true') } +# Check both x86_64 and arm64 locations for brew +# Depending on shell setup in the box: +# InSpec commands want a full path, but this depends on arch + platform_version +# macOS default shell may be /bin/bash or /bin/zsh ... back to basics: /bin/sh +describe command('/bin/sh -c "PATH=/opt/homebrew/bin:/usr/local/bin:$PATH brew --version"') do + its('stdout') { should match('^Homebrew.*$') } end -describe file('/usr/local/Caskroom/caffeine') do +brew_prefix = command('/bin/sh -c "PATH=/opt/homebrew/bin:/usr/local/bin:$PATH brew --prefix"').stdout.chomp +describe brew_prefix do + it { should match('^(\/usr\/local|\/opt\/homebrew)$') } +end + +describe file(brew_prefix) do + it { should be_directory } +end + +brew_caskroom = command('/bin/sh -c "PATH=/opt/homebrew/bin:/usr/local/bin:$PATH brew --caskroom"').stdout.chomp +describe brew_caskroom do + it { should match('^(\/usr\/local|\/opt\/homebrew)/Caskroom$') } +end + +describe file(brew_caskroom) do + it { should be_directory } +end + +describe package('redis') do + it { should be_installed } +end + +describe package('jq') do + it { should be_installed } +end + +# CI agnostic caskroom owner check +# InSpec may be running commands w/sudo +ci_user = command('whoami').stdout.chomp +if ci_user == 'root' + ci_user = command("/bin/sh -c 'echo $SUDO_USER'").stdout.chomp +end +describe file("#{brew_caskroom}/caffeine") do it { should be_directory } its('mode') { should cmp '0755' } - it { should be_owned_by 'runner' } + it { should be_owned_by ci_user } end