diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 048d2b5..8b466cf 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -124,7 +124,7 @@ If you have Ruby 2.x or want a specific version of Puppet, you must set an environment variable such as: ```sh -export PUPPET_VERSION="~> 5.5.6" +export PUPPET_GEM_VERSION="~> 6.1.0" ``` You can install all needed gems for spec tests into the modules directory by @@ -232,17 +232,16 @@ simple tests against it after applying the module. You can run this with: ```sh -BEAKER_setfile=debian10-x64 bundle exec rake beaker +BEAKER_setfile=debian11-64 bundle exec rake beaker ``` You can replace the string `debian10` with any common operating system. The following strings are known to work: -* ubuntu1604 * ubuntu1804 * ubuntu2004 -* debian9 * debian10 +* debian11 * centos7 * centos8 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d08d05e..8a07791 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,84 +7,12 @@ name: CI on: pull_request concurrency: - group: ${{ github.head_ref }} + group: ${{ github.ref_name }} cancel-in-progress: true jobs: - setup_matrix: - name: 'Setup Test Matrix' - runs-on: ubuntu-latest - timeout-minutes: 40 - outputs: - puppet_unit_test_matrix: ${{ steps.get-outputs.outputs.puppet_unit_test_matrix }} - github_action_test_matrix: ${{ steps.get-outputs.outputs.github_action_test_matrix }} - env: - BUNDLE_WITHOUT: development:system_tests:release - steps: - - uses: actions/checkout@v2 - - name: Setup ruby - uses: ruby/setup-ruby@v1 - with: - ruby-version: '3.0' - bundler-cache: true - - name: Run static validations - run: bundle exec rake validate lint check - - name: Run rake rubocop - run: bundle exec rake rubocop - - name: Setup Test Matrix - id: get-outputs - run: bundle exec metadata2gha --use-fqdn --pidfile-workaround false - - unit: - needs: setup_matrix - runs-on: ubuntu-latest - timeout-minutes: 40 - strategy: - fail-fast: false - matrix: - include: ${{fromJson(needs.setup_matrix.outputs.puppet_unit_test_matrix)}} - env: - BUNDLE_WITHOUT: development:system_tests:release - PUPPET_VERSION: "~> ${{ matrix.puppet }}.0" - name: Puppet ${{ matrix.puppet }} (Ruby ${{ matrix.ruby }}) - steps: - - uses: actions/checkout@v2 - - name: Setup ruby - uses: ruby/setup-ruby@v1 - with: - ruby-version: ${{ matrix.ruby }} - bundler-cache: true - - name: Run tests - run: bundle exec rake parallel_spec - - acceptance: - needs: setup_matrix - runs-on: ubuntu-latest - env: - BUNDLE_WITHOUT: development:test:release - strategy: - fail-fast: false - matrix: - include: ${{fromJson(needs.setup_matrix.outputs.github_action_test_matrix)}} - name: ${{ matrix.puppet.name }} - ${{ matrix.setfile.name }} - steps: - - uses: actions/checkout@v2 - - name: Setup ruby - uses: ruby/setup-ruby@v1 - with: - ruby-version: '3.0' - bundler-cache: true - - name: Run tests - run: bundle exec rake beaker - env: - BEAKER_PUPPET_COLLECTION: ${{ matrix.puppet.collection }} - BEAKER_setfile: ${{ matrix.setfile.value }} - - tests: - needs: - - unit - - acceptance - runs-on: ubuntu-latest - name: Test suite - steps: - - run: echo Test suite completed + puppet: + name: Puppet + uses: voxpupuli/gha-puppet/.github/workflows/beaker.yml@v1 + with: + pidfile_workaround: 'false' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9edb616..37438ee 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -9,26 +9,14 @@ on: tags: - '*' -env: - BUNDLE_WITHOUT: development:test:system_tests - jobs: - deploy: - name: 'deploy to forge' - runs-on: ubuntu-latest - if: github.repository_owner == 'root-expert' - steps: - - name: Checkout repository - uses: actions/checkout@v2 - - name: Setup Ruby - uses: ruby/setup-ruby@v1 - with: - ruby-version: '2.7' - bundler-cache: true - - name: Build and Deploy - env: - # Configure secrets here: - # https://docs.github.com/en/free-pro-team@latest/actions/reference/encrypted-secrets - BLACKSMITH_FORGE_USERNAME: '${{ secrets.PUPPET_FORGE_USERNAME }}' - BLACKSMITH_FORGE_API_KEY: '${{ secrets.PUPPET_FORGE_API_KEY }}' - run: bundle exec rake module:push + release: + name: Release + uses: voxpupuli/gha-puppet/.github/workflows/release.yml@v1 + with: + allowed_owner: 'root-expert' + secrets: + # Configure secrets here: + # https://docs.github.com/en/actions/security-guides/encrypted-secrets + username: ${{ secrets.PUPPET_FORGE_USERNAME }} + api_key: ${{ secrets.PUPPET_FORGE_API_KEY }} diff --git a/.msync.yml b/.msync.yml index ab186de..968a936 100644 --- a/.msync.yml +++ b/.msync.yml @@ -2,4 +2,4 @@ # Managed by modulesync - DO NOT EDIT # https://voxpupuli.org/docs/updating-files-managed-with-modulesync/ -modulesync_config_version: '5.0.1' +modulesync_config_version: '5.2.0' diff --git a/.sync.yml b/.sync.yml index 130aaeb..d71e85b 100644 --- a/.sync.yml +++ b/.sync.yml @@ -1,4 +1,8 @@ --- +Gemfile: + optional: + ':test': + - gem: 'net_http_unix' .puppet-lint.rc: enabled_lint_checks: - parameter_documentation diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..eba5c43 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,17 @@ +# Changelog + +All notable changes to this project will be documented in this file. +Each new release typically also includes the latest modulesync defaults. +These should not affect the functionality of the module. + +## [v1.0.0](https://github.com/root-expert/puppet-snap/tree/v1.0.0) (2022-03-26) + +[Full Changelog](https://github.com/root-expert/puppet-snap/compare/613d2068319841ea636da5f22c16665311001304...v1.0.0) + +**Implemented enhancements:** + +- Add snap\_conf resource/Add CentOS 9 [\#4](https://github.com/root-expert/puppet-snap/pull/4) ([root-expert](https://github.com/root-expert)) + + + +\* *This Changelog was automatically generated by [github_changelog_generator](https://github.com/github-changelog-generator/github-changelog-generator)* diff --git a/Dockerfile b/Dockerfile index e3cf307..8dd82d6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,7 +8,7 @@ WORKDIR /opt/puppet # https://github.com/puppetlabs/puppet/blob/06ad255754a38f22fb3a22c7c4f1e2ce453d01cb/lib/puppet/provider/service/runit.rb#L39 RUN mkdir -p /etc/sv -ARG PUPPET_VERSION="~> 6.0" +ARG PUPPET_GEM_VERSION="~> 6.0" ARG PARALLEL_TEST_PROCESSORS=4 # Cache gems diff --git a/Gemfile b/Gemfile index 2b731b9..342c26f 100644 --- a/Gemfile +++ b/Gemfile @@ -1,14 +1,14 @@ # Managed by modulesync - DO NOT EDIT # https://voxpupuli.org/docs/updating-files-managed-with-modulesync/ -source ENV['GEM_SOURCE'] || "https://rubygems.org" +source ENV['GEM_SOURCE'] || 'https://rubygems.org' group :test do - gem 'voxpupuli-test', '~> 4.0', :require => false + gem 'voxpupuli-test', '~> 5.0', :require => false gem 'coveralls', :require => false gem 'simplecov-console', :require => false gem 'puppet_metadata', '~> 1.0', :require => false - gem 'puppet-lint-param-docs', :require => false + gem 'net_http_unix', :require => false end group :development do @@ -29,7 +29,7 @@ end gem 'rake', :require => false gem 'facter', ENV['FACTER_GEM_VERSION'], :require => false, :groups => [:test] -puppetversion = ENV['PUPPET_VERSION'] || '>= 6.0' +puppetversion = ENV['PUPPET_GEM_VERSION'] || '>= 6.0' gem 'puppet', puppetversion, :require => false, :groups => [:test] # vim: syntax=ruby diff --git a/README.md b/README.md index 5a455bd..566b7f2 100644 --- a/README.md +++ b/README.md @@ -2,11 +2,11 @@ [![Build Status](https://github.com/root-expert/puppet-snap/workflows/CI/badge.svg)](https://github.com/root-expert/puppet-snap/actions?query=workflow%3ACI) [![Release](https://github.com/root-expert/puppet-snap/actions/workflows/release.yml/badge.svg)](https://github.com/root-expert/puppet-snap/actions/workflows/release.yml) -[![Puppet Forge](https://img.shields.io/puppetforge/v/puppet/snap.svg)](https://forge.puppetlabs.com/puppet/snap) -[![Puppet Forge - downloads](https://img.shields.io/puppetforge/dt/puppet/snap.svg)](https://forge.puppetlabs.com/puppet/snap) -[![Puppet Forge - endorsement](https://img.shields.io/puppetforge/e/puppet/snap.svg)](https://forge.puppetlabs.com/puppet/snap) -[![Puppet Forge - scores](https://img.shields.io/puppetforge/f/puppet/snap.svg)](https://forge.puppetlabs.com/puppet/snap) -[![puppetmodule.info docs](http://www.puppetmodule.info/images/badge.png)](http://www.puppetmodule.info/m/puppet-snap) +[![Puppet Forge](https://img.shields.io/puppetforge/v/rootexpert/snap.svg)](https://forge.puppet.com/modules/rootexpert/snap) +[![Puppet Forge - downloads](https://img.shields.io/puppetforge/dt/rootexpert/snap.svg)](https://forge.puppet.com/modules/rootexpert/snap) +[![Puppet Forge - endorsement](https://img.shields.io/puppetforge/e/rootexpert/snap.svg)](https://forge.puppet.com/modules/rootexpert/snap) +[![Puppet Forge - scores](https://img.shields.io/puppetforge/f/rootexpert/snap.svg)](https://forge.puppet.com/modules/rootexpert/snap) +[![puppetmodule.info docs](http://www.puppetmodule.info/images/badge.png)](http://www.puppetmodule.info/m/rootexpert-snap) [![Apache-2 License](https://img.shields.io/github/license/root-expert/puppet-snap.svg)](LICENSE) #### Table of Contents @@ -40,7 +40,7 @@ the [Snapd REST API](https://snapcraft.io/docs/snapd-api) for managing snaps. To install Snap and the core package: ```puppet -class { 'snap': } +include snap ``` If you are using a RedHat family OS you need to additionally install [puppet-epel](https://github.com/voxpupuli/puppet-epel) @@ -89,9 +89,8 @@ To install from specific channel: ```puppet package { 'hello-world': - ensure => installed, - provider => 'snap', - install_options => ['channel=beta'], + ensure => 'beta', + provider => 'snap', } ``` @@ -104,9 +103,19 @@ package { 'hello-world': install_options => ['classic'], } ``` - Same applies for options `jailmode` and `devmode` +This snippet +```puppet +package { 'hello-world': + ensure => latest, + provider => 'snap', + install_options => ['classic'], +} +``` + +installs by default the `latest/stable` channel + ## Reference See [REFERENCE](https://github.com/root-expert/puppet-snap/blob/master/REFERENCE.md) diff --git a/REFERENCE.md b/REFERENCE.md index 30ab256..16f4529 100644 --- a/REFERENCE.md +++ b/REFERENCE.md @@ -8,6 +8,10 @@ * [`snap`](#snap) +### Resource types + +* [`snap_conf`](#snap_conf): Manage snap configuration both system wide and snap specific. + ## Classes ### `snap` @@ -23,6 +27,7 @@ The following parameters are available in the `snap` class: * [`service_enable`](#service_enable) * [`core_snap_ensure`](#core_snap_ensure) * [`manage_repo`](#manage_repo) +* [`net_http_unix_ensure`](#net_http_unix_ensure) ##### `package_ensure` @@ -62,5 +67,68 @@ Data type: `Boolean` Whether we should manage EPEL repo or not. -Default value: ``true`` +Default value: ``false`` + +##### `net_http_unix_ensure` + +Data type: `Enum['present', 'installed', 'absent']` + +The state of net_http_unix gem. + +Default value: `'installed'` + +## Resource types + +### `snap_conf` + +Manage snap configuration both system wide and snap specific. + +#### Properties + +The following properties are available in the `snap_conf` type. + +##### `ensure` + +Valid values: `present`, `absent` + +The desired state of the snap configuration. + +Default value: `present` + +#### Parameters + +The following parameters are available in the `snap_conf` type. + +* [`conf`](#conf) +* [`name`](#name) +* [`provider`](#provider) +* [`snap`](#snap) +* [`value`](#value) + +##### `conf` + +Name of configuration option. + +Default value: `''` + +##### `name` + +namevar + +An unique name for this define. + +##### `provider` + +The specific backend to use for this `snap_conf` resource. You will seldom need to specify this --- Puppet will usually +discover the appropriate provider for your platform. + +##### `snap` + +The snap to configure the value for. This can be the reserved name system for system wide configurations. + +Default value: `''` + +##### `value` + +Value of configuration option. diff --git a/Rakefile b/Rakefile index 80b799d..52a5be2 100644 --- a/Rakefile +++ b/Rakefile @@ -1,7 +1,7 @@ # Managed by modulesync - DO NOT EDIT # https://voxpupuli.org/docs/updating-files-managed-with-modulesync/ -# Attempt to load voxupuli-test (which pulls in puppetlabs_spec_helper), +# Attempt to load voxpupuli-test (which pulls in puppetlabs_spec_helper), # otherwise attempt to load it directly. begin require 'voxpupuli/test/rake' @@ -51,7 +51,7 @@ begin config.future_release = "v#{metadata.version}" if metadata.version =~ /^\d+\.\d+.\d+$/ config.header = "# Changelog\n\nAll notable changes to this project will be documented in this file.\nEach new release typically also includes the latest modulesync defaults.\nThese should not affect the functionality of the module." config.exclude_labels = %w{duplicate question invalid wontfix wont-fix modulesync skip-changelog} - config.user = 'voxpupuli' + config.user = 'root-expert' config.project = metadata.metadata['name'] end diff --git a/data/os/Fedora.yaml b/data/os/Fedora.yaml new file mode 100644 index 0000000..4ab3ddb --- /dev/null +++ b/data/os/Fedora.yaml @@ -0,0 +1,2 @@ +--- +snap::manage_repo: false diff --git a/lib/puppet/feature/net_http_unix_lib.rb b/lib/puppet/feature/net_http_unix_lib.rb new file mode 100644 index 0000000..6735fda --- /dev/null +++ b/lib/puppet/feature/net_http_unix_lib.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +require 'puppet/util/feature' + +Puppet.features.add(:net_http_unix_lib, libs: 'net_http_unix') diff --git a/lib/puppet/feature/snapd_socket.rb b/lib/puppet/feature/snapd_socket.rb new file mode 100644 index 0000000..87a6c0c --- /dev/null +++ b/lib/puppet/feature/snapd_socket.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +require 'puppet/util/feature' + +Puppet.features.add(:snapd_socket) do + true if File.exist?('/run/snapd.socket') +end diff --git a/lib/puppet/provider/package/snap.rb b/lib/puppet/provider/package/snap.rb index cf95083..1850467 100644 --- a/lib/puppet/provider/package/snap.rb +++ b/lib/puppet/provider/package/snap.rb @@ -1,191 +1,146 @@ # frozen_string_literal: true require 'puppet/provider/package' -require 'net/http' -require 'socket' -require 'json' +require 'puppet_x/snap/api' Puppet::Type.type(:package).provide :snap, parent: Puppet::Provider::Package do desc "Package management via Snap. This provider supports the `install_options` attribute, which allows snap's flags to be - passed to Snap. Namely `classic`, `dangerous`, `devmode`, `jailmode`, `channel`." + passed to Snap. Namely `classic`, `dangerous`, `devmode`, `jailmode`. + + The 'channel' install option is deprecated and will be removed in a future release. + " commands snap_cmd: '/usr/bin/snap' - has_feature :installable, :install_options, :uninstallable, :purgeable + has_feature :installable, :versionable, :install_options, :uninstallable, :purgeable, :upgradeable + confine feature: %i[net_http_unix_lib snapd_socket] def self.instances - instances = [] - snaps = installed_snaps - - snaps.each do |snap| - instances << new(name: snap['name'], ensure: snap['version'], provider: 'snap') + Puppet.info('called instances') + @installed_snaps ||= installed_snaps + Puppet.info("installed_snaps = #{@installed_snaps}") + @installed_snaps.map do |snap| + new(name: snap['name'], ensure: snap['tracking-channel'], provider: 'snap') end - - instances end def query - instances = self.class.instances - instances.each do |instance| - return instance if instance.name == @resource[:name] - end - - nil + Puppet.info('called query') + Puppet.info("@installed_snaps = #{installed_snaps}") + installed = @installed_snaps&.find { |it| it.name == @resource['name'] } + Puppet.info("installed #{installed}") + { ensure: installed.ensure, name: @resource[:name] } if installed end def install - self.class.modify_snap('install', @resource[:name], @resource[:install_options]) + Puppet.info('called install') + current_ensure = query&.dig(:ensure) + current_ensure ||= :absent + + Puppet.info("current_ensure = #{current_ensure}") + # Refresh the snap if we changed the channel + if current_ensure != @resource[:ensure] && current_ensure != :absent + Puppet.info('modify snap') + modify_snap('refresh') # Refresh will switch the channel AND trigger a refresh immediately. TODO Implement switch? + else + Puppet.info('install snap') + modify_snap('install') + end end def update - self.class.modify_snap('refresh', @resource[:name], @resource[:install_options]) + install end def latest - res = self.class.call_api('GET', "/v2/find?name=#{@resource[:name]}") + Puppet.info('called latest') + params = URI.encode_www_form(name: @resource[:name]) + res = PuppetX::Snap::API.get("/v2/find?#{params}") raise Puppet::Error, "Couldn't find latest version" if res['status-code'] != 200 - # Search latest version for the specified channel. If unspecified fallback to stable.c + # Search latest version for the specified channel. If channel is unspecified, fallback to latest/stable channel = if @resource[:install_options].nil? - 'stable' + 'latest/stable' else self.class.parse_channel(@resource[:install_options]) end - selected_channel = res['result'][0]['channels']["latest/#{channel}"] + + Puppet.info("channel = #{channel}") + selected_channel = res['result'].first&.dig('channels', channel) + Puppet.info("selected_channel = #{selected_channel}") raise Puppet::Error, "No version in channel #{channel}" unless selected_channel + Puppet.info('Evaluating version') + Puppet.info("version = #{selected_channel['version']}") # Return version selected_channel['version'] end def uninstall - self.class.modify_snap('remove', @resource[:name]) + modify_snap('remove', nil) end - # Purge differs from remove as it doesn't save snapshot with snap's data. + # Purge differs from remove as it doesn't save a snapshot with snap's data. def purge - self.class.modify_snap('remove', @resource[:name], ['purge']) + modify_snap('remove', ['purge']) end - def self.call_api(method, url, data = nil) - socket = Net::BufferedIO.new(UNIXSocket.new('/run/snapd.socket')) - - request = if method == 'POST' - req = Net::HTTP::Post.new(url) - req.body = data.to_json - req - else - Net::HTTP::Get.new(url) - end - - request['Host'] = 'localhost' - request['Accept'] = 'application/json' - request['Content-Type'] = 'application/json' - request.exec(socket, '1.1', url) - - response = nil - retried = 0 - max_retries = 5 - # Read timeout can happen while installing core snap. The snap daemon briefly restarts - # which drops the connection to the socket. - loop do - response = Net::HTTPResponse.read_new(socket) - break unless response.is_a?(Net::HTTPContinue) - rescue Net::ReadTimeout, Net::OpenTimeout - raise Puppet::Error, "Got timeout wile calling the api #{retried} times! Giving up..." if retried > max_retries - - Puppet.debug('Got timeout while calling the api, retrying...') - retried += 1 - retry - end - # rubocop:disable Lint/EmptyBlock - response.reading_body(socket, request.response_body_permitted?) {} - # rubocop:enable Lint/EmptyBlock - - JSON.parse(response.body) + def modify_snap(action, options = @resource[:install_options]) + body = self.class.generate_request(action, determine_channel, options) + response = PuppetX::Snap::API.post("/v2/snaps/#{@resource[:name]}", body) + change_id = PuppetX::Snap::API.get_id_from_async_req(response) + PuppetX::Snap::API.complete(change_id) end - def self.installed_snaps - res = call_api('GET', '/v2/snaps') - - raise Puppet::Error, "Could not find installed snaps (code: #{res['status-code']})" unless [200, 404].include?(res['status-code']) - - res['result'].map { |hash| hash.slice('name', 'version') } if res['status-code'] == 200 + def determine_channel + channel = self.class.channel_from_ensure(@resource[:ensure]) + channel ||= self.class.channel_from_options(@resource[:install_options]) + channel ||= 'latest/stable' + channel end - # Helper method to return the change ID from a asynchronous request response. - def self.get_id_from_async_req(request) - # If the request failed raise an error - raise Puppet::Error, "Request failed with #{request['result']['message']}" if request['type'] == 'error' - - request['change'] - end - - # Get the status of a change - # - # @param id The change ID to search for. - def self.get_status(id) - call_api('GET', "/v2/changes/#{id}") - end - - # Queries the API for a specific change and waits until it has - # been completed. - # - # @param id The change ID to search for. - def self.complete(id) - completed = false - until completed - res = get_status(id) - case res['result']['status'] - when 'Do', 'Doing', 'Undoing', 'Undo' - # Still running - # Wait a little bit before hitting the API again! - sleep(1) - next - when 'Abort', 'Hold', 'Error' - raise Puppet::Error, "Error while executing the request #{res}" - when 'Done' - completed = true - else - raise Puppet::Error, "Unknown status #{res}" - end - end - end - - def self.generate_request(action, options) + def self.generate_request(action, channel, options) request = { 'action' => action } + request['channel'] = channel unless channel.nil? if options - channel = parse_channel(options) - request['channel'] = channel unless channel.nil? - - # classic, devmode and jailmode params are only available for install, refresh, revert actions. - if %w[install refresh revert].include?(action) + # classic, devmode and jailmode params are only + # available for install, refresh, revert actions. + case action + when 'install', 'refresh', 'revert' request['classic'] = true if options.include?('classic') request['devmode'] = true if options.include?('devmode') request['jailmode'] = true if options.include?('jailmode') + when 'remove' + request['purge'] = true if options.include?('purge') end - - request['purge'] = true if action == 'remove' && options.include?('purge') end request end - def self.modify_snap(action, name, options = nil) - req = generate_request(action, options) - response = call_api('POST', "/v2/snaps/#{name}", req) - change_id = get_id_from_async_req(response) - complete(change_id) + def self.channel_from_ensure(value) + value = value.to_s + case value + when 'present', 'absent', 'purged', 'installed', 'latest' + nil + else + value + end end - def self.parse_channel(options) - if (channel = options.find { |e| %r{channel} =~ e }) - return channel.split('=')[1] + def self.channel_from_options(options) + options&.find { |e| %r{channel} =~ e }&.split('=')&.last&.tap do |ch| + Puppet.warning("Install option 'channel' is deprecated, use ensure => '#{ch}' instead.") end + end + + def self.installed_snaps + res = PuppetX::Snap::API.get('/v2/snaps') + raise Puppet::Error, "Could not find installed snaps (code: #{res['status-code']})" unless [200, 404].include?(res['status-code']) - nil + res['status-code'] == 200 ? res['result'].map { |hash| hash.slice('name', 'tracking-channel') } : [] end end diff --git a/lib/puppet/provider/snap_conf/snap_conf.rb b/lib/puppet/provider/snap_conf/snap_conf.rb new file mode 100644 index 0000000..6ffb6cd --- /dev/null +++ b/lib/puppet/provider/snap_conf/snap_conf.rb @@ -0,0 +1,89 @@ +# frozen_string_literal: true + +require 'puppet_x/snap/api' + +Puppet::Type.type(:snap_conf).provide(:snap_conf) do + desc 'Manage snap configuration both system wide and snap specific.' + + confine feature: %i[net_http_unix_lib snapd_socket] + + def create + save_conf + end + + def destroy + save_conf + end + + def exists? + params = URI.encode_www_form(keys: @resource[:conf]) + res = PuppetX::Snap::API.get("/v2/snaps/#{@resource[:snap]}/conf?#{params}") + + case res['status-code'] + when 200 + # If we reached here the resource exists. If ensure == absent then return true in order to remove it + true if res['result'][@resource[:conf]] == @resource[:value] || @resource[:ensure] == :absent + when 400 + return false if res['result']['kind'] == 'option-not-found' + + raise Puppet::Error, "Error while executing the request #{res}" + else + raise Puppet::Error, "Error while executing the request #{res}" + end + end + + def save_conf + value = if @resource[:ensure] == :absent + nil + else + @resource[:value] + end + + data = { + @resource[:conf] => value + } + + res = PuppetX::Snap::API.put("/v2/snaps/#{@resource[:snap]}/conf", data) + change_id = PuppetX::Snap::API.get_id_from_async_req(res) + PuppetX::Snap::API.complete(change_id) + end + + def snap + @resource[:snap] + end + + def snap=(value) + @resource[:snap] = value + save_conf + end + + def conf + @resource[:conf] + end + + def conf=(value) + @resource[:conf] = value + save_conf + end + + def value + params = URI.encode_www_form(keys: @resource[:conf]) + res = PuppetX::Snap::API.get("/v2/snaps/#{@resource[:snap]}/conf?#{params}") + + case res['status-code'] + when 200 + res['result'][@resource[:conf]] + when 400 + return nil if res['result']['kind'] == 'option-not-found' + + raise Puppet::Error, "Error while executing the request #{res}" + else + raise Puppet::Error, "Error while executing the request #{res}" + end + end + + def value=(value) + @resource[:value] = value + save_conf + end +end diff --git a/lib/puppet/type/snap_conf.rb b/lib/puppet/type/snap_conf.rb new file mode 100644 index 0000000..305a4f0 --- /dev/null +++ b/lib/puppet/type/snap_conf.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +Puppet::Type.newtype(:snap_conf) do + @doc = 'Manage snap configuration both system wide and snap specific.' + + ensurable do + defaultvalues + defaultto :present + desc 'The desired state of the snap configuration.' + end + + newparam(:name, namevar: true) do + desc 'An unique name for this define.' + end + + newparam(:snap) do + desc 'The snap to configure the value for. This can be the reserved name system for system wide configurations.' + defaultto '' + + validate do |value| + raise ArgumentError, 'snap parameter must not be empty!' if value == '' + end + end + + newparam(:conf) do + desc 'Name of configuration option.' + defaultto '' + + validate do |value| + raise ArgumentError, 'conf parameter must not be empty!' if value == '' + end + end + + newparam(:value) do + desc 'Value of configuration option.' + end +end diff --git a/lib/puppet_x/snap/api.rb b/lib/puppet_x/snap/api.rb new file mode 100644 index 0000000..ca32816 --- /dev/null +++ b/lib/puppet_x/snap/api.rb @@ -0,0 +1,88 @@ +# frozen_string_literal: true + +require 'net_http_unix' if Puppet.features.net_http_unix_lib? +require 'socket' +require 'json' + +module PuppetX + module Snap + module API + class << self + %w[post put get].each do |method| + define_method(method) do |url, data = nil| + request = Object.const_get("Net::HTTP::#{method.capitalize}").new(url) + + request.body = data.to_json if data + + request['Host'] = 'localhost' + request['Accept'] = 'application/json' + request['Content-Type'] = 'application/json' + + call_api(request) + end + end + + def call_api(request) + client = ::NetX::HTTPUnix.new('unix:///run/snapd.socket') + + response = nil + retried = 0 + max_retries = 5 + # Read timeout can happen while installing core snap. The snap daemon briefly restarts + # which drops the connection to the socket. + loop do + response = client.request(request) + break unless response.is_a?(Net::HTTPContinue) + rescue Net::ReadTimeout, Net::OpenTimeout + raise Puppet::Error, "Got timeout wile calling the api #{retried} times! Giving up..." if retried > max_retries + + Puppet.debug('Got timeout while calling the api, retrying...') + retried += 1 + retry + end + + JSON.parse(response.body) + end + + # Helper method to return the change ID from a asynchronous request response. + def get_id_from_async_req(request) + # If the request failed raise an error + raise Puppet::Error, "Request failed with #{request['result']['message']}" if request['type'] == 'error' + + request['change'] + end + + # Get the status of a change + # + # @param id The change ID to search for. + def get_status(id) + get("/v2/changes/#{id}") + end + + # Queries the API for a specific change and waits until it has + # been completed. + # + # @param id The change ID to search for. + def complete(id) + completed = false + until completed + res = get_status(id) + case res['result']['status'] + when 'Do', 'Doing', 'Undoing', 'Undo' + # Still running + # Wait a little bit before hitting the API again! + sleep(1) + next + when 'Abort', 'Hold', 'Error' + raise Puppet::Error, "Error while executing the request #{res}" + when 'Done' + completed = true + else + raise Puppet::Error, "Unknown status #{res}" + end + end + end + end + end + end +end diff --git a/manifests/init.pp b/manifests/init.pp index b7a4b93..c8b15aa 100644 --- a/manifests/init.pp +++ b/manifests/init.pp @@ -5,12 +5,14 @@ # @param service_enable Run the system service on boot. # @param core_snap_ensure The state of the snap `core`. # @param manage_repo Whether we should manage EPEL repo or not. +# @param net_http_unix_ensure The state of net_http_unix gem. class snap ( - String[1] $package_ensure = 'installed', - Enum['stopped', 'running'] $service_ensure = 'running', - Boolean $service_enable = true, - String[1] $core_snap_ensure = 'installed', - Boolean $manage_repo = false, + String[1] $package_ensure = 'installed', + Enum['stopped', 'running'] $service_ensure = 'running', + Boolean $service_enable = true, + String[1] $core_snap_ensure = 'installed', + Boolean $manage_repo = false, + Enum['present', 'installed', 'absent'] $net_http_unix_ensure = 'installed', ) { if $manage_repo { include epel @@ -35,9 +37,13 @@ require => Package['snapd'], } - package { 'core': + -> package { 'net_http_unix': + ensure => $net_http_unix_ensure, + provider => 'puppet_gem', + } + + -> package { 'core': ensure => $core_snap_ensure, provider => 'snap', - require => Service['snapd'], } } diff --git a/metadata.json b/metadata.json index ca7e317..8a7cd00 100644 --- a/metadata.json +++ b/metadata.json @@ -1,6 +1,6 @@ { - "name": "puppet-snap", - "version": "0.1.0-rc0", + "name": "rootexpert-snap", + "version": "1.0.1-rc0", "author": "root-expert", "summary": "Puppet module for installing Snap and managing snap packages.", "license": "Apache-2.0", @@ -14,28 +14,39 @@ "operatingsystem": "RedHat", "operatingsystemrelease": [ "7", - "8" + "8", + "9" ] }, { "operatingsystem": "CentOS", "operatingsystemrelease": [ "7", - "8" + "8", + "9" ] }, { "operatingsystem": "OracleLinux", "operatingsystemrelease": [ "7", - "8" + "8", + "9" ] }, { "operatingsystem": "Scientific", "operatingsystemrelease": [ "7", - "8" + "8", + "9" + ] + }, + { + "operatingsystem": "Fedora", + "operatingsystemrelease": [ + "33", + "34" ] }, { diff --git a/spec/acceptance/01_snapd_spec.rb b/spec/acceptance/01_snapd_spec.rb new file mode 100644 index 0000000..96f6ea2 --- /dev/null +++ b/spec/acceptance/01_snapd_spec.rb @@ -0,0 +1,137 @@ +# frozen_string_literal: true + +require 'spec_helper_acceptance' + +describe 'snapd class' do + context 'with default parameters' do + let(:manifest) { "class {'snap': }" } + + it_behaves_like 'an idempotent resource' + + describe package('snapd') do + it { is_expected.to be_installed } + end + + describe service('snapd') do + it { is_expected.to be_running } + it { is_expected.to be_enabled } + end + + describe file('/run/snapd.socket') do + it { is_expected.to be_socket } + end + end + + context 'package resource' do + describe 'installs package' do + let(:manifest) do + <<-PUPPET + package { 'hello-world': + ensure => installed, + provider => snap, + } + PUPPET + end + + it_behaves_like 'an idempotent resource' + + describe command('snap list --unicode=never --color=never') do + its(:stdout) { is_expected.to match(%r{hello-world}) } + end + end + + describe 'uninstalls package' do + let(:manifest) do + <<-PUPPET + package { 'hello-world': + ensure => absent, + provider => snap, + } + PUPPET + end + + it_behaves_like 'an idempotent resource' + + describe command('snap list --unicode=never --color=never') do + its(:stdout) { is_expected.not_to match(%r{hello-world}) } + end + end + + describe 'installs package with specified version' do + let(:manifest) do + <<-PUPPET + package { 'hello-world': + ensure => 'latest/candidate', + provider => snap, + } + PUPPET + end + + it_behaves_like 'an idempotent resource' + + describe command('snap list --unicode=never --color=never') do + its(:stdout) do + is_expected.to match(%r{hello-world}) + is_expected.to match(%r{candidate}) + end + end + end + + describe 'changes installed channel' do + let(:manifest) do + <<-PUPPET + package { 'hello-world': + ensure => 'latest/beta', + provider => snap, + } + PUPPET + end + + it_behaves_like 'an idempotent resource' + + describe command('snap list --unicode=never --color=never') do + its(:stdout) do + is_expected.to match(%r{hello-world}) + is_expected.to match(%r{beta}) + end + end + end + end + + describe 'purges the package' do + let(:manifest) do + <<-PUPPET + package { 'hello-world': + ensure => purged, + provider => snap, + } + PUPPET + end + + it_behaves_like 'an idempotent resource' + + describe command('snap list --unicode=never --color=never') do + its(:stdout) { is_expected.not_to match(%r{hello-world}) } + end + end + + describe 'installs latest/stable when ensure: latest' do + let(:manifest) do + <<-PUPPET + package { 'hello-world': + ensure => latest, + provider => snap, + } + PUPPET + end + + it_behaves_like 'an idempotent resource' + + describe command('snap list --unicode=never --color=never') do + its(:stdout) do + is_expected.to match(%r{hello-world}) + is_expected.to match(%r{stable}) + end + end + end +end diff --git a/spec/acceptance/02_snap_conf_spec.rb b/spec/acceptance/02_snap_conf_spec.rb new file mode 100644 index 0000000..6d20ae0 --- /dev/null +++ b/spec/acceptance/02_snap_conf_spec.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +require 'spec_helper_acceptance' + +describe 'snap_conf resource' do + context 'snap_conf' do + let(:manifest) do + <<-EOS + snap_conf { 'test1': + ensure => present, + snap => 'system', + conf => 'refresh.retain', + value => '3' + } + EOS + end + + it_behaves_like 'an idempotent resource' + + describe command('snap get system refresh.retain') do + its(:stdout) { is_expected.to match %r{3} } + end + end + + context 'destroy resource' do + let(:manifest) do + <<-EOS + snap_conf { 'test1': + ensure => absent, + snap => 'system', + conf => 'refresh.retain', + } + EOS + end + + it_behaves_like 'an idempotent resource' + + describe command('snap get system refresh.retain') do + its(:stderr) { is_expected.to match %r{error: snap "core" has no "refresh.retain" configuration option} } + end + end +end diff --git a/spec/acceptance/snapd_spec.rb b/spec/acceptance/snapd_spec.rb deleted file mode 100644 index 66c0ab9..0000000 --- a/spec/acceptance/snapd_spec.rb +++ /dev/null @@ -1,24 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper_acceptance' - -describe 'snapd class' do - context 'with default parameters' do - let(:manifest) { "class {'snap': }" } - - it_behaves_like 'an idempotent resource' - - describe package('snapd') do - it { is_expected.to be_installed } - end - - describe service('snapd') do - it { is_expected.to be_running } - it { is_expected.to be_enabled } - end - - describe file('/run/snapd.socket') do - it { is_expected.to be_socket } - end - end -end diff --git a/spec/classes/init_spec.rb b/spec/classes/init_spec.rb index a25e324..1b931f7 100644 --- a/spec/classes/init_spec.rb +++ b/spec/classes/init_spec.rb @@ -15,7 +15,8 @@ it { is_expected.to contain_package('snapd').with_ensure('installed') } it { is_expected.to contain_service('snapd').with_ensure('running').with_enable(true).that_requires('Package[snapd]') } - it { is_expected.to contain_package('core').with_ensure('installed').with_provider('snap').that_requires('Service[snapd]') } + it { is_expected.to contain_package('net_http_unix').with_ensure('installed').with_provider('puppet_gem').that_requires('Service[snapd]') } + it { is_expected.to contain_package('core').with_ensure('installed').with_provider('snap').that_requires(%w[Service[snapd] Package[net_http_unix]]) } end end end diff --git a/spec/fixtures/responses/find_res.json b/spec/fixtures/responses/find_res.json deleted file mode 100644 index 43238af..0000000 --- a/spec/fixtures/responses/find_res.json +++ /dev/null @@ -1,128 +0,0 @@ -{ - "type": "sync", - "status-code": 200, - "status": "OK", - "result": [ - { - "id": "buPKUD3TKqCOgLEjjHx5kSiCpIs5cMuQ", - "title": "Hello World", - "summary": "The 'hello-world' of snaps", - "description": "This is a simple hello world example.", - "download-size": 20480, - "icon": "https://dashboard.snapcraft.io/site_media/appmedia/2015/03/hello.svg_NZLfWbh.png", - "name": "hello-world", - "publisher": { - "id": "canonical", - "username": "canonical", - "display-name": "Canonical", - "validation": "verified" - }, - "store-url": "https://snapcraft.io/hello-world", - "developer": "canonical", - "status": "available", - "type": "app", - "version": "6.4", - "channel": "stable", - "ignore-validation": false, - "revision": "29", - "confinement": "strict", - "private": false, - "devmode": false, - "jailmode": false, - "contact": "mailto:snaps@canonical.com", - "license": "MIT", - "media": [ - { - "type": "icon", - "url": "https://dashboard.snapcraft.io/site_media/appmedia/2015/03/hello.svg_NZLfWbh.png", - "width": 256, - "height": 256 - }, - { - "type": "screenshot", - "url": "https://dashboard.snapcraft.io/site_media/appmedia/2018/06/Screenshot_from_2018-06-14_09-33-31.png", - "width": 199, - "height": 118 - }, - { - "type": "video", - "url": "https://vimeo.com/194577403" - } - ], - "channels": { - "latest/beta": { - "revision": "29", - "confinement": "strict", - "version": "6.0", - "channel": "latest/beta", - "epoch": { - "read": [ - 0 - ], - "write": [ - 0 - ] - }, - "size": 20480, - "released-at": "2019-04-17T16:48:09.90685Z" - }, - "latest/candidate": { - "revision": "29", - "confinement": "strict", - "version": "6.4", - "channel": "latest/candidate", - "epoch": { - "read": [ - 0 - ], - "write": [ - 0 - ] - }, - "size": 20480, - "released-at": "2019-04-17T16:47:59.117114Z" - }, - "latest/edge": { - "revision": "29", - "confinement": "strict", - "version": "6.4", - "channel": "latest/edge", - "epoch": { - "read": [ - 0 - ], - "write": [ - 0 - ] - }, - "size": 20480, - "released-at": "2019-04-17T16:44:33.84163Z" - }, - "latest/stable": { - "revision": "29", - "confinement": "strict", - "version": "6.4", - "channel": "latest/stable", - "epoch": { - "read": [ - 0 - ], - "write": [ - 0 - ] - }, - "size": 20480, - "released-at": "2019-04-17T16:47:59.117114Z" - } - }, - "tracks": [ - "latest" - ], - "install-date": "2021-09-14T13:32:22.871938558+03:00" - } - ], - "sources": [ - "store" - ], - "suggested-currency": "USD" -} diff --git a/spec/spec_helper_acceptance.rb b/spec/spec_helper_acceptance.rb index 2d49afa..fa9d1b9 100644 --- a/spec/spec_helper_acceptance.rb +++ b/spec/spec_helper_acceptance.rb @@ -27,7 +27,7 @@ apply_manifest_on(host, pp, catch_failures: true) apply_manifest_on(host, debian, catch_failures: true) if fact('os.family') == 'Debian' - install_module_from_forge_on(host, 'puppet/epel', '>= 3.1.0 < 4.0.0') if fact('os.family') == 'RedHat' + install_module_from_forge_on(host, 'puppet/epel', '>= 3.1.0 < 5.0.0') if fact('os.family') == 'RedHat' end Dir['./spec/support/acceptance/**/*.rb'].sort.each { |f| require f } diff --git a/spec/unit/puppet/provider/package/snap_spec.rb b/spec/unit/puppet/provider/package/snap_spec.rb index 1e444ae..329ca57 100644 --- a/spec/unit/puppet/provider/package/snap_spec.rb +++ b/spec/unit/puppet/provider/package/snap_spec.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require 'spec_helper' +require 'puppet_x/snap/api' describe Puppet::Type.type(:package).provider(:snap) do let(:name) { 'hello-world' } @@ -16,18 +17,17 @@ resource.provider end - async_change_id_res = JSON.parse(File.read('spec/fixtures/responses/async_change_id_res.json')) - error_res = JSON.parse(File.read('spec/fixtures/responses/error_res.json')) - change_status_doing = JSON.parse(File.read('spec/fixtures/responses/change_status_doing.json')) - change_status_done = JSON.parse(File.read('spec/fixtures/responses/change_status_done.json')) - change_status_error = JSON.parse(File.read('spec/fixtures/responses/change_status_error.json')) - find_res = JSON.parse(File.read('spec/fixtures/responses/find_res.json')) + before do + allow(PuppetX::Snap::API).to receive(:get).with('/v2/snaps').and_return('[]') + end context 'should have provider features' do it { is_expected.to be_installable } + it { is_expected.to be_versionable } it { is_expected.to be_install_options } it { is_expected.to be_uninstallable } it { is_expected.to be_purgeable } + it { is_expected.to be_upgradable } end context 'should respond to' do @@ -50,64 +50,47 @@ context 'installing without any option' do it 'generates correct request' do - response = provider.class.generate_request('install', nil) + response = provider.class.generate_request('install', nil, nil) expect(response).to eq('action' => 'install') end end - context 'installing with channel option' do + context 'installing with channel' do it 'generates correct request' do - response = provider.class.generate_request('install', ['channel=beta']) + response = provider.class.generate_request('install', 'beta', nil) expect(response).to eq('action' => 'install', 'channel' => 'beta') end end context 'installing with classic option' do it 'generates correct request' do - response = provider.class.generate_request('install', ['classic']) + response = provider.class.generate_request('install', nil, ['classic']) expect(response).to eq('action' => 'install', 'classic' => true) end end - context 'calling async operations' do - it 'raises an error if response is an error' do - expect { provider.class.get_id_from_async_req(error_res) }.to raise_error(Puppet::Error) - end - - it 'gets correct change id from response' do - id = provider.class.get_id_from_async_req(async_change_id_res) - expect(id).to eq('77') - end - end - - context 'completing async operations' do - it 'raises an error if response is an error' do - allow(described_class).to receive(:get_status).with('10').and_return(change_status_error) - - expect { provider.class.complete('10') }.to raise_error(Puppet::Error) + context 'decides the correct channel usage' do + it 'with no channel specified returns correct ensure value' do + expect(provider.determine_channel).to eq('latest/stable') end - it 'sleeps for 1 second if response hasn\'t completed' do - allow(described_class).to receive(:get_status).with('10').and_return(change_status_doing, change_status_done) - allow(described_class).to receive(:sleep) - provider.class.complete('10') + it 'with channel specified in ensure returns correct ensure value' do + resource[:ensure] = 'latest/beta' - expect(described_class).to have_received(:sleep).with(1) + expect(provider.determine_channel).to eq('latest/beta') end - end - context 'querying for latest version' do - it 'with no channel specified returns correct version from stable channel' do - allow(described_class).to receive(:call_api).with('GET', '/v2/find?name=hello-world').and_return(find_res) + it 'with channel specified in install options returns correct ensure value' do + resource[:install_options] = ['channel=latest/beta'] - expect(provider.latest).to eq('6.4') + expect(provider.determine_channel).to eq('latest/beta') end - it 'with channel specified returns correct version from specified channel' do - resource[:install_options] = ['channel=beta'] - allow(described_class).to receive(:call_api).with('GET', '/v2/find?name=hello-world').and_return(find_res) + it 'with channel specified in both ensure install options returns correct ensure value' do + resource[:install_options] = ['channel=latest/beta'] + resource[:ensure] = 'latest/candidate' # this should be preferred - expect(provider.latest).to eq('6.0') + expect(provider.determine_channel).to eq('latest/candidate') end end end diff --git a/spec/unit/puppet/provider/snap_conf/snap_conf_spec.rb b/spec/unit/puppet/provider/snap_conf/snap_conf_spec.rb new file mode 100644 index 0000000..e059cbe --- /dev/null +++ b/spec/unit/puppet/provider/snap_conf/snap_conf_spec.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'puppet_x/snap/api' + +describe Puppet::Type.type(:snap_conf) do + let(:name) { 'hello-world' } + let(:resource) do + Puppet::Type.type(:snap_conf).new( + name: name, + snap: 'system', + conf: 'refresh.retain', + value: '3' + ) + end + let(:provider) do + resource.provider + end + + it 'defaults to ensure => present' do + expect(resource[:ensure]).to eq :present + end +end diff --git a/spec/unit/puppet_x/snap/api_spec.rb b/spec/unit/puppet_x/snap/api_spec.rb new file mode 100644 index 0000000..ad89b27 --- /dev/null +++ b/spec/unit/puppet_x/snap/api_spec.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'puppet_x/snap/api' + +module PuppetX::Snap + describe API do + async_change_id_res = JSON.parse(File.read('spec/fixtures/responses/async_change_id_res.json')) + error_res = JSON.parse(File.read('spec/fixtures/responses/error_res.json')) + change_status_doing = JSON.parse(File.read('spec/fixtures/responses/change_status_doing.json')) + change_status_done = JSON.parse(File.read('spec/fixtures/responses/change_status_done.json')) + change_status_error = JSON.parse(File.read('spec/fixtures/responses/change_status_error.json')) + + context 'calling async operations' do + it 'raises an error if response is an error' do + expect { described_class.get_id_from_async_req(error_res) }.to raise_error(Puppet::Error) + end + + it 'gets correct change id from response' do + id = described_class.get_id_from_async_req(async_change_id_res) + expect(id).to eq('77') + end + end + + context 'completing async operations' do + it 'raises an error if response is an error' do + allow(described_class).to receive(:get_status).with('10').and_return(change_status_error) + + expect { described_class.complete('10') }.to raise_error(Puppet::Error) + end + + it 'sleeps for 1 second if response hasn\'t completed' do + allow(described_class).to receive(:get_status).with('10').and_return(change_status_doing, change_status_done) + allow(described_class).to receive(:sleep) + described_class.complete('10') + + expect(described_class).to have_received(:sleep).with(1) + end + end + end +end