inspec-docker-resources 7.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/Gemfile +14 -0
- data/README.md +16 -0
- data/inspec-docker-resources.gemspec +44 -0
- data/inspec.yml +10 -0
- data/lib/inspec-docker-resources/plugin.rb +42 -0
- data/lib/inspec-docker-resources/resource_pack.rb +15 -0
- data/lib/inspec-docker-resources/resources/.gitkeep +0 -0
- data/lib/inspec-docker-resources/resources/docker.rb +272 -0
- data/lib/inspec-docker-resources/resources/docker_container.rb +114 -0
- data/lib/inspec-docker-resources/resources/docker_image.rb +139 -0
- data/lib/inspec-docker-resources/resources/docker_object.rb +52 -0
- data/lib/inspec-docker-resources/resources/docker_plugin.rb +66 -0
- data/lib/inspec-docker-resources/resources/docker_service.rb +93 -0
- data/lib/inspec-docker-resources/version.rb +9 -0
- data/lib/inspec-docker-resources.rb +14 -0
- metadata +72 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: 42403a609f1a1f69bef2e78bfd0ccd689572c3e1f97410bdd2a255edb25deb89
|
4
|
+
data.tar.gz: 806499cbed562f28c5797e2496ed2e816ab90fc24e34011791bcefacd0fae3a7
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 7e5690271746f476fb40504d742924f93c5f5d3460ca74a9168f4270fe813e73bd33d818e5907ad0875319e3a0ec49f86a4625fe9f3b5351481f2b577ac95c9b
|
7
|
+
data.tar.gz: a5c00db497287e910beb8ffa0a6a37382dc8ba5799b1cba2253e0994cdf3f7d78d43b533be27e6bd50b590b0afebe4c41d40cc4fd4f0af520d5f6784c5b9ac72
|
data/Gemfile
ADDED
@@ -0,0 +1,14 @@
|
|
1
|
+
source "https://rubygems.org"
|
2
|
+
gem "inspec-bin", git: "https://github.com/inspec/inspec", branch: "inspec-7"
|
3
|
+
gem "inspec", git: "https://github.com/inspec/inspec", branch: "inspec-7"
|
4
|
+
|
5
|
+
gemspec
|
6
|
+
|
7
|
+
group :test do
|
8
|
+
gem "mocha"
|
9
|
+
gem "minitest"
|
10
|
+
gem "mocha"
|
11
|
+
gem "chefstyle"
|
12
|
+
gem "simplecov"
|
13
|
+
gem "simplecov_json_formatter"
|
14
|
+
end
|
data/README.md
ADDED
@@ -0,0 +1,16 @@
|
|
1
|
+
# inspec-docker-resources
|
2
|
+
|
3
|
+
Docker InSpec Resources in a Gem
|
4
|
+
|
5
|
+
This repository contains the InSpec Docker resources, formerly contained in InSpec Core. In InSpec 7+, these resources are available in a gem, `inspec-docker-resources`.
|
6
|
+
|
7
|
+
## Usage
|
8
|
+
|
9
|
+
To use this resource pack, add this dependency to your inspec.yml :
|
10
|
+
|
11
|
+
```yaml
|
12
|
+
depends:
|
13
|
+
- name: inspec-docker-resources
|
14
|
+
gem: inspec-docker-resources
|
15
|
+
```
|
16
|
+
|
@@ -0,0 +1,44 @@
|
|
1
|
+
# As plugins are usually packaged and distributed as a RubyGem,
|
2
|
+
# we have to provide a .gemspec file, which controls the gembuild
|
3
|
+
# and publish process. This is a fairly generic gemspec.
|
4
|
+
|
5
|
+
# It is traditional in a gemspec to dynamically load the current version
|
6
|
+
# from a file in the source tree. The next three lines make that happen.
|
7
|
+
lib = File.expand_path("lib", __dir__)
|
8
|
+
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
9
|
+
require "inspec-docker-resources/version"
|
10
|
+
|
11
|
+
Gem::Specification.new do |spec|
|
12
|
+
# Importantly, all InSpec plugins must be prefixed with `inspec-` (most
|
13
|
+
# plugins) or `train-` (plugins which add new connectivity features).
|
14
|
+
spec.name = "inspec-docker-resources"
|
15
|
+
|
16
|
+
# It is polite to namespace your plugin under InspecPlugins::YourPluginInCamelCase
|
17
|
+
spec.version = InspecPlugins::DockerResources::VERSION
|
18
|
+
spec.authors = ["InSpec Core Maintainers"]
|
19
|
+
spec.email = ["inspec@progress.com"]
|
20
|
+
spec.summary = "Docker InSpec Resources in a Gem"
|
21
|
+
spec.description = "Contains InSpec 7.0+ resources fo interacting with Docker Desktop."
|
22
|
+
spec.homepage = "https://github.com/inspec/inspec-docker-resources"
|
23
|
+
spec.license = "Apache-2.0"
|
24
|
+
|
25
|
+
# Though complicated-looking, this is pretty standard for a gemspec.
|
26
|
+
# It just filters what will actually be packaged in the gem (leaving
|
27
|
+
# out tests, etc)
|
28
|
+
spec.files = %w{
|
29
|
+
README.md inspec-docker-resources.gemspec Gemfile inspec.yml
|
30
|
+
} + Dir.glob(
|
31
|
+
"lib/**/*", File::FNM_DOTMATCH
|
32
|
+
).reject { |f| File.directory?(f) }
|
33
|
+
spec.require_paths = ["lib"]
|
34
|
+
|
35
|
+
spec.required_ruby_version = ">= 3.1.0"
|
36
|
+
|
37
|
+
# If you rely on any other gems, list them here with any constraints.
|
38
|
+
# This is how `inspec plugin install` is able to manage your dependencies.
|
39
|
+
# For example, perhaps you are writing a thing that talks to AWS, and you
|
40
|
+
# want to ensure you have `aws-sdk` in a certain version.
|
41
|
+
|
42
|
+
# This plugin uses InSpec 7 Resource Pack Plugins
|
43
|
+
spec.add_dependency "inspec-core", ">= 7.0"
|
44
|
+
end
|
data/inspec.yml
ADDED
@@ -0,0 +1,10 @@
|
|
1
|
+
name: inspec-docker-resources
|
2
|
+
title: InSpec Docker Resources
|
3
|
+
maintainer: InSpec Core Maintainers
|
4
|
+
copyright: Progress Software Corporation
|
5
|
+
copyright_email: inspec@progress.com
|
6
|
+
license: Apache-2.0
|
7
|
+
summary: Docker InSpec Resources in a Gem
|
8
|
+
version: 7.1.5
|
9
|
+
supports:
|
10
|
+
platform: os
|
@@ -0,0 +1,42 @@
|
|
1
|
+
# Plugin Definition file
|
2
|
+
# The purpose of this file is to declare to InSpec what plugin_types (capabilities)
|
3
|
+
# are included in this plugin, and provide activator that will load them as needed.
|
4
|
+
|
5
|
+
# It is important that this file load successfully and *quickly*.
|
6
|
+
# Your plugin's functionality may never be used on this InSpec run; so we keep things
|
7
|
+
# fast and light by only loading heavy things when they are needed.
|
8
|
+
|
9
|
+
# Presumably this is light
|
10
|
+
require "inspec-docker-resources/version"
|
11
|
+
|
12
|
+
# The InspecPlugins namespace is where all plugins should declare themselves.
|
13
|
+
# The "Inspec" capitalization is used throughout the InSpec source code; yes, it's
|
14
|
+
# strange.
|
15
|
+
module InspecPlugins
|
16
|
+
# Pick a reasonable namespace here for your plugin. A reasonable choice
|
17
|
+
# would be the CamelCase version of your plugin gem name.
|
18
|
+
# inspec-test-resources => TestResources
|
19
|
+
module DockerResources
|
20
|
+
# This simple class handles the plugin definition, so calling it simply Plugin is OK.
|
21
|
+
# Inspec.plugin returns various Classes, intended to be superclasses for various
|
22
|
+
# plugin components. Here, the one-arg form gives you the Plugin Definition superclass,
|
23
|
+
# which mainly gives you access to the activator / plugin_type DSL.
|
24
|
+
# The number '2' says you are asking for version 2 of the plugin API. If there are
|
25
|
+
# future versions, InSpec promises plugin API v2 will work for at least two more InSpec
|
26
|
+
# major versions.
|
27
|
+
class Plugin < ::Inspec.plugin(2)
|
28
|
+
# Internal machine name of the plugin. InSpec will use this in errors, etc.
|
29
|
+
plugin_name :"inspec-docker-resources"
|
30
|
+
|
31
|
+
# Define a new Resource Pack.
|
32
|
+
resource_pack :"inspec-docker-resources" do
|
33
|
+
# This file will load the resources implicitly via the superclass
|
34
|
+
require "inspec-docker-resources/resource_pack"
|
35
|
+
|
36
|
+
# Having loaded our functionality, return a class that represents the plugin.
|
37
|
+
# Reserved for future use.
|
38
|
+
InspecPlugins::DockerResources::ResourcePack
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
@@ -0,0 +1,15 @@
|
|
1
|
+
require "inspec/resource"
|
2
|
+
|
3
|
+
module InspecPlugins::DockerResources
|
4
|
+
# This class will provide the actual CLI implementation.
|
5
|
+
# Its superclass is provided by another call to Inspec.plugin,
|
6
|
+
# this time with two args. The first arg specifies we are requesting
|
7
|
+
# version 2 of the Plugins API. The second says we are making a Resource
|
8
|
+
# Pack plugin component, so please make available any DSL needed
|
9
|
+
# for that.
|
10
|
+
class ResourcePack < Inspec.plugin(2, :resource_pack)
|
11
|
+
# TBD
|
12
|
+
# load_timing :early <-- isn't that implicit in the rewuire statements
|
13
|
+
# train relationship declarations? <-- that should be in the gemspec
|
14
|
+
end
|
15
|
+
end
|
File without changes
|
@@ -0,0 +1,272 @@
|
|
1
|
+
#
|
2
|
+
# Copyright 2017, Christoph Hartmann
|
3
|
+
#
|
4
|
+
|
5
|
+
require "inspec/resources/command"
|
6
|
+
require "inspec/utils/filter"
|
7
|
+
require "hashie/mash"
|
8
|
+
|
9
|
+
class DockerContainerFilter
|
10
|
+
# use filtertable for containers
|
11
|
+
filter = FilterTable.create
|
12
|
+
filter.register_custom_matcher(:exists?) { |x| !x.entries.empty? }
|
13
|
+
filter.register_column(:commands, field: "command")
|
14
|
+
.register_column(:ids, field: "id")
|
15
|
+
.register_column(:images, field: "image")
|
16
|
+
.register_column(:labels, field: "labels", style: :simple)
|
17
|
+
.register_column(:local_volumes, field: "localvolumes")
|
18
|
+
.register_column(:mounts, field: "mounts")
|
19
|
+
.register_column(:names, field: "names")
|
20
|
+
.register_column(:networks, field: "networks")
|
21
|
+
.register_column(:ports, field: "ports")
|
22
|
+
.register_column(:running_for, field: "runningfor")
|
23
|
+
.register_column(:sizes, field: "size")
|
24
|
+
.register_column(:status, field: "status")
|
25
|
+
.register_custom_matcher(:running?) do |x|
|
26
|
+
x.where { status.downcase.start_with?("up") }
|
27
|
+
end
|
28
|
+
filter.install_filter_methods_on_resource(self, :containers)
|
29
|
+
|
30
|
+
attr_reader :containers
|
31
|
+
def initialize(containers)
|
32
|
+
@containers = containers
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
class DockerImageFilter
|
37
|
+
filter = FilterTable.create
|
38
|
+
filter.register_custom_matcher(:exists?) { |x| !x.entries.empty? }
|
39
|
+
filter.register_column(:ids, field: "id")
|
40
|
+
.register_column(:repositories, field: "repository")
|
41
|
+
.register_column(:tags, field: "tag")
|
42
|
+
.register_column(:sizes, field: "size")
|
43
|
+
.register_column(:digests, field: "digest")
|
44
|
+
.register_column(:created, field: "createdat")
|
45
|
+
.register_column(:created_since, field: "createdsize")
|
46
|
+
filter.install_filter_methods_on_resource(self, :images)
|
47
|
+
|
48
|
+
attr_reader :images
|
49
|
+
def initialize(images)
|
50
|
+
@images = images
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
54
|
+
class DockerPluginFilter
|
55
|
+
filter = FilterTable.create
|
56
|
+
filter.add(:ids, field: "id")
|
57
|
+
.add(:names, field: "name")
|
58
|
+
.add(:versions, field: "version")
|
59
|
+
.add(:enabled, field: "enabled")
|
60
|
+
filter.connect(self, :plugins)
|
61
|
+
|
62
|
+
attr_reader :plugins
|
63
|
+
def initialize(plugins)
|
64
|
+
@plugins = plugins
|
65
|
+
end
|
66
|
+
end
|
67
|
+
|
68
|
+
class DockerServiceFilter
|
69
|
+
filter = FilterTable.create
|
70
|
+
filter.register_custom_matcher(:exists?) { |x| !x.entries.empty? }
|
71
|
+
filter.register_column(:ids, field: "id")
|
72
|
+
.register_column(:names, field: "name")
|
73
|
+
.register_column(:modes, field: "mode")
|
74
|
+
.register_column(:replicas, field: "replicas")
|
75
|
+
.register_column(:images, field: "image")
|
76
|
+
.register_column(:ports, field: "ports")
|
77
|
+
filter.install_filter_methods_on_resource(self, :services)
|
78
|
+
|
79
|
+
attr_reader :services
|
80
|
+
def initialize(services)
|
81
|
+
@services = services
|
82
|
+
end
|
83
|
+
end
|
84
|
+
|
85
|
+
# This resource helps to parse information from the docker host
|
86
|
+
# For compatability with Serverspec we also offer the following resouses:
|
87
|
+
# - docker_container
|
88
|
+
# - docker_image
|
89
|
+
class Docker < Inspec.resource(1)
|
90
|
+
name "docker"
|
91
|
+
supports platform: "unix"
|
92
|
+
desc "
|
93
|
+
A resource to retrieve information about docker
|
94
|
+
"
|
95
|
+
|
96
|
+
example <<~EXAMPLE
|
97
|
+
describe docker.containers do
|
98
|
+
its('images') { should_not include 'u12:latest' }
|
99
|
+
end
|
100
|
+
|
101
|
+
describe docker.images do
|
102
|
+
its('repositories') { should_not include 'inssecure_image' }
|
103
|
+
end
|
104
|
+
|
105
|
+
describe docker.plugins.where { name == 'rexray/ebs' } do
|
106
|
+
it { should exist }
|
107
|
+
end
|
108
|
+
|
109
|
+
describe docker.services do
|
110
|
+
its('images') { should_not include 'inssecure_image' }
|
111
|
+
end
|
112
|
+
|
113
|
+
describe docker.version do
|
114
|
+
its('Server.Version') { should cmp >= '1.12'}
|
115
|
+
its('Client.Version') { should cmp >= '1.12'}
|
116
|
+
end
|
117
|
+
|
118
|
+
describe docker.object(id) do
|
119
|
+
its('Configuration.Path') { should eq 'value' }
|
120
|
+
end
|
121
|
+
|
122
|
+
docker.containers.ids.each do |id|
|
123
|
+
# call docker inspect for a specific container id
|
124
|
+
describe docker.object(id) do
|
125
|
+
its(%w(HostConfig Privileged)) { should cmp false }
|
126
|
+
its(%w(HostConfig Privileged)) { should_not cmp true }
|
127
|
+
end
|
128
|
+
end
|
129
|
+
EXAMPLE
|
130
|
+
|
131
|
+
def containers
|
132
|
+
DockerContainerFilter.new(parse_containers)
|
133
|
+
end
|
134
|
+
|
135
|
+
def images
|
136
|
+
DockerImageFilter.new(parse_images)
|
137
|
+
end
|
138
|
+
|
139
|
+
def plugins
|
140
|
+
DockerPluginFilter.new(parse_plugins)
|
141
|
+
end
|
142
|
+
|
143
|
+
def services
|
144
|
+
DockerServiceFilter.new(parse_services)
|
145
|
+
end
|
146
|
+
|
147
|
+
def version
|
148
|
+
return @version if defined?(@version)
|
149
|
+
|
150
|
+
data = {}
|
151
|
+
cmd = inspec.command("docker version --format '{{ json . }}'")
|
152
|
+
data = JSON.parse(cmd.stdout) if cmd.exit_status == 0
|
153
|
+
@version = Hashie::Mash.new(data)
|
154
|
+
rescue JSON::ParserError => _e
|
155
|
+
Hashie::Mash.new({})
|
156
|
+
end
|
157
|
+
|
158
|
+
def info
|
159
|
+
return @info if defined?(@info)
|
160
|
+
|
161
|
+
data = {}
|
162
|
+
# docke info format is only supported for Docker 17.03+
|
163
|
+
cmd = inspec.command("docker info --format '{{ json . }}'")
|
164
|
+
data = JSON.parse(cmd.stdout) if cmd.exit_status == 0
|
165
|
+
@info = Hashie::Mash.new(data)
|
166
|
+
rescue JSON::ParserError => _e
|
167
|
+
Hashie::Mash.new({})
|
168
|
+
end
|
169
|
+
|
170
|
+
# returns information about docker objects
|
171
|
+
def object(id)
|
172
|
+
return @inspect if defined?(@inspect)
|
173
|
+
|
174
|
+
data = JSON.parse(inspec.command("docker inspect #{id}").stdout)
|
175
|
+
data = data[0] if data.is_a?(Array)
|
176
|
+
@inspect = Hashie::Mash.new(data)
|
177
|
+
rescue JSON::ParserError => _e
|
178
|
+
Hashie::Mash.new({})
|
179
|
+
end
|
180
|
+
|
181
|
+
def to_s
|
182
|
+
"Docker Host"
|
183
|
+
end
|
184
|
+
|
185
|
+
private
|
186
|
+
|
187
|
+
def parse_json_command(labels, subcommand)
|
188
|
+
# build command
|
189
|
+
format = labels.map { |label| "\"#{label}\": {{json .#{label}}}" }
|
190
|
+
raw = inspec.command("docker #{subcommand} --format '{#{format.join(", ")}}'").stdout
|
191
|
+
output = []
|
192
|
+
# since docker is not outputting valid json, we need to parse each row
|
193
|
+
raw.each_line do |entry|
|
194
|
+
# convert all keys to lower_case to work well with ruby and filter table
|
195
|
+
row = JSON.parse(entry).map do |key, value|
|
196
|
+
[key.downcase, value]
|
197
|
+
end.to_h
|
198
|
+
|
199
|
+
# ensure all keys are there
|
200
|
+
row = ensure_keys(row, labels)
|
201
|
+
|
202
|
+
# strip off any linked container names
|
203
|
+
# Depending on how it was linked, the actual container name may come before
|
204
|
+
# or after the link information, so we'll just look for the first name that
|
205
|
+
# does not include a slash since that is not a valid character in a container name
|
206
|
+
if row["names"]
|
207
|
+
row["names"] = row["names"].split(",").find { |c| !c.include?("/") }
|
208
|
+
end
|
209
|
+
|
210
|
+
# Split labels on ',' or set to empty array
|
211
|
+
# Allows for `docker.containers.where { labels.include?('app=redis') }`
|
212
|
+
row["labels"] = row.key?("labels") ? row["labels"].split(",") : []
|
213
|
+
|
214
|
+
output.push(row)
|
215
|
+
end
|
216
|
+
|
217
|
+
output
|
218
|
+
rescue JSON::ParserError => _e
|
219
|
+
warn "Could not parse `docker #{subcommand}` output"
|
220
|
+
[]
|
221
|
+
end
|
222
|
+
|
223
|
+
def parse_containers
|
224
|
+
# @see https://github.com/moby/moby/issues/20625, works for docker 1.13+
|
225
|
+
# raw_containers = inspec.command('docker ps -a --no-trunc --format \'{{ json . }}\'').stdout
|
226
|
+
# therefore we stick with older approach
|
227
|
+
labels = %w{Command CreatedAt ID Image Labels Mounts Names Ports RunningFor Size Status}
|
228
|
+
|
229
|
+
# Networks LocalVolumes work with 1.13+ only
|
230
|
+
if !version.empty? && Gem::Version.new(version["Client"]["Version"]) >= Gem::Version.new("1.13")
|
231
|
+
labels.push("Networks")
|
232
|
+
labels.push("LocalVolumes")
|
233
|
+
end
|
234
|
+
parse_json_command(labels, "ps -a --no-trunc")
|
235
|
+
end
|
236
|
+
|
237
|
+
def parse_services
|
238
|
+
parse_json_command(%w{ID Name Mode Replicas Image Ports}, "service ls")
|
239
|
+
end
|
240
|
+
|
241
|
+
def ensure_keys(entry, labels)
|
242
|
+
labels.each do |key|
|
243
|
+
entry[key.downcase] = nil unless entry.key?(key.downcase)
|
244
|
+
end
|
245
|
+
entry
|
246
|
+
end
|
247
|
+
|
248
|
+
def parse_images
|
249
|
+
# docker does not support the `json .` function here, therefore we need to emulate that behavior.
|
250
|
+
raw_images = inspec.command('docker images -a --no-trunc --format \'{ "id": {{json .ID}}, "repository": {{json .Repository}}, "tag": {{json .Tag}}, "size": {{json .Size}}, "digest": {{json .Digest}}, "createdat": {{json .CreatedAt}}, "createdsize": {{json .CreatedSince}} }\'').stdout
|
251
|
+
c_images = []
|
252
|
+
raw_images.each_line do |entry|
|
253
|
+
c_images.push(JSON.parse(entry))
|
254
|
+
end
|
255
|
+
c_images
|
256
|
+
rescue JSON::ParserError => _e
|
257
|
+
warn "Could not parse `docker images` output"
|
258
|
+
[]
|
259
|
+
end
|
260
|
+
|
261
|
+
def parse_plugins
|
262
|
+
plugins = inspec.command('docker plugin ls --format \'{"id": {{json .ID}}, "name": "{{ with split .Name ":"}}{{index . 0}}{{end}}", "version": "{{ with split .Name ":"}}{{index . 1}}{{end}}", "enabled": {{json .Enabled}} }\'').stdout
|
263
|
+
c_plugins = []
|
264
|
+
plugins.each_line do |entry|
|
265
|
+
c_plugins.push(JSON.parse(entry))
|
266
|
+
end
|
267
|
+
c_plugins
|
268
|
+
rescue JSON::ParserError => _e
|
269
|
+
warn "Could not parse `docker plugin ls` output"
|
270
|
+
[]
|
271
|
+
end
|
272
|
+
end
|
@@ -0,0 +1,114 @@
|
|
1
|
+
#
|
2
|
+
# Copyright 2017, Christoph Hartmann
|
3
|
+
|
4
|
+
require "inspec-docker-resources/resources/docker"
|
5
|
+
require "inspec-docker-resources/resources/docker_object"
|
6
|
+
|
7
|
+
class DockerContainer < Inspec.resource(1)
|
8
|
+
include DockerObject
|
9
|
+
|
10
|
+
name "docker_container"
|
11
|
+
supports platform: "unix"
|
12
|
+
desc ""
|
13
|
+
example <<~EXAMPLE
|
14
|
+
describe docker_container('an-echo-server') do
|
15
|
+
it { should exist }
|
16
|
+
it { should be_running }
|
17
|
+
its('id') { should_not eq '' }
|
18
|
+
its('image') { should eq 'busybox:latest' }
|
19
|
+
its('repo') { should eq 'busybox' }
|
20
|
+
its('tag') { should eq 'latest' }
|
21
|
+
its('ports') { should eq [] }
|
22
|
+
its('command') { should eq 'nc -ll -p 1234 -e /bin/cat' }
|
23
|
+
its('labels') { should include 'app=example' }
|
24
|
+
end
|
25
|
+
|
26
|
+
describe docker_container(id: 'e2c52a183358') do
|
27
|
+
it { should exist }
|
28
|
+
it { should be_running }
|
29
|
+
end
|
30
|
+
EXAMPLE
|
31
|
+
|
32
|
+
def initialize(opts = {})
|
33
|
+
# if a string is provided, we expect it is the name
|
34
|
+
if opts.is_a?(String)
|
35
|
+
@opts = { name: opts }
|
36
|
+
else
|
37
|
+
@opts = opts
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
def running?
|
42
|
+
status.downcase.start_with?("up") if object_info.entries.length == 1
|
43
|
+
end
|
44
|
+
|
45
|
+
# has_volume? matcher checks if the volume specified in source path of host is mounted in destination path of docker
|
46
|
+
def has_volume?(destination, source)
|
47
|
+
# volume_info is the hash which contains the low-level information about the container
|
48
|
+
# if Mounts key is not present or is nil; raise exception
|
49
|
+
raise Inspec::Exceptions::ResourceFailed, "Could not find any mounted volumes for your container" unless volume_info.Mounts[0]
|
50
|
+
|
51
|
+
# Iterate through the list of mounted volumes and check if it matches with the given destination and source
|
52
|
+
# is_mounted flag is used to handle to return explict boolean values of true or false
|
53
|
+
is_mounted = false
|
54
|
+
volume_info.Mounts.detect { |mount| is_mounted = mount.Destination == destination && mount.Source == source }
|
55
|
+
is_mounted
|
56
|
+
end
|
57
|
+
|
58
|
+
def status
|
59
|
+
object_info.status[0] if object_info.entries.length == 1
|
60
|
+
end
|
61
|
+
|
62
|
+
def labels
|
63
|
+
object_info.labels
|
64
|
+
end
|
65
|
+
|
66
|
+
def ports
|
67
|
+
object_info.ports[0] if object_info.entries.length == 1
|
68
|
+
end
|
69
|
+
|
70
|
+
def command
|
71
|
+
return unless object_info.entries.length == 1
|
72
|
+
|
73
|
+
cmd = object_info.commands[0]
|
74
|
+
cmd.slice(1, cmd.length - 2)
|
75
|
+
end
|
76
|
+
|
77
|
+
def image
|
78
|
+
object_info.images[0] if object_info.entries.length == 1
|
79
|
+
end
|
80
|
+
|
81
|
+
def repo
|
82
|
+
parse_components_from_image(image)[:repo] if object_info.entries.size == 1
|
83
|
+
end
|
84
|
+
|
85
|
+
def tag
|
86
|
+
parse_components_from_image(image)[:tag] if object_info.entries.size == 1
|
87
|
+
end
|
88
|
+
|
89
|
+
def to_s
|
90
|
+
name = @opts[:name] || @opts[:id]
|
91
|
+
"Docker Container #{name}"
|
92
|
+
end
|
93
|
+
|
94
|
+
def resource_id
|
95
|
+
object_info.ids[0] || @opts[:id] || @opts[:name] || ""
|
96
|
+
end
|
97
|
+
|
98
|
+
private
|
99
|
+
|
100
|
+
def object_info
|
101
|
+
return @info if defined?(@info)
|
102
|
+
|
103
|
+
opts = @opts
|
104
|
+
@info = inspec.docker.containers.where { names == opts[:name] || (!id.nil? && !opts[:id].nil? && (id == opts[:id] || id.start_with?(opts[:id]))) }
|
105
|
+
end
|
106
|
+
|
107
|
+
# volume_info returns the low-level information obtained on docker inspect [container_name/id]
|
108
|
+
def volume_info
|
109
|
+
return @mount_info if defined?(@mount_info)
|
110
|
+
|
111
|
+
# Check for either docker inspect [container_name] or docker inspect [container_id]
|
112
|
+
@mount_info = inspec.docker.object(@opts[:name] || @opts[:id])
|
113
|
+
end
|
114
|
+
end
|
@@ -0,0 +1,139 @@
|
|
1
|
+
#
|
2
|
+
# Copyright 2017, Christoph Hartmann
|
3
|
+
|
4
|
+
require "inspec-docker-resources/resources/docker"
|
5
|
+
require "inspec-docker-resources/resources/docker_object"
|
6
|
+
|
7
|
+
class DockerImage < Inspec.resource(1)
|
8
|
+
include DockerObject
|
9
|
+
|
10
|
+
name "docker_image"
|
11
|
+
supports platform: "unix"
|
12
|
+
desc ""
|
13
|
+
example <<~EXAMPLE
|
14
|
+
describe docker_image('alpine:latest') do
|
15
|
+
it { should exist }
|
16
|
+
its('id') { should_not eq '' }
|
17
|
+
its('image') { should eq 'alpine:latest' }
|
18
|
+
its('repo') { should eq 'alpine' }
|
19
|
+
its('tag') { should eq 'latest' }
|
20
|
+
end
|
21
|
+
|
22
|
+
describe docker_image('alpine:latest') do
|
23
|
+
it { should exist }
|
24
|
+
end
|
25
|
+
|
26
|
+
describe docker_image(id: '4a415e366388') do
|
27
|
+
it { should exist }
|
28
|
+
end
|
29
|
+
EXAMPLE
|
30
|
+
|
31
|
+
def initialize(opts = {})
|
32
|
+
# do sanitizion of input values
|
33
|
+
o = opts.dup
|
34
|
+
o = { image: opts } if opts.is_a?(String)
|
35
|
+
@opts = sanitize_options(o)
|
36
|
+
end
|
37
|
+
|
38
|
+
def image
|
39
|
+
"#{repo}:#{tag}" if object_info.entries.size == 1
|
40
|
+
end
|
41
|
+
|
42
|
+
def repo
|
43
|
+
object_info.repositories[0] if object_info.entries.size == 1
|
44
|
+
end
|
45
|
+
|
46
|
+
def tag
|
47
|
+
object_info.tags[0] if object_info.entries.size == 1
|
48
|
+
end
|
49
|
+
|
50
|
+
# method_missing handles when hash_keys are invoked to check information obtained on docker inspect [image_name]
|
51
|
+
def method_missing(*hash_keys)
|
52
|
+
# User can test the low-level inspect information in three ways:
|
53
|
+
# Way 1: Serverspec style: its(['Config.Cmd']) { should include some_value }
|
54
|
+
# here, the value for hash_keys recieved is [:[], "Config.Cmd"]
|
55
|
+
# Way 2: InSpec style: its(['Config','Cmd']) { should include some_value }
|
56
|
+
# here, the value for hash_keys recieved is [:[], "Config", "Cmd"]
|
57
|
+
# Way 3: Mix of both: its(['GraphDriver.Data','MergedDir']) { should include some_value }
|
58
|
+
# here, the value for hash_keys recieved is [:[], "GraphDriver.Data", "MergedDir"]
|
59
|
+
|
60
|
+
# hash_keys are passed to this method to evaluate the value
|
61
|
+
image_hash_inspection(hash_keys)
|
62
|
+
end
|
63
|
+
|
64
|
+
# inspection property allows to test any of the hash key-value pairs as part of the image_inspect_info
|
65
|
+
def inspection
|
66
|
+
image_inspect_info
|
67
|
+
end
|
68
|
+
|
69
|
+
def to_s
|
70
|
+
img = @opts[:image] || @opts[:id]
|
71
|
+
"Docker Image #{img}"
|
72
|
+
end
|
73
|
+
|
74
|
+
def resource_id
|
75
|
+
object_info.ids[0] || @opts[:id] || @opts[:image] || ""
|
76
|
+
end
|
77
|
+
|
78
|
+
private
|
79
|
+
|
80
|
+
def sanitize_options(opts)
|
81
|
+
opts.merge!(parse_components_from_image(opts[:image]))
|
82
|
+
|
83
|
+
# assume a "latest" tag if we don't have one
|
84
|
+
opts[:tag] ||= "latest"
|
85
|
+
|
86
|
+
# if the ID isn't nil and doesn't contain a hash indicator (indicated by the presence
|
87
|
+
# of a colon, which separates the indicator from the actual hash), we assume it's sha256.
|
88
|
+
opts[:id] = "sha256:" + opts[:id] unless opts[:id].nil? || opts[:id].include?(":")
|
89
|
+
|
90
|
+
# Assemble/reassemble the image from the repo and tag
|
91
|
+
opts[:image] = "#{opts[:repo]}:#{opts[:tag]}" unless opts[:repo].nil?
|
92
|
+
|
93
|
+
# return the santized opts back to the caller
|
94
|
+
opts
|
95
|
+
end
|
96
|
+
|
97
|
+
def object_info
|
98
|
+
return @info if defined?(@info)
|
99
|
+
|
100
|
+
opts = @opts
|
101
|
+
@info = inspec.docker.images.where do
|
102
|
+
(repository == opts[:repo] && tag == opts[:tag]) || (!id.nil? && !opts[:id].nil? && (id == opts[:id] || id.start_with?(opts[:id])))
|
103
|
+
end
|
104
|
+
end
|
105
|
+
|
106
|
+
# image_inspect_info returns the complete inspect hash_values of the image
|
107
|
+
def image_inspect_info
|
108
|
+
return @inspect_info if defined?(@inspect_info)
|
109
|
+
|
110
|
+
@inspect_info = inspec.docker.object(@opts[:image] || (!@opts[:id].nil? && @opts[:id]))
|
111
|
+
end
|
112
|
+
|
113
|
+
# image_hash_inspection formats the input hash_keys and checks if any value exists for such keys in @inspect_info(image_inspect_info)
|
114
|
+
def image_hash_inspection(hash_keys)
|
115
|
+
# The hash_keys recieved are in three formats as mentioned in method_missing
|
116
|
+
# The hash_keys recieved must be in array format [] and the zeroth index must be :[]
|
117
|
+
# Check for the conditions and remove the zeroth element from the hash_keys
|
118
|
+
|
119
|
+
hash_keys.shift if hash_keys.is_a?(Array) && hash_keys[0] == :[]
|
120
|
+
|
121
|
+
# When received hash_keys in Serverspec style or mix of both
|
122
|
+
# The hash_keys are to be splitted at '.' (dot) and flatten it so that it doesn't become array of arrays
|
123
|
+
# After splitting and flattening is done, hash_keys is now an array with individual keys
|
124
|
+
hash_keys = hash_keys.map { |key| key.split(".") }.flatten
|
125
|
+
|
126
|
+
# image_inspect_info returns the complete inspect hash_values of the image
|
127
|
+
# dig() finds the nested value specified by the sequence of the key object by calling dig at each step.
|
128
|
+
# hash_keys is the key object. If one of the key is bad, value will be nil.
|
129
|
+
hash_value = image_inspect_info.dig(*hash_keys)
|
130
|
+
|
131
|
+
# If one of the key is bad, hash_value will be nil, so raise exception which throws it in rescue block
|
132
|
+
# else return hash_value
|
133
|
+
raise Inspec::Exceptions::ResourceFailed if hash_value.nil?
|
134
|
+
|
135
|
+
hash_value
|
136
|
+
rescue
|
137
|
+
raise Inspec::Exceptions::ResourceFailed, "#{hash_keys.join(".")} is not a valid key for your image or has nil value."
|
138
|
+
end
|
139
|
+
end
|
@@ -0,0 +1,52 @@
|
|
1
|
+
#
|
2
|
+
# Copyright 2017, Christoph Hartmann
|
3
|
+
#
|
4
|
+
|
5
|
+
module DockerObject
|
6
|
+
def exist?
|
7
|
+
object_info.exists?
|
8
|
+
end
|
9
|
+
|
10
|
+
def id
|
11
|
+
object_info.ids[0] if object_info.entries.size == 1
|
12
|
+
end
|
13
|
+
|
14
|
+
private
|
15
|
+
|
16
|
+
def parse_components_from_image(image_string)
|
17
|
+
# if the user did not supply an image string, they likely supplied individual
|
18
|
+
# option parameters, such as repo and tag. Return empty data back to the caller.
|
19
|
+
return {} if image_string.nil?
|
20
|
+
|
21
|
+
first_colon = image_string.index(":") || -1
|
22
|
+
first_slash = image_string.index("/") || -1
|
23
|
+
|
24
|
+
if image_string.count(":") == 2
|
25
|
+
# If there are two colons in the image string, it contains a repo-with-port and a tag.
|
26
|
+
# example: localhost:5000/chef/inspec:1.46.3
|
27
|
+
partitioned_string = image_string.rpartition(":")
|
28
|
+
repo = partitioned_string.first
|
29
|
+
tag = partitioned_string.last
|
30
|
+
image_name = repo.split("/")[1..-1].join
|
31
|
+
elsif image_string.count(":") == 1 && first_colon < first_slash
|
32
|
+
# If there's one colon in the image string, and it comes before a forward-slash,
|
33
|
+
# it contains a repo-with-port but no tag.
|
34
|
+
# example: localhost:5000/ubuntu
|
35
|
+
repo = image_string
|
36
|
+
tag = nil
|
37
|
+
image_name = repo.split("/")[1..-1].join
|
38
|
+
else
|
39
|
+
# If there's one colon in the image string and it doesn't preceed a slash, or if
|
40
|
+
# there is no colon at all, then it separates the repo from the tag, if there is a tag.
|
41
|
+
# example: chef/inspec:1.46.3
|
42
|
+
# example: chef/inspec
|
43
|
+
# example: ubuntu:14.04
|
44
|
+
repo, tag = image_string.split(":")
|
45
|
+
image_name = repo
|
46
|
+
end
|
47
|
+
|
48
|
+
# return the repo, image_name and tag parsed from the string, which can be merged into
|
49
|
+
# the rest of the user-supplied options
|
50
|
+
{ repo: repo, image_name: image_name, tag: tag }
|
51
|
+
end
|
52
|
+
end
|
@@ -0,0 +1,66 @@
|
|
1
|
+
require "inspec-docker-resources/resources/docker"
|
2
|
+
|
3
|
+
class DockerPlugin < Inspec.resource(1)
|
4
|
+
name "docker_plugin"
|
5
|
+
supports platform: "unix"
|
6
|
+
desc "Retrieves info about docker plugins"
|
7
|
+
example <<~EXAMPLE
|
8
|
+
describe docker_plugin('rexray/ebs') do
|
9
|
+
it { should exist }
|
10
|
+
its('id') { should_not eq '0ac30b93ad40' }
|
11
|
+
its('version') { should eq '0.11.1' }
|
12
|
+
it { should be_enabled }
|
13
|
+
end
|
14
|
+
|
15
|
+
describe docker_plugin('alpine:latest') do
|
16
|
+
it { should exist }
|
17
|
+
end
|
18
|
+
|
19
|
+
describe docker_plugin(id: '4a415e366388') do
|
20
|
+
it { should exist }
|
21
|
+
end
|
22
|
+
EXAMPLE
|
23
|
+
|
24
|
+
def initialize(opts = {})
|
25
|
+
# do sanitizion of input values
|
26
|
+
o = opts.dup
|
27
|
+
o = { name: opts } if opts.is_a?(String)
|
28
|
+
@opts = o
|
29
|
+
end
|
30
|
+
|
31
|
+
def exist?
|
32
|
+
object_info.entries.size == 1
|
33
|
+
end
|
34
|
+
|
35
|
+
def enabled?
|
36
|
+
object_info.enabled[0]
|
37
|
+
end
|
38
|
+
|
39
|
+
def id
|
40
|
+
object_info.ids[0] if object_info.entries.size == 1
|
41
|
+
end
|
42
|
+
|
43
|
+
def version
|
44
|
+
object_info.versions[0] if object_info.entries.size == 1
|
45
|
+
end
|
46
|
+
|
47
|
+
def to_s
|
48
|
+
plugin = @opts[:name] || @opts[:id]
|
49
|
+
"Docker plugin #{plugin}"
|
50
|
+
end
|
51
|
+
|
52
|
+
def resource_id
|
53
|
+
id || @opts[:id] || @opts[:name] || ""
|
54
|
+
end
|
55
|
+
|
56
|
+
private
|
57
|
+
|
58
|
+
def object_info
|
59
|
+
return @info if defined?(@info)
|
60
|
+
|
61
|
+
opts = @opts
|
62
|
+
@info = inspec.docker.plugins.where do
|
63
|
+
(name == opts[:name]) || (!id.nil? && !opts[:id].nil? && (id == opts[:id]))
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|
@@ -0,0 +1,93 @@
|
|
1
|
+
#
|
2
|
+
# Copyright 2017, Christoph Hartmann
|
3
|
+
|
4
|
+
require "inspec-docker-resources/resources/docker"
|
5
|
+
require "inspec-docker-resources/resources/docker_object"
|
6
|
+
|
7
|
+
class DockerService < Inspec.resource(1)
|
8
|
+
include DockerObject
|
9
|
+
|
10
|
+
name "docker_service"
|
11
|
+
supports platform: "unix"
|
12
|
+
desc "Swarm-mode service"
|
13
|
+
example <<~EXAMPLE
|
14
|
+
describe docker_service('service1') do
|
15
|
+
it { should exist }
|
16
|
+
its('id') { should_not eq '' }
|
17
|
+
its('image') { should eq 'alpine:latest' }
|
18
|
+
its('repo') { should eq 'alpine' }
|
19
|
+
its('tag') { should eq 'latest' }
|
20
|
+
end
|
21
|
+
|
22
|
+
describe docker_service(id: '4a415e366388') do
|
23
|
+
it { should exist }
|
24
|
+
end
|
25
|
+
|
26
|
+
describe docker_service(image: 'alpine:latest') do
|
27
|
+
it { should exist }
|
28
|
+
end
|
29
|
+
EXAMPLE
|
30
|
+
|
31
|
+
def initialize(opts = {})
|
32
|
+
# do sanitizion of input values
|
33
|
+
o = opts.dup
|
34
|
+
o = { name: opts } if opts.is_a?(String)
|
35
|
+
@opts = sanitize_options(o)
|
36
|
+
end
|
37
|
+
|
38
|
+
def name
|
39
|
+
object_info.names[0] if object_info.entries.size == 1
|
40
|
+
end
|
41
|
+
|
42
|
+
def image
|
43
|
+
object_info.images[0] if object_info.entries.size == 1
|
44
|
+
end
|
45
|
+
|
46
|
+
def image_name
|
47
|
+
parse_components_from_image(image)[:image_name] if object_info.entries.size == 1
|
48
|
+
end
|
49
|
+
|
50
|
+
def repo
|
51
|
+
parse_components_from_image(image)[:repo] if object_info.entries.size == 1
|
52
|
+
end
|
53
|
+
|
54
|
+
def tag
|
55
|
+
parse_components_from_image(image)[:tag] if object_info.entries.size == 1
|
56
|
+
end
|
57
|
+
|
58
|
+
def mode
|
59
|
+
object_info.modes[0] if object_info.entries.size == 1
|
60
|
+
end
|
61
|
+
|
62
|
+
def replicas
|
63
|
+
object_info.replicas[0] if object_info.entries.size == 1
|
64
|
+
end
|
65
|
+
|
66
|
+
def ports
|
67
|
+
object_info.ports[0] if object_info.entries.size == 1
|
68
|
+
end
|
69
|
+
|
70
|
+
def to_s
|
71
|
+
service = @opts[:name] || @opts[:id]
|
72
|
+
"Docker Service #{service}"
|
73
|
+
end
|
74
|
+
|
75
|
+
def resource_id
|
76
|
+
object_info.ids[0] || @opts[:id] || @opts[:name] || ""
|
77
|
+
end
|
78
|
+
|
79
|
+
private
|
80
|
+
|
81
|
+
def sanitize_options(opts)
|
82
|
+
opts.merge(parse_components_from_image(opts[:image]))
|
83
|
+
end
|
84
|
+
|
85
|
+
def object_info
|
86
|
+
return @info if defined?(@info)
|
87
|
+
|
88
|
+
opts = @opts
|
89
|
+
@info = inspec.docker.services.where do
|
90
|
+
name == opts[:name] || image == opts[:image] || (!id.nil? && !opts[:id].nil? && (id == opts[:id] || id.start_with?(opts[:id])))
|
91
|
+
end
|
92
|
+
end
|
93
|
+
end
|
@@ -0,0 +1,9 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
# This file simply makes it easier for CI engines to update
|
3
|
+
# the version stamp, and provide a clean way for the gemspec
|
4
|
+
# to learn the current version.
|
5
|
+
module InspecPlugins
|
6
|
+
module DockerResources
|
7
|
+
VERSION = "7.1.5"
|
8
|
+
end
|
9
|
+
end
|
@@ -0,0 +1,14 @@
|
|
1
|
+
# This file is known as the "entry point."
|
2
|
+
# This is the file InSpec will try to load if it
|
3
|
+
# thinks your plugin is installed.
|
4
|
+
|
5
|
+
# The *only* thing this file should do is setup the
|
6
|
+
# load path, then load the plugin definition file.
|
7
|
+
|
8
|
+
# Next two lines simply add the path of the gem to the load path.
|
9
|
+
# This is not needed when being loaded as a gem; but when doing
|
10
|
+
# plugin development, you may need it. Either way, it's harmless.
|
11
|
+
libdir = File.dirname(__FILE__)
|
12
|
+
$LOAD_PATH.unshift(libdir) unless $LOAD_PATH.include?(libdir)
|
13
|
+
|
14
|
+
require "inspec-docker-resources/plugin"
|
metadata
ADDED
@@ -0,0 +1,72 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: inspec-docker-resources
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 7.1.5
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- InSpec Core Maintainers
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2025-10-09 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: inspec-core
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - ">="
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: '7.0'
|
20
|
+
type: :runtime
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - ">="
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: '7.0'
|
27
|
+
description: Contains InSpec 7.0+ resources fo interacting with Docker Desktop.
|
28
|
+
email:
|
29
|
+
- inspec@progress.com
|
30
|
+
executables: []
|
31
|
+
extensions: []
|
32
|
+
extra_rdoc_files: []
|
33
|
+
files:
|
34
|
+
- Gemfile
|
35
|
+
- README.md
|
36
|
+
- inspec-docker-resources.gemspec
|
37
|
+
- inspec.yml
|
38
|
+
- lib/inspec-docker-resources.rb
|
39
|
+
- lib/inspec-docker-resources/plugin.rb
|
40
|
+
- lib/inspec-docker-resources/resource_pack.rb
|
41
|
+
- lib/inspec-docker-resources/resources/.gitkeep
|
42
|
+
- lib/inspec-docker-resources/resources/docker.rb
|
43
|
+
- lib/inspec-docker-resources/resources/docker_container.rb
|
44
|
+
- lib/inspec-docker-resources/resources/docker_image.rb
|
45
|
+
- lib/inspec-docker-resources/resources/docker_object.rb
|
46
|
+
- lib/inspec-docker-resources/resources/docker_plugin.rb
|
47
|
+
- lib/inspec-docker-resources/resources/docker_service.rb
|
48
|
+
- lib/inspec-docker-resources/version.rb
|
49
|
+
homepage: https://github.com/inspec/inspec-docker-resources
|
50
|
+
licenses:
|
51
|
+
- Apache-2.0
|
52
|
+
metadata: {}
|
53
|
+
post_install_message:
|
54
|
+
rdoc_options: []
|
55
|
+
require_paths:
|
56
|
+
- lib
|
57
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
58
|
+
requirements:
|
59
|
+
- - ">="
|
60
|
+
- !ruby/object:Gem::Version
|
61
|
+
version: 3.1.0
|
62
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
63
|
+
requirements:
|
64
|
+
- - ">="
|
65
|
+
- !ruby/object:Gem::Version
|
66
|
+
version: '0'
|
67
|
+
requirements: []
|
68
|
+
rubygems_version: 3.3.27
|
69
|
+
signing_key:
|
70
|
+
specification_version: 4
|
71
|
+
summary: Docker InSpec Resources in a Gem
|
72
|
+
test_files: []
|