deploy_doc 0.1.0
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/.gitignore +8 -0
- data/.travis.yml +5 -0
- data/CODE_OF_CONDUCT.md +49 -0
- data/Gemfile +4 -0
- data/LICENSE.txt +21 -0
- data/README.md +188 -0
- data/Rakefile +10 -0
- data/bin/console +14 -0
- data/deploy_doc.gemspec +25 -0
- data/doc/fig/architecture.dot +69 -0
- data/doc/fig/architecture.png +0 -0
- data/exe/deploy_doc +8 -0
- data/lib/deploy_doc/cli.rb +109 -0
- data/lib/deploy_doc/config.rb +7 -0
- data/lib/deploy_doc/docker.rb +36 -0
- data/lib/deploy_doc/error.rb +4 -0
- data/lib/deploy_doc/test_plan/annotator_parser.rb +120 -0
- data/lib/deploy_doc/test_plan.rb +99 -0
- data/lib/deploy_doc/version.rb +3 -0
- data/lib/deploy_doc.rb +13 -0
- data/runner/runner.rb +143 -0
- metadata +108 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA1:
|
3
|
+
metadata.gz: ad636b7c3d04d48cb1348f0a4a51144855b15e6c
|
4
|
+
data.tar.gz: 3e2d63ba1d5507ce621a87693ee2316908dfba93
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 31ddd23250904ebfed56bce18dc65402115ddcc7a0be2cc310e3bf30b9cb733797c47ff17ba243d59f0c7070bcb25754d6539641ddae684edccba085d96ba218
|
7
|
+
data.tar.gz: 912167c2b23c5d4fc57c557ec7f229e125c26fdff5a7d5bf50a43f97e96b33ff6bf1436b24aa603141941b6849f8489c03f17e6555d7bd7464f8ef66c8d8beac
|
data/.gitignore
ADDED
data/.travis.yml
ADDED
data/CODE_OF_CONDUCT.md
ADDED
@@ -0,0 +1,49 @@
|
|
1
|
+
# Contributor Code of Conduct
|
2
|
+
|
3
|
+
As contributors and maintainers of this project, and in the interest of
|
4
|
+
fostering an open and welcoming community, we pledge to respect all people who
|
5
|
+
contribute through reporting issues, posting feature requests, updating
|
6
|
+
documentation, submitting pull requests or patches, and other activities.
|
7
|
+
|
8
|
+
We are committed to making participation in this project a harassment-free
|
9
|
+
experience for everyone, regardless of level of experience, gender, gender
|
10
|
+
identity and expression, sexual orientation, disability, personal appearance,
|
11
|
+
body size, race, ethnicity, age, religion, or nationality.
|
12
|
+
|
13
|
+
Examples of unacceptable behavior by participants include:
|
14
|
+
|
15
|
+
* The use of sexualized language or imagery
|
16
|
+
* Personal attacks
|
17
|
+
* Trolling or insulting/derogatory comments
|
18
|
+
* Public or private harassment
|
19
|
+
* Publishing other's private information, such as physical or electronic
|
20
|
+
addresses, without explicit permission
|
21
|
+
* Other unethical or unprofessional conduct
|
22
|
+
|
23
|
+
Project maintainers have the right and responsibility to remove, edit, or
|
24
|
+
reject comments, commits, code, wiki edits, issues, and other contributions
|
25
|
+
that are not aligned to this Code of Conduct, or to ban temporarily or
|
26
|
+
permanently any contributor for other behaviors that they deem inappropriate,
|
27
|
+
threatening, offensive, or harmful.
|
28
|
+
|
29
|
+
By adopting this Code of Conduct, project maintainers commit themselves to
|
30
|
+
fairly and consistently applying these principles to every aspect of managing
|
31
|
+
this project. Project maintainers who do not follow or enforce the Code of
|
32
|
+
Conduct may be permanently removed from the project team.
|
33
|
+
|
34
|
+
This code of conduct applies both within project spaces and in public spaces
|
35
|
+
when an individual is representing the project or its community.
|
36
|
+
|
37
|
+
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
38
|
+
reported by contacting a project maintainer at maarten@moretea.nl. All
|
39
|
+
complaints will be reviewed and investigated and will result in a response that
|
40
|
+
is deemed necessary and appropriate to the circumstances. Maintainers are
|
41
|
+
obligated to maintain confidentiality with regard to the reporter of an
|
42
|
+
incident.
|
43
|
+
|
44
|
+
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
|
45
|
+
version 1.3.0, available at
|
46
|
+
[http://contributor-covenant.org/version/1/3/0/][version]
|
47
|
+
|
48
|
+
[homepage]: http://contributor-covenant.org
|
49
|
+
[version]: http://contributor-covenant.org/version/1/3/0/
|
data/Gemfile
ADDED
data/LICENSE.txt
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
The MIT License (MIT)
|
2
|
+
|
3
|
+
Copyright (c) 2016 Maarten Hoogendoorn
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
7
|
+
in the Software without restriction, including without limitation the rights
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
10
|
+
furnished to do so, subject to the following conditions:
|
11
|
+
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
13
|
+
all copies or substantial portions of the Software.
|
14
|
+
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,188 @@
|
|
1
|
+
# DeployDoc
|
2
|
+
|
3
|
+
## Synopsis
|
4
|
+
With DeployDoc, you can test the deployment of your microservice constellation to multiple
|
5
|
+
platforms, based on your end user documentation.
|
6
|
+
Usually, a project will have documented the required steps to deploy the microservices.
|
7
|
+
This documentation typically consists of a text with these steps embedded in it.
|
8
|
+
We realized that by adding some annotations, these steps could be parsed by a computer and
|
9
|
+
run in a CI pipeline. See the [syntax](#syntax) section below for more information about
|
10
|
+
how to write these Markdown files.
|
11
|
+
|
12
|
+
## Architecture
|
13
|
+
- As a user, you must provide a **Markdown file** that contains the steps to set up the
|
14
|
+
infrastructure your app requires, deploy the app to this infrastructure, and finally destroy the
|
15
|
+
created infrastructure.
|
16
|
+
- Optionally, you can provide an alternative **Docker Image**. It defaults to the `ruby` image.
|
17
|
+
- The **`deploy_doc`** tool parses, extracts and converts the Markdown file into a JSON file which
|
18
|
+
contains all the steps that need to be run. (See the [syntax](#syntax) section below on
|
19
|
+
how to write these Markdown files).
|
20
|
+
- The `deploy_doc` tool creates a docker container, and mounts (a) the JSON file containing the
|
21
|
+
steps that need to be run, (b) the runner script that interprets the JSON file and (c) the
|
22
|
+
data dir to `/deploy_doc/data`. The runner returns an appropriate status code to communicate
|
23
|
+
the result of the test run to the CI environment (see the [usage](#usage) section below).
|
24
|
+
- Based on the value of the test output, the CI environment can generate and publish the
|
25
|
+
human readable and verified version version of the documentation.
|
26
|
+
|
27
|
+
|
28
|
+
<img src="doc/fig/architecture.png">
|
29
|
+
|
30
|
+
## Usage
|
31
|
+
Install the `deploy_doc` gem, see the [installation instructions](#installation) below.
|
32
|
+
|
33
|
+
```
|
34
|
+
USAGE: deploy_doc <markdown_file> [options]
|
35
|
+
-h, --help Print help
|
36
|
+
-r, --run-test Run tests
|
37
|
+
-j, --dump-json Just parse the Markdown file and dump the steps to JSON
|
38
|
+
-c, --shell-in-container Start a (temporary) docker container with the same environment as the tests will run in
|
39
|
+
-i, --docker-image=IMG_NAME Use this docker image instead of the default 'ruby:2.3'
|
40
|
+
-d, --data-dir=DIR Set the data directory, instead of the default '.'
|
41
|
+
|
42
|
+
EXIT STATUS:
|
43
|
+
If all test passed, and the infrastructure has been cleaned up, 0 is returned.
|
44
|
+
If something went wrong, but there the infrastructure has been destroyed correctly,
|
45
|
+
a 1 error status is returned, but if the destruction of the infrastructure did not complete,
|
46
|
+
the process returns an error status 2. These cases will probably require manual cleanup steps.
|
47
|
+
Finally, a error status 3 is returned if the program is aborted. We cannot give any guarantees in this case!
|
48
|
+
```
|
49
|
+
|
50
|
+
NOTE: If you use a custom docker image, be sure that it contains a Ruby implementation with version >= 2.1
|
51
|
+
|
52
|
+
## Phases in a DeployDoc
|
53
|
+
The test runner has four phases:
|
54
|
+
|
55
|
+
- `pre-install`, in which required additional software should be installed, such as Terraform.
|
56
|
+
If this phase fails, the tests just aborts, without attempting to destroy the infrastructure.
|
57
|
+
*NOTE*: This implies that it is very important to also install any the tool that is required
|
58
|
+
to tear down the infrastructure.
|
59
|
+
- `create-infrastructure`, in which the cloud resources are created. If this phase fails,
|
60
|
+
the tests are skipped, but the `destroy-infrastructure` phase is executed.
|
61
|
+
- `run-tests`, which should contain instructions that validate that the infrastructure is up and
|
62
|
+
running and that the microservices are deployed correctly.
|
63
|
+
- `destroy-infrastructure`, which must tear down the infrastructure.
|
64
|
+
|
65
|
+
### Secrets phase
|
66
|
+
There is an additional `require-env` phase, that can be used to pass in environmental variables to
|
67
|
+
the steps. If they are not present, testing the documentation will result in an error message that
|
68
|
+
these variables are not present.
|
69
|
+
|
70
|
+
|
71
|
+
## Syntax
|
72
|
+
The expected syntax is compatible with Jekyll.
|
73
|
+
We expect the Markdown file to start with a YAML section, enclosed in a `---` delimiter.
|
74
|
+
|
75
|
+
### Defining phases and steps
|
76
|
+
Multiple shell snippets (steps) can be added to a phase.
|
77
|
+
The phases are run in the order give in the previous section, after which each snippet is executed
|
78
|
+
in the order of declaration in the file.
|
79
|
+
|
80
|
+
The following syntaxes are supported to add snippets:
|
81
|
+
|
82
|
+
1. Single line annotation, not visible in Markdown output
|
83
|
+
|
84
|
+
<!-- deploy-doc PHASE [VALUE]* -->
|
85
|
+
|
86
|
+
2. Hidden multi-line annotation
|
87
|
+
|
88
|
+
<!-- deploy-doc-start PHASE [VALUE]*
|
89
|
+
CONTENT
|
90
|
+
-->
|
91
|
+
|
92
|
+
3. Visible multi-line annotation
|
93
|
+
|
94
|
+
<!-- deploy-doc-start PHASE [VALUE]* -->
|
95
|
+
CONTENT
|
96
|
+
<!-- deploy-doc-end -->
|
97
|
+
|
98
|
+
Note that due to current technical limitations of the test runner, each step is executed in a
|
99
|
+
separate shell. This implies that *setting* an environmental variable in one step, will not be
|
100
|
+
available in a next step. A work-around is to load/store this information in files.
|
101
|
+
The externally required environmental variables to store secrets are available in all steps.
|
102
|
+
|
103
|
+
|
104
|
+
### Example DeployDoc
|
105
|
+
|
106
|
+
```
|
107
|
+
---
|
108
|
+
deployDoc: true
|
109
|
+
---
|
110
|
+
# Hello World
|
111
|
+
<!-- deploy-doc require-env ENV_A ENV_B -->
|
112
|
+
|
113
|
+
<!-- deploy-doc-start pre-install -->
|
114
|
+
|
115
|
+
apt-get update
|
116
|
+
apt-get install -yq jq terraform
|
117
|
+
|
118
|
+
<!-- deploy-doc-end -->
|
119
|
+
|
120
|
+
<!-- deploy-doc-hidden pre-install
|
121
|
+
|
122
|
+
curl https://nixos.org/nix/install | sh
|
123
|
+
nix-env -i nixops
|
124
|
+
|
125
|
+
-->
|
126
|
+
|
127
|
+
<!-- deploy-doc-start create-infrastructure -->
|
128
|
+
|
129
|
+
# This both creates the infrastructure, and
|
130
|
+
# deploys a distributed application to it.
|
131
|
+
nixops create -d test_deployment ./deployment.nix
|
132
|
+
nixops deploy -d test_deployment
|
133
|
+
|
134
|
+
<!-- deploy-doc-end -->
|
135
|
+
|
136
|
+
<!-- deploy-doc-start run-tests -->
|
137
|
+
|
138
|
+
# Get the public endpoint
|
139
|
+
WEBAPP_HOST=`nixops export -d test_deployment | jq ' .[].resources["load-balancer"].publicIpv4' -r`
|
140
|
+
|
141
|
+
# Test that the webapp is up.
|
142
|
+
curl $WEBAPP_HOST
|
143
|
+
|
144
|
+
<!-- deploy-doc-end -->
|
145
|
+
|
146
|
+
<!-- deploy-doc-start destroy-infrastructure -->
|
147
|
+
|
148
|
+
nixops destroy -d test_deployment
|
149
|
+
|
150
|
+
<!-- deploy-doc-end -->
|
151
|
+
```
|
152
|
+
|
153
|
+
## Installation
|
154
|
+
|
155
|
+
Add this line to your application's Gemfile:
|
156
|
+
|
157
|
+
```ruby
|
158
|
+
gem 'deploy_doc'
|
159
|
+
```
|
160
|
+
|
161
|
+
And then execute:
|
162
|
+
|
163
|
+
$ bundle
|
164
|
+
|
165
|
+
Or install it yourself as:
|
166
|
+
|
167
|
+
$ gem install deploy_doc
|
168
|
+
|
169
|
+
## Development of this gem
|
170
|
+
|
171
|
+
After checking out the repo, run `bundle install` to install dependencies.
|
172
|
+
Then, run `rake test` to run the tests.
|
173
|
+
You can also run `bin/console` for an interactive prompt that will allow you to experiment.
|
174
|
+
|
175
|
+
To install this gem onto your local machine, run `bundle exec rake install`.
|
176
|
+
To release a new version, update the version number in `version.rb`, and then run
|
177
|
+
`bundle exec rake release`, which will create a git tag for the version, push git commits and tags,
|
178
|
+
and push the `.gem` file to [rubygems.org](https://rubygems.org).
|
179
|
+
|
180
|
+
## Contributing
|
181
|
+
|
182
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/microservices-demo/deploy_doc.
|
183
|
+
This project is intended to be a safe, welcoming space for collaboration, and contributors are
|
184
|
+
expected to adhere to the [Contributor Covenant](http://contributor-covenant.org) code of conduct.
|
185
|
+
|
186
|
+
## License
|
187
|
+
|
188
|
+
The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT).
|
data/Rakefile
ADDED
data/bin/console
ADDED
@@ -0,0 +1,14 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
require "bundler/setup"
|
4
|
+
require "deploy_doc"
|
5
|
+
|
6
|
+
# You can add fixtures and/or initialization code here to make experimenting
|
7
|
+
# with your gem easier. You can also use a different console, if you like.
|
8
|
+
|
9
|
+
# (If you use this, don't forget to add pry to your Gemfile!)
|
10
|
+
# require "pry"
|
11
|
+
# Pry.start
|
12
|
+
|
13
|
+
require "irb"
|
14
|
+
IRB.start
|
data/deploy_doc.gemspec
ADDED
@@ -0,0 +1,25 @@
|
|
1
|
+
# coding: utf-8
|
2
|
+
lib = File.expand_path('../lib', __FILE__)
|
3
|
+
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
4
|
+
require 'deploy_doc/version'
|
5
|
+
|
6
|
+
Gem::Specification.new do |spec|
|
7
|
+
spec.name = "deploy_doc"
|
8
|
+
spec.version = DeployDoc::VERSION
|
9
|
+
spec.authors = ["Maarten Hoogendoorn"]
|
10
|
+
spec.email = ["maarten@moretea.nl"]
|
11
|
+
|
12
|
+
spec.summary = %q{Test system for your deploment documentation for your application}
|
13
|
+
spec.description = %q{Using DeployDoc's, you can make your documentation executable}
|
14
|
+
spec.homepage = "https://github.com/microservices-demo/deploy_doc"
|
15
|
+
spec.license = "MIT"
|
16
|
+
|
17
|
+
spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
|
18
|
+
spec.bindir = "exe"
|
19
|
+
spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
|
20
|
+
spec.require_paths = ["lib"]
|
21
|
+
|
22
|
+
spec.add_development_dependency "bundler", "~> 1.12"
|
23
|
+
spec.add_development_dependency "rake", "~> 10.0"
|
24
|
+
spec.add_development_dependency "minitest", "~> 5.0"
|
25
|
+
end
|
@@ -0,0 +1,69 @@
|
|
1
|
+
digraph deploy_doc_architecture {
|
2
|
+
edge[minlen=1.8];
|
3
|
+
subgraph cluster_user_provided {
|
4
|
+
label= "User provided";
|
5
|
+
fontsize=10
|
6
|
+
node[shape="note"];
|
7
|
+
base_image[label="Docker Base Image"];
|
8
|
+
deploy_doc[label="documentation.md",fontname="mono"];
|
9
|
+
};
|
10
|
+
|
11
|
+
subgraph cluster_doc {
|
12
|
+
label="E.g. Yekyll";
|
13
|
+
labeljust="r";
|
14
|
+
fontsize=10;
|
15
|
+
|
16
|
+
generate_doc[label="Generate\ndocumentation"];
|
17
|
+
}
|
18
|
+
|
19
|
+
|
20
|
+
subgraph cluster_deploydoc {
|
21
|
+
label = "DeployDoc";
|
22
|
+
labeljust="l";
|
23
|
+
fontsize=10
|
24
|
+
script_generator[label="deploy_doc",fontname="mono"];
|
25
|
+
generated_script[label="Test steps\n(JSON file)", shape=note];
|
26
|
+
}
|
27
|
+
|
28
|
+
subgraph cluster_container{
|
29
|
+
label="Within container";
|
30
|
+
labeljust="l";
|
31
|
+
fontsize=10;
|
32
|
+
|
33
|
+
run_kubectl[label="Deploy to k8s\nwith kubectl"];
|
34
|
+
run_terraform[label="Terraform\ninfrastructure"];
|
35
|
+
run_test_script[shape="circle", label="Run tests"]
|
36
|
+
run_test_script -> run_tests[dir=both];
|
37
|
+
run_test_script -> run_terraform [dir=both];
|
38
|
+
run_test_script -> run_kubectl[dir=both];
|
39
|
+
|
40
|
+
run_tests[label="Run sanity\ntests"];
|
41
|
+
}
|
42
|
+
|
43
|
+
|
44
|
+
script_generator -> generated_script;
|
45
|
+
generated_script -> run_test_script;
|
46
|
+
|
47
|
+
deploy_doc -> script_generator;
|
48
|
+
base_image -> run_test_script;
|
49
|
+
deploy_doc -> generate_doc;
|
50
|
+
|
51
|
+
doc_output[shape="note",label="Documentation"];
|
52
|
+
subgraph cluster_outputs {
|
53
|
+
style=invis;
|
54
|
+
node[shape=note];
|
55
|
+
test_output[label="Test output"];
|
56
|
+
|
57
|
+
is_test_ok[shape="diamond", label="Test passed?"];
|
58
|
+
test_output->is_test_ok;
|
59
|
+
is_test_ok -> test_bad [label="No"];
|
60
|
+
is_test_ok -> publish_doc[label="yes"];
|
61
|
+
test_bad[shape="rect", label="Fail CI build"];
|
62
|
+
publish_doc[shape="default", label="Publish correct docs"];
|
63
|
+
}
|
64
|
+
|
65
|
+
doc_output -> publish_doc;
|
66
|
+
|
67
|
+
run_test_script -> test_output;
|
68
|
+
generate_doc -> doc_output;
|
69
|
+
}
|
Binary file
|
data/exe/deploy_doc
ADDED
@@ -0,0 +1,109 @@
|
|
1
|
+
module DeployDoc
|
2
|
+
module CLI
|
3
|
+
def self.run!(arguments)
|
4
|
+
configuration = parse_arguments(arguments)
|
5
|
+
|
6
|
+
test_plan = TestPlan.from_file(configuration.markdown_file)
|
7
|
+
|
8
|
+
case configuration.action
|
9
|
+
when :run then self.do_run(test_plan, configuration)
|
10
|
+
when :dump_json then self.dump_json(test_plan, configuration)
|
11
|
+
when :just_docker_env then self.just_docker_env(test_plan, configuration)
|
12
|
+
else raise RuntimeError.new("No such action '#{configuration.action}'")
|
13
|
+
end
|
14
|
+
rescue DeployDoc::Error => e
|
15
|
+
puts "Runtime error: #{e.message}"
|
16
|
+
exit 1
|
17
|
+
end
|
18
|
+
|
19
|
+
def self.parse_arguments(arguments)
|
20
|
+
configuration = Config.defaults
|
21
|
+
|
22
|
+
opt_parser = OptionParser.new do |opts|
|
23
|
+
opts.banner = "USAGE: deploy_doc <markdown_file> [options]"
|
24
|
+
|
25
|
+
opts.on("-h", "--help", "Print help") do
|
26
|
+
puts opts
|
27
|
+
puts
|
28
|
+
puts "EXIT STATUS:"
|
29
|
+
puts " If all test passed, and the infrastructure has been cleaned up, 0 is returned."
|
30
|
+
puts " If something went wrong, but there the infrastructure has been destroyed correctly,"
|
31
|
+
puts " a 1 error status is returned, but if the destruction of the infrastructure did not complete,"
|
32
|
+
puts " the process returns an error status 2. These cases will probably require manual cleanup steps."
|
33
|
+
puts " Finally, a error status 3 is returned if the program is aborted. We cannot give any guarrantees in this case!"
|
34
|
+
exit
|
35
|
+
end
|
36
|
+
|
37
|
+
opts.on("-r", "--run-test", "Run tests") do
|
38
|
+
configuration.action = :run
|
39
|
+
end
|
40
|
+
|
41
|
+
opts.on("-j", "--dump-json", "Just parse the Markdown file and dump the steps to JSON") do
|
42
|
+
configuration.action = :dump_json
|
43
|
+
end
|
44
|
+
|
45
|
+
opts.on("-c", "--shell-in-container", "Start a (temporary) docker container with the same environment as the tests will run in") do
|
46
|
+
configuration.action = :just_docker_env
|
47
|
+
end
|
48
|
+
|
49
|
+
opts.on("-iIMG_NAME", "--docker-image=IMG_NAME", "Use this docker image instead of the default '#{configuration.docker_image}'") do |image|
|
50
|
+
configuration.docker_image = image
|
51
|
+
end
|
52
|
+
|
53
|
+
opts.on("-dDIR", "--data-dir=DIR", "Set the data directory, instead of the default '#{configuration.data_dir}'")do |data_dir|
|
54
|
+
configuration.data_dir = data_dir
|
55
|
+
end
|
56
|
+
end
|
57
|
+
opt_parser.parse!(arguments)
|
58
|
+
configuration.markdown_file = arguments.shift
|
59
|
+
|
60
|
+
if configuration.markdown_file.nil?
|
61
|
+
raise DeployDoc::Error.new("No markdown file provided! Run `deploy_doc -h' to learn more about how to use this tool.")
|
62
|
+
end
|
63
|
+
|
64
|
+
if configuration.action.nil?
|
65
|
+
raise DeployDoc::Error.new("No action given. Either --run-tests, --dump-json or --shell-in-container must be specified")
|
66
|
+
end
|
67
|
+
|
68
|
+
configuration
|
69
|
+
end
|
70
|
+
|
71
|
+
def self.do_run(test_plan, configuration)
|
72
|
+
status = nil
|
73
|
+
|
74
|
+
test_instructions_file = Tempfile.new("deploydoc", "/tmp")
|
75
|
+
test_instructions_file.write test_plan.to_json
|
76
|
+
test_instructions_file.close
|
77
|
+
runner_path = File.expand_path("../../../runner/runner.rb", __FILE__)
|
78
|
+
extra_opts = [
|
79
|
+
"-v#{test_instructions_file.path}:/deploy_doc/steps.json",
|
80
|
+
"-v#{runner_path}:/deploy_doc/runner",
|
81
|
+
]
|
82
|
+
system Docker.cmd(configuration, check_and_find_envs(test_plan), "ruby /deploy_doc/runner /deploy_doc/steps.json", extra_opts)
|
83
|
+
status = $?
|
84
|
+
test_instructions_file.close
|
85
|
+
test_instructions_file.unlink
|
86
|
+
exit status.exitstatus
|
87
|
+
end
|
88
|
+
|
89
|
+
def self.dump_json(test_plan, configuration)
|
90
|
+
puts test_plan.to_json
|
91
|
+
end
|
92
|
+
|
93
|
+
def self.just_docker_env(test_plan, configuration)
|
94
|
+
Kernel.exec Docker.cmd(configuration, check_and_find_envs(test_plan), "sh", ["-ti"])
|
95
|
+
end
|
96
|
+
|
97
|
+
def self.check_and_find_envs(test_plan)
|
98
|
+
test_plan.required_env_vars.map do |name|
|
99
|
+
val = ENV[name]
|
100
|
+
if val.nil?
|
101
|
+
$stderr.puts "Required environmental variable #{name} is not set"
|
102
|
+
exit 1
|
103
|
+
else
|
104
|
+
"-e#{name}=#{val}"
|
105
|
+
end
|
106
|
+
end.join(" ")
|
107
|
+
end
|
108
|
+
end
|
109
|
+
end
|
@@ -0,0 +1,36 @@
|
|
1
|
+
module DeployDoc
|
2
|
+
module Docker
|
3
|
+
def self.cmd(configuration, envs, cmd, extra_opts = [])
|
4
|
+
data_dir = if configuration.data_dir == "."
|
5
|
+
Dir.pwd
|
6
|
+
else
|
7
|
+
configuration.data_dir
|
8
|
+
end
|
9
|
+
|
10
|
+
docker_cmd = [
|
11
|
+
"docker",
|
12
|
+
"run",
|
13
|
+
"-it",
|
14
|
+
"--rm",
|
15
|
+
envs,
|
16
|
+
"-v#{data_dir}:/deploy_doc/data/",
|
17
|
+
"-w/deploy_doc/data/"
|
18
|
+
]
|
19
|
+
|
20
|
+
# Expose the host host docker daemon in the child docker container.
|
21
|
+
docker_socket_protocol, docker_socket_address = configuration.docker_socket.split("://",2)
|
22
|
+
case docker_socket_protocol
|
23
|
+
when "unix"
|
24
|
+
docker_cmd.push "-v#{docker_socket_address}:/var/run/docker.sock"
|
25
|
+
when "tcp"
|
26
|
+
docker_cmd.push "-e DOCKER_HOST='#{configuration.docker_socket}'"
|
27
|
+
else
|
28
|
+
raise DeployDocError.new("Unkown docker socket protocol '#{docker_socket_protocol}'")
|
29
|
+
end
|
30
|
+
|
31
|
+
docker_cmd += extra_opts
|
32
|
+
docker_cmd += [configuration.docker_image, cmd]
|
33
|
+
docker_cmd.join(" ")
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
@@ -0,0 +1,120 @@
|
|
1
|
+
module DeployDoc
|
2
|
+
class TestPlan
|
3
|
+
class AnnotationParser
|
4
|
+
Annotation = Struct.new(:source_name, :line_span, :kind, :params, :content)
|
5
|
+
|
6
|
+
def self.parse(markdown, path="<unknown>")
|
7
|
+
AnnotationParser.new(markdown, path).parse!
|
8
|
+
end
|
9
|
+
|
10
|
+
def initialize(markdown, source_name="<unknown>")
|
11
|
+
@source_name = source_name
|
12
|
+
@markdown_lines = markdown.split("\n")
|
13
|
+
end
|
14
|
+
|
15
|
+
def parse!
|
16
|
+
@annotations = []
|
17
|
+
@parse_idx = 0
|
18
|
+
|
19
|
+
while !eof_reached?
|
20
|
+
parse_block || parse_inline || parse_single_line || parse_text
|
21
|
+
end
|
22
|
+
|
23
|
+
@annotations
|
24
|
+
end
|
25
|
+
|
26
|
+
private
|
27
|
+
|
28
|
+
def parse_text
|
29
|
+
inc_line # ignore text, line by line
|
30
|
+
end
|
31
|
+
|
32
|
+
BLOCK_START = /^<!-- deploy-doc-start (?<kind>[-\w]+)( )?(?<params>.*) -->/
|
33
|
+
BLOCK_END = /^<!-- deploy-doc-end -->/
|
34
|
+
|
35
|
+
INLINE_START = /^<!-- deploy-doc-hidden (?<kind>[-\w]+)( )?(?<params>.*)/
|
36
|
+
INLINE_END = /^-->/
|
37
|
+
|
38
|
+
SINGLE_LINE = /^<!-- deploy-doc (?<kind>[-\w]+)( )?(?<params>.*) -->/
|
39
|
+
|
40
|
+
def parse_block
|
41
|
+
if (match = BLOCK_START.match(current_line)).nil?
|
42
|
+
false
|
43
|
+
else
|
44
|
+
inc_line
|
45
|
+
start_line = current_line_nr
|
46
|
+
kind = match["kind"]
|
47
|
+
params = match["params"].split(/\s+/)
|
48
|
+
content = ""
|
49
|
+
loop do
|
50
|
+
if eof_reached?
|
51
|
+
raise("Unexpected end of file; --> not closed? started on #{@source_name}:#{start_line}")
|
52
|
+
elsif !(BLOCK_END.match(current_line).nil?)
|
53
|
+
end_line = current_line_nr() -1
|
54
|
+
@annotations << Annotation.new(@source_name, [start_line, end_line], kind, params, content)
|
55
|
+
return true
|
56
|
+
else
|
57
|
+
content += current_line + "\n"
|
58
|
+
end
|
59
|
+
inc_line
|
60
|
+
end
|
61
|
+
true
|
62
|
+
end
|
63
|
+
end
|
64
|
+
|
65
|
+
def parse_inline
|
66
|
+
if (match = INLINE_START.match(current_line)).nil?
|
67
|
+
false
|
68
|
+
else
|
69
|
+
inc_line
|
70
|
+
start_line = current_line_nr
|
71
|
+
kind = match["kind"]
|
72
|
+
params = match["params"].split(/\s+/)
|
73
|
+
content = ""
|
74
|
+
loop do
|
75
|
+
if eof_reached?
|
76
|
+
raise("Unexpected end of file; --> not closed? started on #{@source_name}:#{start_line}")
|
77
|
+
elsif !(INLINE_END.match(current_line).nil?)
|
78
|
+
end_line = current_line_nr() -1
|
79
|
+
@annotations << Annotation.new(@source_name, [start_line, end_line], kind, params, content)
|
80
|
+
return true
|
81
|
+
else
|
82
|
+
content += current_line + "\n"
|
83
|
+
end
|
84
|
+
inc_line
|
85
|
+
end
|
86
|
+
true
|
87
|
+
end
|
88
|
+
end
|
89
|
+
|
90
|
+
def parse_single_line
|
91
|
+
if (match = SINGLE_LINE.match(current_line)).nil?
|
92
|
+
false
|
93
|
+
else
|
94
|
+
kind = match["kind"]
|
95
|
+
params = match["params"].split(/\s+/)
|
96
|
+
@annotations << Annotation.new(@source_name, [current_line_nr, current_line_nr], kind, params, nil)
|
97
|
+
inc_line
|
98
|
+
end
|
99
|
+
end
|
100
|
+
|
101
|
+
##### Helper functions ####
|
102
|
+
|
103
|
+
def eof_reached?
|
104
|
+
@parse_idx >= @markdown_lines.length
|
105
|
+
end
|
106
|
+
|
107
|
+
def current_line
|
108
|
+
@markdown_lines[@parse_idx]
|
109
|
+
end
|
110
|
+
|
111
|
+
def current_line_nr
|
112
|
+
@parse_idx + 1
|
113
|
+
end
|
114
|
+
|
115
|
+
def inc_line
|
116
|
+
@parse_idx+=1
|
117
|
+
end
|
118
|
+
end
|
119
|
+
end
|
120
|
+
end
|
@@ -0,0 +1,99 @@
|
|
1
|
+
module DeployDoc
|
2
|
+
class TestPlan
|
3
|
+
require_relative "test_plan/annotator_parser"
|
4
|
+
|
5
|
+
PHASES = ["pre-install", "create-infrastructure", "run-tests", "destroy-infrastructure"]
|
6
|
+
|
7
|
+
class Step < Struct.new(:source_name, :line_span, :shell)
|
8
|
+
def full_name
|
9
|
+
"#{source_name}:#{line_span.inspect}"
|
10
|
+
end
|
11
|
+
end
|
12
|
+
|
13
|
+
def self.from_file(file_name)
|
14
|
+
content = File.read(file_name)
|
15
|
+
self.from_str(content, file_name)
|
16
|
+
end
|
17
|
+
|
18
|
+
def self.from_str(content, file_name="<unknown file>")
|
19
|
+
metadata = self.parse_metadata(content, file_name)
|
20
|
+
|
21
|
+
if metadata["deployDoc"] != true
|
22
|
+
raise DeployDoc::Error.new("Markdown file #{file_name} does not have a 'deployDoc:true' metadatum")
|
23
|
+
end
|
24
|
+
|
25
|
+
annotations = AnnotationParser.parse(content, file_name)
|
26
|
+
required_env_vars = (annotations.select { |a| a.kind == "require-env" }).map { |a| a.params }.flatten
|
27
|
+
phases = self.phases_from_annotations(annotations)
|
28
|
+
TestPlan.new(metadata, required_env_vars, phases)
|
29
|
+
end
|
30
|
+
|
31
|
+
def self.parse_metadata(content, file_name)
|
32
|
+
metadata = content.split("---",3)[1]
|
33
|
+
YAML.load(metadata)
|
34
|
+
rescue
|
35
|
+
raise Error.new("Could not parse metadata in #{file_name}")
|
36
|
+
end
|
37
|
+
|
38
|
+
def self.phases_from_annotations(annotations)
|
39
|
+
phases = {}
|
40
|
+
|
41
|
+
PHASES.each do |phase|
|
42
|
+
phases[phase] = (annotations.select { |a| a.kind == phase }).map { |a| Step.new(a.source_name, a.line_span, a.content) }
|
43
|
+
end
|
44
|
+
|
45
|
+
phases
|
46
|
+
end
|
47
|
+
|
48
|
+
attr_reader :metadata
|
49
|
+
attr_reader :required_env_vars
|
50
|
+
attr_reader :steps_in_phases
|
51
|
+
|
52
|
+
def initialize(metadata, required_env_vars, steps_in_phases)
|
53
|
+
@metadata = metadata
|
54
|
+
@required_env_vars = required_env_vars
|
55
|
+
@steps_in_phases = steps_in_phases
|
56
|
+
end
|
57
|
+
|
58
|
+
def to_s
|
59
|
+
parts = []
|
60
|
+
parts << "Deployment test plan:"
|
61
|
+
parts << ""
|
62
|
+
parts << "Required environment parameters"
|
63
|
+
|
64
|
+
@required_env_vars.each do |e|
|
65
|
+
parts << " - #{e}"
|
66
|
+
end
|
67
|
+
|
68
|
+
PHASES.each do |phase|
|
69
|
+
parts << "Steps in phase #{phase}:"
|
70
|
+
@steps_in_phases[phase].each do |step|
|
71
|
+
parts << "- #{step.source_name}:#{step.line_span.inspect}"
|
72
|
+
parts << step.shell
|
73
|
+
end
|
74
|
+
end
|
75
|
+
|
76
|
+
parts.join("\n")
|
77
|
+
end
|
78
|
+
|
79
|
+
def missing_env_vars
|
80
|
+
@required_env_vars.select { |e| ENV[e].nil? }
|
81
|
+
end
|
82
|
+
|
83
|
+
def to_json
|
84
|
+
json = {}
|
85
|
+
|
86
|
+
PHASES.each do |phase_name|
|
87
|
+
json[phase_name] = []
|
88
|
+
@steps_in_phases[phase_name].each do |step|
|
89
|
+
json[phase_name].push({
|
90
|
+
line_span: step.line_span.inspect,
|
91
|
+
shell: step.shell.strip
|
92
|
+
})
|
93
|
+
end
|
94
|
+
end
|
95
|
+
|
96
|
+
JSON.pretty_generate(json)
|
97
|
+
end
|
98
|
+
end
|
99
|
+
end
|
data/lib/deploy_doc.rb
ADDED
data/runner/runner.rb
ADDED
@@ -0,0 +1,143 @@
|
|
1
|
+
require 'json'
|
2
|
+
require 'open3'
|
3
|
+
$stdout.sync = true
|
4
|
+
$stderr.sync = true
|
5
|
+
|
6
|
+
class StepFailed < RuntimeError
|
7
|
+
def initialize(phase, location)
|
8
|
+
super("on lines #{location} in phase #{phase}")
|
9
|
+
end
|
10
|
+
end
|
11
|
+
|
12
|
+
all_steps = JSON.load(File.read(ARGV.first))
|
13
|
+
|
14
|
+
def report_error(e)
|
15
|
+
puts
|
16
|
+
puts red { "#{e.class}: #{e}" }
|
17
|
+
puts
|
18
|
+
$all_passed = false
|
19
|
+
end
|
20
|
+
|
21
|
+
def run_phase(all_steps, name)
|
22
|
+
puts(bold { underline { name}})
|
23
|
+
steps = all_steps[name]
|
24
|
+
if steps.empty?
|
25
|
+
puts yellow { ' - No steps in this phase'}
|
26
|
+
else
|
27
|
+
steps.each do |step|
|
28
|
+
puts blue { " + Running step #{step["line_span"]}" }
|
29
|
+
|
30
|
+
shellcode = "set +e\n" + step["shell"]
|
31
|
+
begin
|
32
|
+
Open3.popen2e(shellcode) do |stdin, stdout_stderr, wait_exit_code_of_child|
|
33
|
+
$current_io_thread = wait_exit_code_of_child
|
34
|
+
buffer = ""
|
35
|
+
|
36
|
+
loop do
|
37
|
+
begin
|
38
|
+
read = stdout_stderr.read_nonblock(1024)
|
39
|
+
rescue IO::EAGAINWaitReadable
|
40
|
+
IO.select([stdout_stderr])
|
41
|
+
retry
|
42
|
+
rescue EOFError
|
43
|
+
if buffer.length > 0
|
44
|
+
lines = buffer.split("\n")
|
45
|
+
lines.each do |line|
|
46
|
+
print_line(line)
|
47
|
+
end
|
48
|
+
end
|
49
|
+
break
|
50
|
+
end
|
51
|
+
buffer += read
|
52
|
+
line, new_buffer = buffer.split("\n", 2)
|
53
|
+
if new_buffer != nil
|
54
|
+
print_line(line)
|
55
|
+
buffer = new_buffer
|
56
|
+
end
|
57
|
+
end
|
58
|
+
|
59
|
+
if wait_exit_code_of_child.value != 0
|
60
|
+
raise StepFailed.new(name, step["line_span"])
|
61
|
+
end
|
62
|
+
end
|
63
|
+
ensure
|
64
|
+
$current_io_thread = nil
|
65
|
+
end
|
66
|
+
end
|
67
|
+
end
|
68
|
+
ensure
|
69
|
+
puts
|
70
|
+
end
|
71
|
+
|
72
|
+
def print_line(line)
|
73
|
+
puts((blue { " | "}) + line)
|
74
|
+
end
|
75
|
+
|
76
|
+
def bold
|
77
|
+
"\033[1m" + yield + "\033[0m"
|
78
|
+
end
|
79
|
+
|
80
|
+
def underline
|
81
|
+
"\033[4m" + yield + "\033[0m"
|
82
|
+
end
|
83
|
+
|
84
|
+
def red
|
85
|
+
"\033[31m" + yield + "\033[0m"
|
86
|
+
end
|
87
|
+
|
88
|
+
def yellow
|
89
|
+
"\033[33m" + yield + "\033[0m"
|
90
|
+
end
|
91
|
+
|
92
|
+
def blue
|
93
|
+
"\033[34m" + yield + "\033[0m"
|
94
|
+
end
|
95
|
+
|
96
|
+
begin
|
97
|
+
begin
|
98
|
+
run_phase(all_steps, "pre-install")
|
99
|
+
rescue StepFailed => e
|
100
|
+
report_error(e)
|
101
|
+
exit 1
|
102
|
+
end
|
103
|
+
|
104
|
+
all_passed = true
|
105
|
+
begin
|
106
|
+
begin
|
107
|
+
run_phase(all_steps, "create-infrastructure")
|
108
|
+
run_phase(all_steps, "run-tests")
|
109
|
+
rescue Exception => e
|
110
|
+
report_error(e)
|
111
|
+
puts "Skipping next steps, jump to cleaning up\n"
|
112
|
+
all_passed = false
|
113
|
+
end
|
114
|
+
|
115
|
+
begin
|
116
|
+
run_phase(all_steps, "destroy-infrastructure")
|
117
|
+
rescue Exception => e
|
118
|
+
report_error(e)
|
119
|
+
puts " " + bold { red { "#" * 46 } }
|
120
|
+
puts " " + bold { red { "# " } } + bold { red { underline { "Failed to clean up the infrastructure!" } } } + bold { red { " #"}}
|
121
|
+
puts " " + bold { red { "#" * 46 } }
|
122
|
+
exit 2
|
123
|
+
end
|
124
|
+
end
|
125
|
+
|
126
|
+
if all_passed
|
127
|
+
exit 0
|
128
|
+
else
|
129
|
+
exit 1
|
130
|
+
end
|
131
|
+
rescue Interrupt
|
132
|
+
puts bold { red { "Interrupted!" } }
|
133
|
+
if $current_io_thread
|
134
|
+
puts "Killing child process #{$current_io_thread.pid}"
|
135
|
+
begin
|
136
|
+
Process.kill("KILL",$current_io_thread.pid)
|
137
|
+
rescue
|
138
|
+
# Don't care if the PID doesn't exist anymore.
|
139
|
+
# All bets are off in any case.
|
140
|
+
end
|
141
|
+
end
|
142
|
+
exit 3
|
143
|
+
end
|
metadata
ADDED
@@ -0,0 +1,108 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: deploy_doc
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.1.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Maarten Hoogendoorn
|
8
|
+
autorequire:
|
9
|
+
bindir: exe
|
10
|
+
cert_chain: []
|
11
|
+
date: 2016-12-03 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: bundler
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - "~>"
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: '1.12'
|
20
|
+
type: :development
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - "~>"
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: '1.12'
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: rake
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - "~>"
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: '10.0'
|
34
|
+
type: :development
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - "~>"
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: '10.0'
|
41
|
+
- !ruby/object:Gem::Dependency
|
42
|
+
name: minitest
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
44
|
+
requirements:
|
45
|
+
- - "~>"
|
46
|
+
- !ruby/object:Gem::Version
|
47
|
+
version: '5.0'
|
48
|
+
type: :development
|
49
|
+
prerelease: false
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
51
|
+
requirements:
|
52
|
+
- - "~>"
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: '5.0'
|
55
|
+
description: Using DeployDoc's, you can make your documentation executable
|
56
|
+
email:
|
57
|
+
- maarten@moretea.nl
|
58
|
+
executables:
|
59
|
+
- deploy_doc
|
60
|
+
extensions: []
|
61
|
+
extra_rdoc_files: []
|
62
|
+
files:
|
63
|
+
- ".gitignore"
|
64
|
+
- ".travis.yml"
|
65
|
+
- CODE_OF_CONDUCT.md
|
66
|
+
- Gemfile
|
67
|
+
- LICENSE.txt
|
68
|
+
- README.md
|
69
|
+
- Rakefile
|
70
|
+
- bin/console
|
71
|
+
- deploy_doc.gemspec
|
72
|
+
- doc/fig/architecture.dot
|
73
|
+
- doc/fig/architecture.png
|
74
|
+
- exe/deploy_doc
|
75
|
+
- lib/deploy_doc.rb
|
76
|
+
- lib/deploy_doc/cli.rb
|
77
|
+
- lib/deploy_doc/config.rb
|
78
|
+
- lib/deploy_doc/docker.rb
|
79
|
+
- lib/deploy_doc/error.rb
|
80
|
+
- lib/deploy_doc/test_plan.rb
|
81
|
+
- lib/deploy_doc/test_plan/annotator_parser.rb
|
82
|
+
- lib/deploy_doc/version.rb
|
83
|
+
- runner/runner.rb
|
84
|
+
homepage: https://github.com/microservices-demo/deploy_doc
|
85
|
+
licenses:
|
86
|
+
- MIT
|
87
|
+
metadata: {}
|
88
|
+
post_install_message:
|
89
|
+
rdoc_options: []
|
90
|
+
require_paths:
|
91
|
+
- lib
|
92
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
93
|
+
requirements:
|
94
|
+
- - ">="
|
95
|
+
- !ruby/object:Gem::Version
|
96
|
+
version: '0'
|
97
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
98
|
+
requirements:
|
99
|
+
- - ">="
|
100
|
+
- !ruby/object:Gem::Version
|
101
|
+
version: '0'
|
102
|
+
requirements: []
|
103
|
+
rubyforge_project:
|
104
|
+
rubygems_version: 2.5.1
|
105
|
+
signing_key:
|
106
|
+
specification_version: 4
|
107
|
+
summary: Test system for your deploment documentation for your application
|
108
|
+
test_files: []
|