diff options
author | Lamont Granquist <lamont@scriptkiddie.org> | 2018-11-29 10:41:43 -0800 |
---|---|---|
committer | GitHub <noreply@github.com> | 2018-11-29 10:41:43 -0800 |
commit | 971629bbd421e6975bd8f05a5dfcfa0b7724e7f2 (patch) | |
tree | 1c136dc4bca3c6684284d6c8911609a42a63bbb4 | |
parent | b8433f37539f83c37ddfe4301df87f6d1cebb12f (diff) | |
parent | 380505cc30dbe998c6bbbf070d5d9ee8d24babce (diff) | |
download | chef-971629bbd421e6975bd8f05a5dfcfa0b7724e7f2.tar.gz |
Merge pull request #7999 from chef/lcg/snap_package_support
Initial suppport for snap packages
-rw-r--r-- | lib/chef/provider/package/snap.rb | 360 | ||||
-rw-r--r-- | lib/chef/resource/snap_package.rb | 35 | ||||
-rw-r--r-- | lib/chef/resources.rb | 1 | ||||
-rw-r--r-- | spec/data/snap_package/async_result_success.json | 6 | ||||
-rw-r--r-- | spec/data/snap_package/change_id_result.json | 175 | ||||
-rw-r--r-- | spec/data/snap_package/find_result_failure.json | 10 | ||||
-rw-r--r-- | spec/data/snap_package/find_result_success.json | 70 | ||||
-rw-r--r-- | spec/data/snap_package/get_by_name_result_failure.json | 10 | ||||
-rw-r--r-- | spec/data/snap_package/get_by_name_result_success.json | 38 | ||||
-rw-r--r-- | spec/data/snap_package/get_conf_success.json | 10 | ||||
-rw-r--r-- | spec/data/snap_package/result_failure.json | 9 | ||||
-rw-r--r-- | spec/unit/provider/package/snap_spec.rb | 208 | ||||
-rw-r--r-- | spec/unit/provider_resolver_spec.rb | 1 | ||||
-rw-r--r-- | spec/unit/resource/snap_package_spec.rb | 60 |
14 files changed, 993 insertions, 0 deletions
diff --git a/lib/chef/provider/package/snap.rb b/lib/chef/provider/package/snap.rb new file mode 100644 index 0000000000..7bfb065a84 --- /dev/null +++ b/lib/chef/provider/package/snap.rb @@ -0,0 +1,360 @@ +# +# Author:: S.Cavallo (<smcavallo@hotmail.com>) +# Copyright:: Copyright 2016-2018, Chef Software Inc. +# License:: Apache License, Version 2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +require "chef/provider/package" +require "chef/resource/snap_package" +require "chef/mixin/shell_out" +require "socket" +require "json" + +class Chef + class Provider + class Package + class Snap < Chef::Provider::Package + allow_nils + use_multipackage_api + use_package_name_for_source + + provides :snap_package + + def load_current_resource + @current_resource = Chef::Resource::SnapPackage.new(new_resource.name) + current_resource.package_name(new_resource.package_name) + current_resource.version(get_current_versions) + + current_resource + end + + def define_resource_requirements + requirements.assert(:install, :upgrade, :remove, :purge) do |a| + a.assertion { !new_resource.source || ::File.exist?(new_resource.source) } + a.failure_message Chef::Exceptions::Package, "Package #{new_resource.package_name} not found: #{new_resource.source}" + a.whyrun "assuming #{new_resource.source} would have previously been created" + end + + super + end + + def candidate_version + package_name_array.each_with_index.map do |pkg, i| + available_version(i) + end + end + + def get_current_versions + package_name_array.each_with_index.map do |pkg, i| + installed_version(i) + end + end + + def install_package(names, versions) + if new_resource.source + install_snap_from_source(names, new_resource.source) + else + resolved_names = names.each_with_index.map { |name, i| available_version(i).to_s unless name.nil? } + install_snaps(resolved_names) + end + end + + def upgrade_package(names, versions) + if new_resource.source + install_snap_from_source(names, new_resource.source) + else + resolved_names = names.each_with_index.map { |name, i| available_version(i).to_s unless name.nil? } + update_snaps(resolved_names) + end + end + + def remove_package(names, versions) + resolved_names = names.each_with_index.map { |name, i| installed_version(i).to_s unless name.nil? } + uninstall_snaps(resolved_names) + end + + alias purge_package remove_package + + private + + # @return Array<Version> + def available_version(index) + @available_version ||= [] + + @available_version[index] ||= if new_resource.source + get_snap_version_from_source(new_resource.source) + else + get_latest_package_version(package_name_array[index], new_resource.channel) + end + + @available_version[index] + end + + # @return [Array<Version>] + def installed_version(index) + @installed_version ||= [] + @installed_version[index] ||= get_installed_package_version_by_name(package_name_array[index]) + @installed_version[index] + end + + def safe_version_array + if new_resource.version.is_a?(Array) + new_resource.version + elsif new_resource.version.nil? + package_name_array.map { nil } + else + [new_resource.version] + end + end + + # ToDo: Support authentication + # ToDo: Support private snap repos + # https://github.com/snapcore/snapd/wiki/REST-API + + # ToDo: Would prefer to use net/http over socket + def call_snap_api(method, uri, post_data = nil?) + request = "#{method} #{uri} HTTP/1.0\r\n" + + "Accept: application/json\r\n" + + "Content-Type: application/json\r\n" + if method == "POST" + request.concat("Content-Length: #{post_data.bytesize}\r\n\r\n#{post_data}") + end + request.concat("\r\n") + # While it is expected to allow clients to connect using HTTPS over a TCP socket, + # at this point only a UNIX socket is supported. The socket is /run/snapd.socket + # Note - UNIXSocket is not defined on windows systems + if defined?(::UNIXSocket) + UNIXSocket.open("/run/snapd.socket") do |socket| + # Send request, read the response, split the response and parse the body + socket.print(request) + response = socket.read + headers, body = response.split("\r\n\r\n", 2) + JSON.parse(body) + end + end + end + + def get_change_id(id) + call_snap_api("GET", "/v2/changes/#{id}") + end + + def get_id_from_async_response(response) + if response["type"] == "error" + raise "status: #{response["status"]}, kind: #{response["result"]["kind"]}, message: #{response["result"]["message"]}" + end + response["change"] + end + + def wait_for_completion(id) + n = 0 + waiting = true + while waiting + result = get_change_id(id) + puts "STATUS: #{result["result"]["status"]}" + case result["result"]["status"] + when "Do", "Doing", "Undoing", "Undo" + # Continue + when "Abort" + raise result + when "Hold", "Error" + raise result + when "Done" + waiting = false + else + # How to handle unknown status + end + n += 1 + raise "Snap operating timed out after #{n} seconds." if n == 300 + sleep(1) + end + end + + def snapctl(*args) + shell_out!("snap", *args) + end + + def get_snap_version_from_source(path) + body = { + "context-id" => "get_snap_version_from_source_#{path}", + "args" => ["info", path,], + }.to_json + + # json = call_snap_api('POST', '/v2/snapctl', body) + response = snapctl(["info", path]) + Chef::Log.trace(response) + response.error! + get_version_from_stdout(response.stdout) + end + + def get_version_from_stdout(stdout) + stdout.match(/version: (\S+)/)[1] + end + + def install_snap_from_source(name, path) + # json = call_snap_api('POST', '/v2/snapctl', body) + response = snapctl(["install", path]) + Chef::Log.trace(response) + response.error! + end + + def install_snaps(snap_names) + response = post_snaps(snap_names, "install", new_resource.channel, new_resource.options) + id = get_id_from_async_response(response) + wait_for_completion(id) + end + + def update_snaps(snap_names) + response = post_snaps(snap_names, "refresh", new_resource.channel, new_resource.options) + id = get_id_from_async_response(response) + wait_for_completion(id) + end + + def uninstall_snaps(snap_names) + response = post_snaps(snap_names, "remove", new_resource.channel, new_resource.options) + id = get_id_from_async_response(response) + wait_for_completion(id) + end + + # Constructs the multipart/form-data required to sideload packages + # https://github.com/snapcore/snapd/wiki/REST-API#sideload-request + # + # @param snap_name [String] An array of snap package names to install + # @param action [String] The action. Valid: install or try + # @param options [Hash] Misc configuration Options + # @param path [String] Path to the package on disk + # @param content_length [Integer] byte size of the snap file + def generate_multipart_form_data(snap_name, action, options, path, content_length) + snap_options = options.map do |k, v| + <<~SNAP_OPTION + Content-Disposition: form-data; name="#{k}" + + #{v} + --#{snap_name} + SNAP_OPTION + end + + pp snap_options + + multipart_form_data = <<~SNAP_S + Host: + Content-Type: multipart/form-data; boundary=#{snap_name} + Content-Length: #{content_length} + + --#{snap_name} + Content-Disposition: form-data; name="action" + + #{action} + --#{snap_name} + #{snap_options.join("\n").chomp} + Content-Disposition: form-data; name="snap"; filename="#{path}" + + <#{content_length} bytes of snap file data> + --#{snap_name} + SNAP_S + multipart_form_data + end + + # Constructs json to post for snap changes + # + # @param snap_names [Array] An array of snap package names to install + # @param action [String] The action. install, refresh, remove, revert, enable, disable or switch + # @param channel [String] The release channel. Ex. stable + # @param options [Hash] Misc configuration Options + # @param revision [String] A revision/version + def generate_snap_json(snap_names, action, channel, options, revision = nil) + request = { + "action" => action, + "snaps" => snap_names, + } + if %w{install refresh switch}.include?(action) + request["channel"] = channel + end + + # No defensive handling of params + # Snap will throw the proper exception if called improperly + # And we can provide that exception to the end user + request["classic"] = true if options["classic"] + request["devmode"] = true if options["devmode"] + request["jailmode"] = true if options["jailmode"] + request["revision"] = revision unless revision.nil? + request["ignore_validation"] = true if options["ignore-validation"] + request + end + + # Post to the snap api to update snaps + # + # @param snap_names [Array] An array of snap package names to install + # @param action [String] The action. install, refresh, remove, revert, enable, disable or switch + # @param channel [String] The release channel. Ex. stable + # @param options [Hash] Misc configuration Options + # @param revision [String] A revision/version + def post_snaps(snap_names, action, channel, options, revision = nil) + json = generate_snap_json(snap_names, action, channel, options, revision = nil) + call_snap_api("POST", "/v2/snaps", json) + end + + def get_latest_package_version(name, channel) + json = call_snap_api("GET", "/v2/find?name=#{name}") + if json["status-code"] != 200 + raise Chef::Exceptions::Package, json["result"], caller + end + + # Return the version matching the channel + json["result"][0]["channels"]["latest/#{channel}"]["version"] + end + + def get_installed_packages + json = call_snap_api("GET", "/v2/snaps") + # We only allow 200 or 404s + unless [200, 404].include? json["status-code"] + raise Chef::Exceptions::Package, json["result"], caller + end + json["result"] + end + + def get_installed_package_version_by_name(name) + result = get_installed_package_by_name(name) + # Return nil if not installed + if result["status-code"] == 404 + nil + else + result["version"] + end + end + + def get_installed_package_by_name(name) + json = call_snap_api("GET", "/v2/snaps/#{name}") + # We only allow 200 or 404s + unless [200, 404].include? json["status-code"] + raise Chef::Exceptions::Package, json["result"], caller + end + json["result"] + end + + def get_installed_package_conf(name) + json = call_snap_api("GET", "/v2/snaps/#{name}/conf") + json["result"] + end + + def set_installed_package_conf(name, value) + response = call_snap_api("PUT", "/v2/snaps/#{name}/conf", value) + id = get_id_from_async_response(response) + wait_for_completion(id) + end + + end + end + end +end diff --git a/lib/chef/resource/snap_package.rb b/lib/chef/resource/snap_package.rb new file mode 100644 index 0000000000..81904f8405 --- /dev/null +++ b/lib/chef/resource/snap_package.rb @@ -0,0 +1,35 @@ +# +# Author:: S.Cavallo (<smcavallo@hotmail.com>) +# Copyright:: Copyright 2008-2018, Chef Software Inc. +# License:: Apache License, Version 2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +require "chef/resource/package" + +class Chef + class Resource + class SnapPackage < Chef::Resource::Package + resource_name :snap_package + + description "Use the snap_package resource to manage snap packages on Debian and Ubuntu platforms." + + property :channel, String, + description: "The default channel. For example: stable.", + default: "stable", + equal_to: %w{edge beta candidate stable}, + desired_state: false + end + end +end diff --git a/lib/chef/resources.rb b/lib/chef/resources.rb index 32739087d5..9a418229c8 100644 --- a/lib/chef/resources.rb +++ b/lib/chef/resources.rb @@ -93,6 +93,7 @@ require "chef/resource/rhsm_register" require "chef/resource/rhsm_repo" require "chef/resource/rhsm_subscription" require "chef/resource/rpm_package" +require "chef/resource/snap_package" require "chef/resource/solaris_package" require "chef/resource/route" require "chef/resource/ruby" diff --git a/spec/data/snap_package/async_result_success.json b/spec/data/snap_package/async_result_success.json new file mode 100644 index 0000000000..09781ad5bd --- /dev/null +++ b/spec/data/snap_package/async_result_success.json @@ -0,0 +1,6 @@ +{ + "type": "async", + "status-code": 202, + "status": "Accepted", + "change": "401" +} diff --git a/spec/data/snap_package/change_id_result.json b/spec/data/snap_package/change_id_result.json new file mode 100644 index 0000000000..cd823d7cbc --- /dev/null +++ b/spec/data/snap_package/change_id_result.json @@ -0,0 +1,175 @@ +{ + "type": "sync", + "status-code": 200, + "status": "OK", + "result": { + "id": "15", + "kind": "install-snap", + "summary": "Install snap \"hello\"", + "status": "Done", + "tasks": [{ + "id": "165", + "kind": "prerequisites", + "summary": "Ensure prerequisites for \"hello\" are available", + "status": "Done", + "progress": { + "label": "", + "done": 1, + "total": 1 + }, + "spawn-time": "2018-09-22T20:25:25.22104314Z", + "ready-time": "2018-09-22T20:25:25.231090966Z" + }, { + "id": "166", + "kind": "download-snap", + "summary": "Download snap \"hello\" (20) from channel \"stable\"", + "status": "Done", + "progress": { + "label": "", + "done": 1, + "total": 1 + }, + "spawn-time": "2018-09-22T20:25:25.221070859Z", + "ready-time": "2018-09-22T20:25:25.24321909Z" + }, { + "id": "167", + "kind": "validate-snap", + "summary": "Fetch and check assertions for snap \"hello\" (20)", + "status": "Done", + "progress": { + "label": "", + "done": 1, + "total": 1 + }, + "spawn-time": "2018-09-22T20:25:25.221080163Z", + "ready-time": "2018-09-22T20:25:25.55308904Z" + }, { + "id": "168", + "kind": "mount-snap", + "summary": "Mount snap \"hello\" (20)", + "status": "Done", + "progress": { + "label": "", + "done": 1, + "total": 1 + }, + "spawn-time": "2018-09-22T20:25:25.221082984Z", + "ready-time": "2018-09-22T20:25:25.782452658Z" + }, { + "id": "169", + "kind": "copy-snap-data", + "summary": "Copy snap \"hello\" data", + "status": "Done", + "progress": { + "label": "", + "done": 1, + "total": 1 + }, + "spawn-time": "2018-09-22T20:25:25.221085677Z", + "ready-time": "2018-09-22T20:25:25.790911883Z" + }, { + "id": "170", + "kind": "setup-profiles", + "summary": "Setup snap \"hello\" (20) security profiles", + "status": "Done", + "progress": { + "label": "", + "done": 1, + "total": 1 + }, + "spawn-time": "2018-09-22T20:25:25.221088261Z", + "ready-time": "2018-09-22T20:25:25.972796111Z" + }, { + "id": "171", + "kind": "link-snap", + "summary": "Make snap \"hello\" (20) available to the system", + "status": "Done", + "progress": { + "label": "", + "done": 1, + "total": 1 + }, + "spawn-time": "2018-09-22T20:25:25.221090669Z", + "ready-time": "2018-09-22T20:25:25.986931331Z" + }, { + "id": "172", + "kind": "auto-connect", + "summary": "Automatically connect eligible plugs and slots of snap \"hello\"", + "status": "Done", + "progress": { + "label": "", + "done": 1, + "total": 1 + }, + "spawn-time": "2018-09-22T20:25:25.221093357Z", + "ready-time": "2018-09-22T20:25:25.996914144Z" + }, { + "id": "173", + "kind": "set-auto-aliases", + "summary": "Set automatic aliases for snap \"hello\"", + "status": "Done", + "progress": { + "label": "", + "done": 1, + "total": 1 + }, + "spawn-time": "2018-09-22T20:25:25.221097651Z", + "ready-time": "2018-09-22T20:25:26.009155888Z" + }, { + "id": "174", + "kind": "setup-aliases", + "summary": "Setup snap \"hello\" aliases", + "status": "Done", + "progress": { + "label": "", + "done": 1, + "total": 1 + }, + "spawn-time": "2018-09-22T20:25:25.221100379Z", + "ready-time": "2018-09-22T20:25:26.021062388Z" + }, { + "id": "175", + "kind": "run-hook", + "summary": "Run install hook of \"hello\" snap if present", + "status": "Done", + "progress": { + "label": "", + "done": 1, + "total": 1 + }, + "spawn-time": "2018-09-22T20:25:25.221103116Z", + "ready-time": "2018-09-22T20:25:26.031383884Z" + }, { + "id": "176", + "kind": "start-snap-services", + "summary": "Start snap \"hello\" (20) services", + "status": "Done", + "progress": { + "label": "", + "done": 1, + "total": 1 + }, + "spawn-time": "2018-09-22T20:25:25.221110251Z", + "ready-time": "2018-09-22T20:25:26.039564637Z" + }, { + "id": "177", + "kind": "run-hook", + "summary": "Run configure hook of \"hello\" snap if present", + "status": "Done", + "progress": { + "label": "", + "done": 1, + "total": 1 + }, + "spawn-time": "2018-09-22T20:25:25.221115952Z", + "ready-time": "2018-09-22T20:25:26.05069451Z" + } + ], + "ready": true, + "spawn-time": "2018-09-22T20:25:25.221130149Z", + "ready-time": "2018-09-22T20:25:26.050696298Z", + "data": { + "snap-names": ["hello"] + } + } +} diff --git a/spec/data/snap_package/find_result_failure.json b/spec/data/snap_package/find_result_failure.json new file mode 100644 index 0000000000..ec0d82a3b8 --- /dev/null +++ b/spec/data/snap_package/find_result_failure.json @@ -0,0 +1,10 @@ +{ + "type": "error", + "status-code": 404, + "status": "Not Found", + "result": { + "message": "snap not found", + "kind": "snap-not-found", + "value": "hello2" + } +} diff --git a/spec/data/snap_package/find_result_success.json b/spec/data/snap_package/find_result_success.json new file mode 100644 index 0000000000..f19f24dcef --- /dev/null +++ b/spec/data/snap_package/find_result_success.json @@ -0,0 +1,70 @@ +{ + "type": "sync", + "status-code": 200, + "status": "OK", + "result": [{ + "id": "mVyGrEwiqSi5PugCwyH7WgpoQLemtTd6", + "title": "hello", + "summary": "GNU Hello, the \"hello world\" snap", + "description": "GNU hello prints a friendly greeting. This is part of the snapcraft tour at https://snapcraft.io/", + "download-size": 65536, + "name": "hello", + "publisher": { + "id": "canonical", + "username": "canonical", + "display-name": "Canonical", + "validation": "verified" + }, + "developer": "canonical", + "status": "available", + "type": "app", + "version": "2.10", + "channel": "stable", + "ignore-validation": false, + "revision": "20", + "confinement": "strict", + "private": false, + "devmode": false, + "jailmode": false, + "contact": "mailto:snaps@canonical.com", + "license": "GPL-3.0", + "channels": { + "latest/beta": { + "revision": "29", + "confinement": "strict", + "version": "2.10.1", + "channel": "beta", + "epoch": "0", + "size": 65536 + }, + "latest/candidate": { + "revision": "20", + "confinement": "strict", + "version": "2.10", + "channel": "candidate", + "epoch": "0", + "size": 65536 + }, + "latest/edge": { + "revision": "34", + "confinement": "strict", + "version": "2.10.42", + "channel": "edge", + "epoch": "0", + "size": 65536 + }, + "latest/stable": { + "revision": "20", + "confinement": "strict", + "version": "2.10", + "channel": "stable", + "epoch": "0", + "size": 65536 + } + }, + "tracks": ["latest"] + } + ], + "sources": ["store"], + "suggested-currency": "USD" +} diff --git a/spec/data/snap_package/get_by_name_result_failure.json b/spec/data/snap_package/get_by_name_result_failure.json new file mode 100644 index 0000000000..c8c1bb7342 --- /dev/null +++ b/spec/data/snap_package/get_by_name_result_failure.json @@ -0,0 +1,10 @@ +{ + "type": "error", + "status-code": 404, + "status": "Not Found", + "result": { + "message": "snap not installed", + "kind": "snap-not-found", + "value": "aws-cliasdfasdf" + } +} diff --git a/spec/data/snap_package/get_by_name_result_success.json b/spec/data/snap_package/get_by_name_result_success.json new file mode 100644 index 0000000000..05517362ab --- /dev/null +++ b/spec/data/snap_package/get_by_name_result_success.json @@ -0,0 +1,38 @@ +{ + "type": "sync", + "status-code": 200, + "status": "OK", + "result": { + "id": "CRrJViJiSuDcCkU31G0xpNRVNaj4P960", + "summary": "Universal Command Line Interface for Amazon Web Services", + "description": "This package provides a unified command line interface to Amazon Web\nServices.\n", + "installed-size": 15851520, + "name": "aws-cli", + "publisher": { + "id": "S7iQ7mKDXBDliQqRcgefvc2TKXIH9pYk", + "username": "aws", + "display-name": "Amazon Web Services", + "validation": "verified" + }, + "developer": "aws", + "status": "active", + "type": "app", + "version": "1.15.71", + "channel": "", + "tracking-channel": "stable", + "ignore-validation": false, + "revision": "135", + "confinement": "classic", + "private": false, + "devmode": false, + "jailmode": false, + "apps": [{ + "snap": "aws-cli", + "name": "aws" + } + ], + "contact": "", + "mounted-from": "/var/lib/snapd/snaps/aws-cli_135.snap", + "install-date": "2018-09-17T20:39:38.516Z" + } +} diff --git a/spec/data/snap_package/get_conf_success.json b/spec/data/snap_package/get_conf_success.json new file mode 100644 index 0000000000..e83ffbfbe3 --- /dev/null +++ b/spec/data/snap_package/get_conf_success.json @@ -0,0 +1,10 @@ +{ + "type": "sync", + "status-code": 200, + "status": "OK", + "result": { + "address": "0.0.0.0", + "allow-privileged": true, + "anonymous-auth": false + } +} diff --git a/spec/data/snap_package/result_failure.json b/spec/data/snap_package/result_failure.json new file mode 100644 index 0000000000..e65120ad33 --- /dev/null +++ b/spec/data/snap_package/result_failure.json @@ -0,0 +1,9 @@ +{ + "type": "error", + "status-code": 401, + "status": "Unauthorized", + "result": { + "message": "access denied", + "kind": "login-required" + } +} diff --git a/spec/unit/provider/package/snap_spec.rb b/spec/unit/provider/package/snap_spec.rb new file mode 100644 index 0000000000..674870824b --- /dev/null +++ b/spec/unit/provider/package/snap_spec.rb @@ -0,0 +1,208 @@ +# Author:: S.Cavallo (smcavallo@hotmail.com) +# Copyright 2014-2018, Chef Software Inc. <legal@chef.io> +# License:: Apache License, Version 2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +require "spec_helper" +require "chef/provider/package" +require "chef/provider/package/snap" +require "json" + +describe Chef::Provider::Package::Snap do + let(:node) { Chef::Node.new } + let(:events) { Chef::EventDispatch::Dispatcher.new } + let(:run_context) { Chef::RunContext.new(node, {}, events) } + let(:package) { "hello" } + let(:source) { "/tmp/hello_20.snap" } + let(:new_resource) do + new_resource = Chef::Resource::SnapPackage.new(package) + new_resource.source source + new_resource + end + let(:provider) { Chef::Provider::Package::Snap.new(new_resource, run_context) } + let(:snap_status) do + stdout = <<~SNAP_S + path: "/tmp/hello_20.snap" + name: hello + summary: GNU Hello, the "hello world" snap + version: 2.10 - + SNAP_S + status = double(stdout: stdout, stderr: "", exitstatus: 0) + allow(status).to receive(:error!).with(no_args).and_return(false) + status + end + + before(:each) do + allow(provider).to receive(:shell_out_compacted!).with("snap", "info", source, timeout: 900).and_return(snap_status) + end + + # Example output from https://github.com/snapcore/snapd/wiki/REST-API + find_result_success = JSON.parse(File.read(File.join(CHEF_SPEC_DATA, "snap_package", "find_result_success.json"))) + find_result_fail = JSON.parse(File.read(File.join(CHEF_SPEC_DATA, "snap_package", "find_result_failure.json"))) + get_by_name_result_success = JSON.parse(File.read(File.join(CHEF_SPEC_DATA, "snap_package", "get_by_name_result_success.json"))) + get_by_name_result_fail = JSON.parse(File.read(File.join(CHEF_SPEC_DATA, "snap_package", "get_by_name_result_failure.json"))) + async_result_success = JSON.parse(File.read(File.join(CHEF_SPEC_DATA, "snap_package", "async_result_success.json"))) + result_fail = JSON.parse(File.read(File.join(CHEF_SPEC_DATA, "snap_package", "result_failure.json"))) + change_id_result = JSON.parse(File.read(File.join(CHEF_SPEC_DATA, "snap_package", "change_id_result.json"))) + get_conf_success = JSON.parse(File.read(File.join(CHEF_SPEC_DATA, "snap_package", "get_conf_success.json"))) + + describe "#define_resource_requirements" do + + before do + allow_any_instance_of(Chef::Provider::Package::Snap).to receive(:call_snap_api).with("GET", "/v2/snaps/#{package}").and_return(get_by_name_result_success) + end + + it "should raise an exception if a source is supplied but not found when :install" do + allow(::File).to receive(:exist?).with(source).and_return(false) + expect { provider.run_action(:install) }.to raise_error(Chef::Exceptions::Package) + end + + it "should raise an exception if a source is supplied but not found when :upgrade" do + allow(::File).to receive(:exist?).with(source).and_return(false) + expect { provider.run_action(:upgrade) }.to raise_error(Chef::Exceptions::Package) + end + end + + describe "when using a local file source" do + let(:source) { "/tmp/hello_20.snap" } + + before do + allow_any_instance_of(Chef::Provider::Package::Snap).to receive(:call_snap_api).with("GET", "/v2/snaps/#{package}").and_return(get_by_name_result_success) + end + + it "should create a current resource with the name of the new_resource" do + provider.load_current_resource + expect(provider.current_resource.package_name).to eq("hello") + end + + describe "gets the candidate version from the source package" do + + def check_version(version) + provider.load_current_resource + expect(provider.current_resource.package_name).to eq("hello") + expect(provider.get_current_versions).to eq(["1.15.71"]) + expect(provider.candidate_version).to eq([version]) + end + + it "checks the installed and local candidate versions" do + check_version("2.10") + end + + it "generates multipart form data" do + expected = <<~SNAP_S + Host: + Content-Type: multipart/form-data; boundary=foo + Content-Length: 20480 + + --foo + Content-Disposition: form-data; name="action" + + install + --foo + Content-Disposition: form-data; name="devmode" + + true + --foo + Content-Disposition: form-data; name="snap"; filename="hello-world_27.snap" + + <20480 bytes of snap file data> + --foo + SNAP_S + + options = {} + options["devmode"] = true + path = "hello-world_27.snap" + content_length = "20480" + + result = provider.send(:generate_multipart_form_data, "foo", "install", options, path, content_length) + + expect(result).to eq(expected) + + end + + end + end + + describe "when using the snap store" do + let(:source) { nil } + describe "gets the candidate version from the snap store" do + before do + allow_any_instance_of(Chef::Provider::Package::Snap).to receive(:call_snap_api).with("GET", "/v2/find?name=#{package}").and_return(find_result_success) + allow_any_instance_of(Chef::Provider::Package::Snap).to receive(:call_snap_api).with("GET", "/v2/snaps/#{package}").and_return(get_by_name_result_success) + end + + def check_version(version) + provider.load_current_resource + expect(provider.current_resource.package_name).to eq("hello") + expect(provider.get_current_versions).to eq(["1.15.71"]) + expect(provider.candidate_version).to eq([version]) + end + + it "checks the installed and store candidate versions" do + check_version("2.10") + end + + end + + describe "fails to get the candidate version from the snap store" do + before do + allow_any_instance_of(Chef::Provider::Package::Snap).to receive(:call_snap_api).with("GET", "/v2/find?name=#{package}").and_return(find_result_fail) + allow_any_instance_of(Chef::Provider::Package::Snap).to receive(:call_snap_api).with("GET", "/v2/snaps/#{package}").and_return(get_by_name_result_fail) + end + + it "throws an error if candidate version not found" do + provider.load_current_resource + expect { provider.candidate_version }.to raise_error(Chef::Exceptions::Package) + end + + it "does not throw an error if installed version not found" do + provider.load_current_resource + expect(provider.get_current_versions).to eq([nil]) + end + end + end + + describe "when calling async operations" do + + it "should should throw if the async response is an error" do + expect { provider.send(:get_id_from_async_response, result_fail) }.to raise_error(RuntimeError) + end + + it "should get the id from an async response" do + result = provider.send(:get_id_from_async_response, async_result_success) + expect(result).to eq("401") + end + + it "should wait for change completion" do + result = provider.send(:get_id_from_async_response, async_result_success) + expect(result).to eq("401") + end + end + + describe Chef::Provider::Package::Snap do + + it "should post the correct json" do + snap_names = ["hello"] + action = "install" + channel = "stable" + options = {} + revision = nil + actual = provider.send(:generate_snap_json, snap_names, action, channel, options, revision) + + expect(actual).to eq("action" => "install", "snaps" => ["hello"], "channel" => "stable") + end + + end +end diff --git a/spec/unit/provider_resolver_spec.rb b/spec/unit/provider_resolver_spec.rb index a3f2801adf..5066135b90 100644 --- a/spec/unit/provider_resolver_spec.rb +++ b/spec/unit/provider_resolver_spec.rb @@ -593,6 +593,7 @@ describe Chef::ProviderResolver do ruby: [ Chef::Resource::Ruby, Chef::Provider::Script ], script: [ Chef::Resource::Script, Chef::Provider::Script ], smartos_package: [ Chef::Resource::SmartosPackage, Chef::Provider::Package::SmartOS ], + snap_package: [ Chef::Resource::SnapPackage, Chef::Provider::Package::Snap ], solaris_package: [ Chef::Resource::SolarisPackage, Chef::Provider::Package::Solaris ], solaris_user: [ Chef::Resource::User::SolarisUser, Chef::Provider::User::Solaris ], subversion: [ Chef::Resource::Subversion, Chef::Provider::Subversion ], diff --git a/spec/unit/resource/snap_package_spec.rb b/spec/unit/resource/snap_package_spec.rb new file mode 100644 index 0000000000..e625d6b2c3 --- /dev/null +++ b/spec/unit/resource/snap_package_spec.rb @@ -0,0 +1,60 @@ +# +# Author:: S.Cavallo (<smcavallo@hotmail.com>) +# Copyright:: Copyright 2008-2018, Chef Software Inc. +# License:: Apache License, Version 2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +require "spec_helper" +require "chef/resource/snap_package" +require "chef/provider/package/snap" +require "support/shared/unit/resource/static_provider_resolution" + +describe Chef::Resource::SnapPackage, "initialize" do + + static_provider_resolution( + resource: Chef::Resource::SnapPackage, + provider: Chef::Provider::Package::Snap, + name: :snap_package, + action: :install, + os: "linux" + ) + + let(:resource) { Chef::Resource::SnapPackage.new("foo") } + + it "sets the default action as :install" do + expect(resource.action).to eql([:install]) + end + + it "supports :install, :lock, :purge, :reconfig, :remove, :unlock, :upgrade actions" do + expect { resource.action :install }.not_to raise_error + expect { resource.action :lock }.not_to raise_error + expect { resource.action :purge }.not_to raise_error + expect { resource.action :reconfig }.not_to raise_error + expect { resource.action :remove }.not_to raise_error + expect { resource.action :unlock }.not_to raise_error + expect { resource.action :upgrade }.not_to raise_error + end + + it "channel defaults to stable" do + expect(resource.channel).to eql("stable") + end + + it "supports all channel values" do + expect { resource.channel "stable" }.not_to raise_error + expect { resource.channel "edge" }.not_to raise_error + expect { resource.channel "beta" }.not_to raise_error + expect { resource.channel "candidate" }.not_to raise_error + end +end |