summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorLamont Granquist <lamont@scriptkiddie.org>2018-11-29 10:41:43 -0800
committerGitHub <noreply@github.com>2018-11-29 10:41:43 -0800
commit971629bbd421e6975bd8f05a5dfcfa0b7724e7f2 (patch)
tree1c136dc4bca3c6684284d6c8911609a42a63bbb4
parentb8433f37539f83c37ddfe4301df87f6d1cebb12f (diff)
parent380505cc30dbe998c6bbbf070d5d9ee8d24babce (diff)
downloadchef-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.rb360
-rw-r--r--lib/chef/resource/snap_package.rb35
-rw-r--r--lib/chef/resources.rb1
-rw-r--r--spec/data/snap_package/async_result_success.json6
-rw-r--r--spec/data/snap_package/change_id_result.json175
-rw-r--r--spec/data/snap_package/find_result_failure.json10
-rw-r--r--spec/data/snap_package/find_result_success.json70
-rw-r--r--spec/data/snap_package/get_by_name_result_failure.json10
-rw-r--r--spec/data/snap_package/get_by_name_result_success.json38
-rw-r--r--spec/data/snap_package/get_conf_success.json10
-rw-r--r--spec/data/snap_package/result_failure.json9
-rw-r--r--spec/unit/provider/package/snap_spec.rb208
-rw-r--r--spec/unit/provider_resolver_spec.rb1
-rw-r--r--spec/unit/resource/snap_package_spec.rb60
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