Compare commits

..

74 Commits

Author SHA1 Message Date
Donal McBreen
1547089da0 Bump version for 2.4.0 2024-12-13 12:38:26 +00:00
Donal McBreen
ae7a4f3411 Update yml files to match doc site changes 2024-12-13 12:27:22 +00:00
Donal McBreen
77c202ebaf Highlight ssl/forward_headers behaviour
Pulled in from: https://github.com/basecamp/kamal-site/pull/141
2024-12-13 12:20:05 +00:00
Donal McBreen
063dfd9edd Merge pull request #1296 from basecamp/fix-for-dotenv-1.3.5
Fix for Dotenv 3.1.5
2024-12-13 10:54:16 +00:00
Donal McBreen
3e4a190173 Fix for Dotenv 3.1.5
In Dotenv 3.1.5, `Dotenv.parse` no longer returns values that are
already in the environment.

See https://github.com/bkeepers/dotenv/issues/518

We can get the values though by setting overwrite: true, which works
with both 3.1.4 and 3.1.5.
2024-12-13 10:42:02 +00:00
Donal McBreen
d9c25533e4 Merge pull request #1292 from nickhammond/aws-secrets-manager-simple
Aws secrets manager simple strings and error checking
2024-12-13 09:45:52 +00:00
Donal McBreen
d5ec0e6ad2 Merge pull request #1291 from nickhammond/remove-grep-options-alias
Remove the alias for grep_options, issues processing with thor
2024-12-13 09:44:06 +00:00
Nick Hammond
725da6aa68 Rubocop, Rubocop 2024-12-12 05:29:15 -07:00
Nick Hammond
84a874e63b Update secrets manager spec to render multiple errors 2024-12-12 05:15:52 -07:00
Nick Hammond
ba567e0474 Just map the secrets returned from AWS 2024-12-12 05:09:12 -07:00
Nick Hammond
e464177349 Check for errors from AWS secrets manager 2024-12-12 04:58:53 -07:00
Nick Hammond
68e6f82b30 Grab from secret2 for assertion 2024-12-12 04:17:03 -07:00
Nick Hammond
55983c6431 AWS secrets manager value can be a string 2024-12-12 04:10:48 -07:00
Nick Hammond
b2cf3f33a7 Remove the alias for grep_options, issues processing with thor 2024-12-12 03:47:25 -07:00
Jeremy Daer
16fb3adacb No need for IO.read for basic file paths
References 3cad095, e1d5182
2024-12-10 16:08:58 -08:00
Jeremy Daer
407c8b834e Simplify hostname trimming. References #762. 2024-12-10 15:57:30 -08:00
dependabot[bot]
3468b45014 Bump actionpack in the bundler group across 1 directory (#1283)
Bumps the bundler group with 1 update in the / directory: [actionpack](https://github.com/rails/rails).


Updates `actionpack` from 7.1.4.1 to 7.1.5.1
- [Release notes](https://github.com/rails/rails/releases)
- [Changelog](https://github.com/rails/rails/blob/v8.0.0.1/actionpack/CHANGELOG.md)
- [Commits](https://github.com/rails/rails/compare/v7.1.4.1...v7.1.5.1)

---
updated-dependencies:
- dependency-name: actionpack
  dependency-type: indirect
  dependency-group: bundler
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-12-10 15:46:48 -08:00
Donal McBreen
495b3cd95f Merge pull request #1270 from basecamp/kamal-proxy-0.8.4
Update to proxy version 0.8.4
2024-12-03 12:15:00 +00:00
Donal McBreen
b04e8cd8d7 Merge pull request #1272 from basecamp/dependabot/bundler/bundler-7ec3bf8405
Bump rails-html-sanitizer from 1.6.0 to 1.6.1 in the bundler group across 1 directory
2024-12-03 12:14:47 +00:00
dependabot[bot]
f5391d7fe4 Bump rails-html-sanitizer in the bundler group across 1 directory
Bumps the bundler group with 1 update in the / directory: [rails-html-sanitizer](https://github.com/rails/rails-html-sanitizer).


Updates `rails-html-sanitizer` from 1.6.0 to 1.6.1
- [Release notes](https://github.com/rails/rails-html-sanitizer/releases)
- [Changelog](https://github.com/rails/rails-html-sanitizer/blob/main/CHANGELOG.md)
- [Commits](https://github.com/rails/rails-html-sanitizer/compare/v1.6.0...v1.6.1)

---
updated-dependencies:
- dependency-name: rails-html-sanitizer
  dependency-type: indirect
  dependency-group: bundler
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-12-02 22:06:59 +00:00
Donal McBreen
2386c903ca Update to proxy version 0.8.4
Release: https://github.com/basecamp/kamal-proxy/releases/tag/v0.8.4

- Silence late healthcheck requests
2024-12-02 10:37:07 +00:00
Donal McBreen
fbc4515888 Merge pull request #906 from aliismayilov/detached-run
Allow running detached app commands and follow logs by container ID
2024-12-02 10:22:45 +00:00
Donal McBreen
99829092b3 Merge pull request #1229 from matjack1/use-ssh-keys-when-executing-commands
[FIX] - Make kamal use ssh keys from config when performing commands
2024-12-02 09:59:03 +00:00
Donal McBreen
084d1d4a1d Merge pull request #1253 from AxelTheGerman/proxy-0.8.3
Bump proxy minimum version to 0.8.3
2024-12-02 09:54:33 +00:00
Axel Gustav
a1596af815 Bump proxy minimum version to 0.8.3 2024-11-26 09:55:21 -04:00
Donal McBreen
69867e2650 Merge pull request #981 from igor-alexandrov/proxy-accessories
Proxy for accessories
2024-11-26 09:51:22 +00:00
Igor Alexandrov
eee47d10ee Added an integration test for proxied accessory using Busybox and netcat 2024-11-26 13:34:51 +04:00
Ali Ismayilov
8a7843cb35 Allow running the CI manually 2024-11-23 21:45:51 +01:00
Ali Ismayilov
1cc5406b00 Pipe app container id 2024-11-23 21:37:58 +01:00
Matteo Giaccone
e31b98539c Avoid string mutation
For Ruby 3.4
2024-11-22 09:57:45 +01:00
Igor Alexandrov
f367ca8ea5 Replaced Kamal::Commands::Proxy::Exec with Kamal::Commands::App::Proxy and Kamal::Commands::Accessory::Proxy 2024-11-21 23:08:03 +04:00
Igor Alexandrov
14068b32b1 Added alias to accessories proxy configuration example 2024-11-21 22:38:06 +04:00
Igor Alexandrov
f52826b2d6 Updated accessory proxy to support hosts option 2024-11-21 22:23:56 +04:00
Igor Alexandrov
9204624752 Removed duplicated method 2024-11-21 22:23:56 +04:00
Igor Alexandrov
006fa0de17 Extracted proxy commands to a module 2024-11-21 22:23:56 +04:00
Igor Alexandrov
4d8241ebab Fixed kamal-proxy remove command 2024-11-21 22:23:56 +04:00
Igor Alexandrov
86657b0172 Fixed kamal-proxy remove command 2024-11-21 22:23:56 +04:00
Igor Alexandrov
aa2906086a Added host to the expected accessory deploy command result 2024-11-21 22:23:56 +04:00
Igor Alexandrov
f4b7c886fb Added tests for accessory deploy and remove commands 2024-11-21 22:23:56 +04:00
Igor Alexandrov
4c778de2d9 Added tests for accessory configuration with proxy 2024-11-21 22:23:56 +04:00
Igor Alexandrov
70d2c71734 Added commands to deploy accessory to kamal-proxy 2024-11-21 22:23:56 +04:00
Ali Ismayilov
ac90ee068f Prefer dasherized notation 2024-11-21 18:54:34 +01:00
Ali Ismayilov
75b44cd328 Capture logs for specific container_id 2024-11-21 18:54:34 +01:00
Ali Ismayilov
183fe9e06e Follow logs of a specific container 2024-11-21 18:05:56 +01:00
Ali Ismayilov
1da882bb01 Enable logging on app exec new containers 2024-11-21 18:05:55 +01:00
Ali Ismayilov
c662b8d578 Make --detach incompatible with reuse or interactive 2024-11-21 18:05:55 +01:00
Ali Ismayilov
dbe0c3a7f8 Allow running detached app commands
this is useful for long running rake tasks or scripts
that can be run without having to keep open connection to the server.

Example:
```
kamal app exec 'bin/rails db:backfill_task' --detach
```
2024-11-21 18:05:55 +01:00
Donal McBreen
b9804a07aa Merge pull request #1239 from matjack1/output-accessory
Add support for exec output in accessories
2024-11-21 16:57:17 +00:00
Donal McBreen
f4d98bb67a Merge pull request #1225 from matthewbjones/feature/sbom-attestations
Adds support for SBOM attestations
2024-11-21 16:21:41 +00:00
Donal McBreen
42c3425411 Merge pull request #1235 from basecamp/support-line-filtering
Support line filtering when running tests
2024-11-21 15:26:25 +00:00
Donal McBreen
57e48a33bb Merge pull request #1141 from justindell/feat-add-aws-secrets-manager-adapter
feat: add secrets adapter for aws secrets manager
2024-11-21 15:03:54 +00:00
Donal McBreen
4acb78fff6 Merge pull request #1099 from mrbongiolo/feat-secrets-add-doppler-adapter
feat(secrets): add Doppler adapter
2024-11-21 15:03:36 +00:00
Donal McBreen
1a86b3ae6e Merge pull request #1196 from tiramizoo/role-accessories-msg
Improve error on unknown role in accessories config.
2024-11-21 15:02:48 +00:00
Donal McBreen
a4ab34d8d9 Merge pull request #1170 from davidstosik/sto/spaces
Remove trailing spaces from deploy.yml template
2024-11-21 14:52:35 +00:00
Donal McBreen
24d03fd60e Merge pull request #1105 from igor-alexandrov/ruby-version-example
Updated deploy.yml template to fetch the Ruby version automatically
2024-11-21 14:50:11 +00:00
Matteo Giaccone
83fd2a051d Add support for exec output in accessories
When running accessory exec now you get the output from the hosts.

Also you can pass commands with arguments and it will work
e.g.: cat yourfilename
2024-11-21 11:06:36 +01:00
Lewis Buckley
72f30774ba Support line filtering when running tests 2024-11-20 11:56:58 +00:00
Federico
3fa9cd5a41 Make kamal use ssh keys from config when performing commands 2024-11-19 11:38:42 +01:00
Matthew Jones
c970ceebe3 Adds support for SBOM attestations 2024-11-18 13:01:53 -07:00
Ralf Schmitz Bongiolo
8dd864af89 refactor(secrets): adapter/test_optional_account inherit from adapter/test 2024-11-05 14:14:18 -04:00
Wojciech Wnętrzak
e4ab2a0d24 Improve error on unknown role in accessories config.
Previously when unknown role (or with typo) was placed in accessories.roles,
this error was thrown: `ERROR (NoMethodError): undefined method `hosts' for nil`.
2024-11-05 14:42:17 +01:00
Ralf Schmitz Bongiolo
3069552315 feat(secrets): update doppler adapter to use --from option and DOPPLER_TOKEN env 2024-11-04 19:00:38 -04:00
Ralf Schmitz Bongiolo
77cd29f5ad feat(cli): update secrets --account flag as optional depending on adapter 2024-11-04 18:59:37 -04:00
Ralf Schmitz Bongiolo
d0d9dfcba9 Merge branch 'basecamp:main' into main 2024-11-04 16:26:12 -04:00
Justin Dell
b4d395cec9 shell escape account name in cli command 2024-11-04 09:46:45 -06:00
Justin Dell
e266945413 implement check_dependencies! 2024-11-04 09:18:56 -06:00
Justin Dell
c9fff3cb40 rename secretsmanager to secrets manager 2024-11-04 09:14:47 -06:00
Justin Dell
cef1e53f84 Merge branch 'basecamp:main' into feat-add-aws-secrets-manager-adapter 2024-11-04 09:06:04 -06:00
Donal McBreen
9cf8da64c4 Merge pull request #1193 from basecamp/filter-by-no-destination
Filter correctly for empty destinations
2024-11-04 11:14:42 +00:00
Donal McBreen
e9ba92386c Filter correctly for empty destinations
An empty destination should only filter container with empty
destination, not pick up all containers.

Fixes: https://github.com/basecamp/kamal/issues/1184
2024-11-04 11:05:24 +00:00
David Stosik
7e8a8eb6e5 Remove trailing spaces from deploy.yml template
Just a minor cleanup, nothing important.
`git` highlighted these spaces in red in my commit so I thought I'd
remove them.
2024-10-27 23:38:45 +09:00
Justin Dell
6856742eca add secrets adapter for aws secrets manager 2024-10-21 09:19:06 -05:00
Igor Alexandrov
b4df51b8b4 Added example how to read the Ruby version from the .ruby-version file. 2024-10-12 21:27:56 +04:00
Ralf Schmitz Bongiolo
3c91a83942 feat(secrets): add Doppler adapter 2024-10-10 21:41:09 -04:00
46 changed files with 1032 additions and 177 deletions

View File

@@ -4,6 +4,7 @@ on:
branches: branches:
- main - main
pull_request: pull_request:
workflow_dispatch:
jobs: jobs:
rubocop: rubocop:
name: RuboCop name: RuboCop

View File

@@ -1,7 +1,7 @@
PATH PATH
remote: . remote: .
specs: specs:
kamal (2.3.0) kamal (2.4.0)
activesupport (>= 7.0) activesupport (>= 7.0)
base64 (~> 0.2) base64 (~> 0.2)
bcrypt_pbkdf (~> 1.0) bcrypt_pbkdf (~> 1.0)
@@ -16,80 +16,87 @@ PATH
GEM GEM
remote: https://rubygems.org/ remote: https://rubygems.org/
specs: specs:
actionpack (7.1.4.1) actionpack (8.0.0.1)
actionview (= 7.1.4.1) actionview (= 8.0.0.1)
activesupport (= 7.1.4.1) activesupport (= 8.0.0.1)
nokogiri (>= 1.8.5) nokogiri (>= 1.8.5)
racc
rack (>= 2.2.4) rack (>= 2.2.4)
rack-session (>= 1.0.1) rack-session (>= 1.0.1)
rack-test (>= 0.6.3) rack-test (>= 0.6.3)
rails-dom-testing (~> 2.2) rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6) rails-html-sanitizer (~> 1.6)
actionview (7.1.4.1) useragent (~> 0.16)
activesupport (= 7.1.4.1) actionview (8.0.0.1)
activesupport (= 8.0.0.1)
builder (~> 3.1) builder (~> 3.1)
erubi (~> 1.11) erubi (~> 1.11)
rails-dom-testing (~> 2.2) rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6) rails-html-sanitizer (~> 1.6)
activesupport (7.1.4.1) activesupport (8.0.0.1)
base64 base64
benchmark (>= 0.3)
bigdecimal bigdecimal
concurrent-ruby (~> 1.0, >= 1.0.2) concurrent-ruby (~> 1.0, >= 1.3.1)
connection_pool (>= 2.2.5) connection_pool (>= 2.2.5)
drb drb
i18n (>= 1.6, < 2) i18n (>= 1.6, < 2)
logger (>= 1.4.2)
minitest (>= 5.1) minitest (>= 5.1)
mutex_m securerandom (>= 0.3)
tzinfo (~> 2.0) tzinfo (~> 2.0, >= 2.0.5)
uri (>= 0.13.1)
ast (2.4.2) ast (2.4.2)
base64 (0.2.0) base64 (0.2.0)
bcrypt_pbkdf (1.1.1) bcrypt_pbkdf (1.1.1)
bcrypt_pbkdf (1.1.1-arm64-darwin) bcrypt_pbkdf (1.1.1-arm64-darwin)
bcrypt_pbkdf (1.1.1-x86_64-darwin) bcrypt_pbkdf (1.1.1-x86_64-darwin)
benchmark (0.4.0)
bigdecimal (3.1.8) bigdecimal (3.1.8)
builder (3.3.0) builder (3.3.0)
concurrent-ruby (1.3.4) concurrent-ruby (1.3.4)
connection_pool (2.4.1) connection_pool (2.4.1)
crass (1.0.6) crass (1.0.6)
date (3.4.1)
debug (1.9.2) debug (1.9.2)
irb (~> 1.10) irb (~> 1.10)
reline (>= 0.3.8) reline (>= 0.3.8)
dotenv (3.1.2) dotenv (3.1.5)
drb (2.2.1) drb (2.2.1)
ed25519 (1.3.0) ed25519 (1.3.0)
erubi (1.13.0) erubi (1.13.0)
i18n (1.14.6) i18n (1.14.6)
concurrent-ruby (~> 1.0) concurrent-ruby (~> 1.0)
io-console (0.7.2) io-console (0.8.0)
irb (1.14.0) irb (1.14.2)
rdoc (>= 4.0.0) rdoc (>= 4.0.0)
reline (>= 0.4.2) reline (>= 0.4.2)
json (2.7.2) json (2.9.0)
language_server-protocol (3.17.0.3) language_server-protocol (3.17.0.3)
loofah (2.22.0) logger (1.6.3)
loofah (2.23.1)
crass (~> 1.0.2) crass (~> 1.0.2)
nokogiri (>= 1.12.0) nokogiri (>= 1.12.0)
minitest (5.25.1) minitest (5.25.4)
mocha (2.4.5) mocha (2.7.1)
ruby2_keywords (>= 0.0.5) ruby2_keywords (>= 0.0.5)
mutex_m (0.2.0)
net-scp (4.0.0) net-scp (4.0.0)
net-ssh (>= 2.6.5, < 8.0.0) net-ssh (>= 2.6.5, < 8.0.0)
net-sftp (4.0.0) net-sftp (4.0.0)
net-ssh (>= 5.0.0, < 8.0.0) net-ssh (>= 5.0.0, < 8.0.0)
net-ssh (7.3.0) net-ssh (7.3.0)
nokogiri (1.16.7-arm64-darwin) nokogiri (1.17.2-arm64-darwin)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.16.7-x86_64-darwin) nokogiri (1.17.2-x86_64-darwin)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.16.7-x86_64-linux) nokogiri (1.17.2-x86_64-linux)
racc (~> 1.4) racc (~> 1.4)
parallel (1.25.1) ostruct (0.6.1)
parser (3.3.4.0) parallel (1.26.3)
parser (3.3.6.0)
ast (~> 2.4.1) ast (~> 2.4.1)
racc racc
psych (5.1.2) psych (5.2.1)
date
stringio stringio
racc (1.8.1) racc (1.8.1)
rack (3.1.8) rack (3.1.8)
@@ -97,55 +104,52 @@ GEM
rack (>= 3.0.0) rack (>= 3.0.0)
rack-test (2.1.0) rack-test (2.1.0)
rack (>= 1.3) rack (>= 1.3)
rackup (2.1.0) rackup (2.2.1)
rack (>= 3) rack (>= 3)
webrick (~> 1.8)
rails-dom-testing (2.2.0) rails-dom-testing (2.2.0)
activesupport (>= 5.0.0) activesupport (>= 5.0.0)
minitest minitest
nokogiri (>= 1.6) nokogiri (>= 1.6)
rails-html-sanitizer (1.6.0) rails-html-sanitizer (1.6.2)
loofah (~> 2.21) loofah (~> 2.21)
nokogiri (~> 1.14) nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
railties (7.1.4.1) railties (8.0.0.1)
actionpack (= 7.1.4.1) actionpack (= 8.0.0.1)
activesupport (= 7.1.4.1) activesupport (= 8.0.0.1)
irb irb (~> 1.13)
rackup (>= 1.0.0) rackup (>= 1.0.0)
rake (>= 12.2) rake (>= 12.2)
thor (~> 1.0, >= 1.2.2) thor (~> 1.0, >= 1.2.2)
zeitwerk (~> 2.6) zeitwerk (~> 2.6)
rainbow (3.1.1) rainbow (3.1.1)
rake (13.2.1) rake (13.2.1)
rdoc (6.7.0) rdoc (6.8.1)
psych (>= 4.0.0) psych (>= 4.0.0)
regexp_parser (2.9.2) regexp_parser (2.9.3)
reline (0.5.9) reline (0.5.12)
io-console (~> 0.5) io-console (~> 0.5)
rexml (3.3.9) rubocop (1.69.2)
rubocop (1.65.1)
json (~> 2.3) json (~> 2.3)
language_server-protocol (>= 3.17.0) language_server-protocol (>= 3.17.0)
parallel (~> 1.10) parallel (~> 1.10)
parser (>= 3.3.0.2) parser (>= 3.3.0.2)
rainbow (>= 2.2.2, < 4.0) rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 2.4, < 3.0) regexp_parser (>= 2.9.3, < 3.0)
rexml (>= 3.2.5, < 4.0) rubocop-ast (>= 1.36.2, < 2.0)
rubocop-ast (>= 1.31.1, < 2.0)
ruby-progressbar (~> 1.7) ruby-progressbar (~> 1.7)
unicode-display_width (>= 2.4.0, < 3.0) unicode-display_width (>= 2.4.0, < 4.0)
rubocop-ast (1.32.0) rubocop-ast (1.36.2)
parser (>= 3.3.1.0) parser (>= 3.3.1.0)
rubocop-minitest (0.35.1) rubocop-minitest (0.36.0)
rubocop (>= 1.61, < 2.0) rubocop (>= 1.61, < 2.0)
rubocop-ast (>= 1.31.1, < 2.0) rubocop-ast (>= 1.31.1, < 2.0)
rubocop-performance (1.21.1) rubocop-performance (1.23.0)
rubocop (>= 1.48.1, < 2.0) rubocop (>= 1.48.1, < 2.0)
rubocop-ast (>= 1.31.1, < 2.0) rubocop-ast (>= 1.31.1, < 2.0)
rubocop-rails (2.25.1) rubocop-rails (2.27.0)
activesupport (>= 4.2.0) activesupport (>= 4.2.0)
rack (>= 1.1) rack (>= 1.1)
rubocop (>= 1.33.0, < 2.0) rubocop (>= 1.52.0, < 2.0)
rubocop-ast (>= 1.31.1, < 2.0) rubocop-ast (>= 1.31.1, < 2.0)
rubocop-rails-omakase (1.0.0) rubocop-rails-omakase (1.0.0)
rubocop rubocop
@@ -154,17 +158,22 @@ GEM
rubocop-rails rubocop-rails
ruby-progressbar (1.13.0) ruby-progressbar (1.13.0)
ruby2_keywords (0.0.5) ruby2_keywords (0.0.5)
sshkit (1.23.0) securerandom (0.4.0)
sshkit (1.23.2)
base64 base64
net-scp (>= 1.1.2) net-scp (>= 1.1.2)
net-sftp (>= 2.1.2) net-sftp (>= 2.1.2)
net-ssh (>= 2.8.0) net-ssh (>= 2.8.0)
stringio (3.1.1) ostruct
thor (1.3.1) stringio (3.1.2)
thor (1.3.2)
tzinfo (2.0.6) tzinfo (2.0.6)
concurrent-ruby (~> 1.0) concurrent-ruby (~> 1.0)
unicode-display_width (2.5.0) unicode-display_width (3.1.2)
webrick (1.8.2) unicode-emoji (~> 4.0, >= 4.0.4)
unicode-emoji (4.0.4)
uri (1.0.2)
useragent (0.16.11)
zeitwerk (2.7.1) zeitwerk (2.7.1)
PLATFORMS PLATFORMS

View File

@@ -18,6 +18,11 @@ class Kamal::Cli::Accessory < Kamal::Cli::Base
execute *accessory.ensure_env_directory execute *accessory.ensure_env_directory
upload! accessory.secrets_io, accessory.secrets_path, mode: "0600" upload! accessory.secrets_io, accessory.secrets_path, mode: "0600"
execute *accessory.run execute *accessory.run
if accessory.running_proxy?
target = capture_with_info(*accessory.container_id_for(container_name: accessory.service_name, only_running: true)).strip
execute *accessory.deploy(target: target)
end
end end
end end
end end
@@ -75,6 +80,10 @@ class Kamal::Cli::Accessory < Kamal::Cli::Base
on(hosts) do on(hosts) do
execute *KAMAL.auditor.record("Started #{name} accessory"), verbosity: :debug execute *KAMAL.auditor.record("Started #{name} accessory"), verbosity: :debug
execute *accessory.start execute *accessory.start
if accessory.running_proxy?
target = capture_with_info(*accessory.container_id_for(container_name: accessory.service_name, only_running: true)).strip
execute *accessory.deploy(target: target)
end
end end
end end
end end
@@ -87,6 +96,11 @@ class Kamal::Cli::Accessory < Kamal::Cli::Base
on(hosts) do on(hosts) do
execute *KAMAL.auditor.record("Stopped #{name} accessory"), verbosity: :debug execute *KAMAL.auditor.record("Stopped #{name} accessory"), verbosity: :debug
execute *accessory.stop, raise_on_non_zero_exit: false execute *accessory.stop, raise_on_non_zero_exit: false
if accessory.running_proxy?
target = capture_with_info(*accessory.container_id_for(container_name: accessory.service_name, only_running: true)).strip
execute *accessory.remove if target
end
end end
end end
end end
@@ -112,14 +126,15 @@ class Kamal::Cli::Accessory < Kamal::Cli::Base
end end
end end
desc "exec [NAME] [CMD]", "Execute a custom command on servers (use --help to show options)" desc "exec [NAME] [CMD...]", "Execute a custom command on servers within the accessory container (use --help to show options)"
option :interactive, aliases: "-i", type: :boolean, default: false, desc: "Execute command over ssh for an interactive shell (use for console/bash)" option :interactive, aliases: "-i", type: :boolean, default: false, desc: "Execute command over ssh for an interactive shell (use for console/bash)"
option :reuse, type: :boolean, default: false, desc: "Reuse currently running container instead of starting a new one" option :reuse, type: :boolean, default: false, desc: "Reuse currently running container instead of starting a new one"
def exec(name, cmd) def exec(name, *cmd)
cmd = Kamal::Utils.join_commands(cmd)
with_accessory(name) do |accessory, hosts| with_accessory(name) do |accessory, hosts|
case case
when options[:interactive] && options[:reuse] when options[:interactive] && options[:reuse]
say "Launching interactive command with via SSH from existing container...", :magenta say "Launching interactive command via SSH from existing container...", :magenta
run_locally { exec accessory.execute_in_existing_container_over_ssh(cmd) } run_locally { exec accessory.execute_in_existing_container_over_ssh(cmd) }
when options[:interactive] when options[:interactive]
@@ -128,16 +143,16 @@ class Kamal::Cli::Accessory < Kamal::Cli::Base
when options[:reuse] when options[:reuse]
say "Launching command from existing container...", :magenta say "Launching command from existing container...", :magenta
on(hosts) do on(hosts) do |host|
execute *KAMAL.auditor.record("Executed cmd '#{cmd}' on #{name} accessory"), verbosity: :debug execute *KAMAL.auditor.record("Executed cmd '#{cmd}' on #{name} accessory"), verbosity: :debug
capture_with_info(*accessory.execute_in_existing_container(cmd)) puts_by_host host, capture_with_info(*accessory.execute_in_existing_container(cmd))
end end
else else
say "Launching command from new container...", :magenta say "Launching command from new container...", :magenta
on(hosts) do on(hosts) do |host|
execute *KAMAL.auditor.record("Executed cmd '#{cmd}' on #{name} accessory"), verbosity: :debug execute *KAMAL.auditor.record("Executed cmd '#{cmd}' on #{name} accessory"), verbosity: :debug
capture_with_info(*accessory.execute_in_new_container(cmd)) puts_by_host host, capture_with_info(*accessory.execute_in_new_container(cmd))
end end
end end
end end
@@ -147,7 +162,7 @@ class Kamal::Cli::Accessory < Kamal::Cli::Base
option :since, aliases: "-s", desc: "Show logs since timestamp (e.g. 2013-01-02T13:23:37Z) or relative (e.g. 42m for 42 minutes)" option :since, aliases: "-s", desc: "Show logs since timestamp (e.g. 2013-01-02T13:23:37Z) or relative (e.g. 42m for 42 minutes)"
option :lines, type: :numeric, aliases: "-n", desc: "Number of log lines to pull from each server" option :lines, type: :numeric, aliases: "-n", desc: "Number of log lines to pull from each server"
option :grep, aliases: "-g", desc: "Show lines with grep match only (use this to fetch specific requests by id)" option :grep, aliases: "-g", desc: "Show lines with grep match only (use this to fetch specific requests by id)"
option :grep_options, aliases: "-o", desc: "Additional options supplied to grep" option :grep_options, desc: "Additional options supplied to grep"
option :follow, aliases: "-f", desc: "Follow logs on primary server (or specific host set by --hosts)" option :follow, aliases: "-f", desc: "Follow logs on primary server (or specific host set by --hosts)"
option :skip_timestamps, type: :boolean, aliases: "-T", desc: "Skip appending timestamps to logging output" option :skip_timestamps, type: :boolean, aliases: "-T", desc: "Skip appending timestamps to logging output"
def logs(name) def logs(name)

View File

@@ -94,9 +94,15 @@ class Kamal::Cli::App < Kamal::Cli::Base
option :interactive, aliases: "-i", type: :boolean, default: false, desc: "Execute command over ssh for an interactive shell (use for console/bash)" option :interactive, aliases: "-i", type: :boolean, default: false, desc: "Execute command over ssh for an interactive shell (use for console/bash)"
option :reuse, type: :boolean, default: false, desc: "Reuse currently running container instead of starting a new one" option :reuse, type: :boolean, default: false, desc: "Reuse currently running container instead of starting a new one"
option :env, aliases: "-e", type: :hash, desc: "Set environment variables for the command" option :env, aliases: "-e", type: :hash, desc: "Set environment variables for the command"
option :detach, type: :boolean, default: false, desc: "Execute command in a detached container"
def exec(*cmd) def exec(*cmd)
if (incompatible_options = [ :interactive, :reuse ].select { |key| options[:detach] && options[key] }.presence)
raise ArgumentError, "Detach is not compatible with #{incompatible_options.join(" or ")}"
end
cmd = Kamal::Utils.join_commands(cmd) cmd = Kamal::Utils.join_commands(cmd)
env = options[:env] env = options[:env]
detach = options[:detach]
case case
when options[:interactive] && options[:reuse] when options[:interactive] && options[:reuse]
say "Get current version of running container...", :magenta unless options[:version] say "Get current version of running container...", :magenta unless options[:version]
@@ -138,7 +144,7 @@ class Kamal::Cli::App < Kamal::Cli::Base
roles.each do |role| roles.each do |role|
execute *KAMAL.auditor.record("Executed cmd '#{cmd}' on app version #{version}"), verbosity: :debug execute *KAMAL.auditor.record("Executed cmd '#{cmd}' on app version #{version}"), verbosity: :debug
puts_by_host host, capture_with_info(*KAMAL.app(role: role, host: host).execute_in_new_container(cmd, env: env)) puts_by_host host, capture_with_info(*KAMAL.app(role: role, host: host).execute_in_new_container(cmd, env: env, detach: detach))
end end
end end
end end
@@ -186,15 +192,17 @@ class Kamal::Cli::App < Kamal::Cli::Base
option :since, aliases: "-s", desc: "Show lines since timestamp (e.g. 2013-01-02T13:23:37Z) or relative (e.g. 42m for 42 minutes)" option :since, aliases: "-s", desc: "Show lines since timestamp (e.g. 2013-01-02T13:23:37Z) or relative (e.g. 42m for 42 minutes)"
option :lines, type: :numeric, aliases: "-n", desc: "Number of lines to show from each server" option :lines, type: :numeric, aliases: "-n", desc: "Number of lines to show from each server"
option :grep, aliases: "-g", desc: "Show lines with grep match only (use this to fetch specific requests by id)" option :grep, aliases: "-g", desc: "Show lines with grep match only (use this to fetch specific requests by id)"
option :grep_options, aliases: "-o", desc: "Additional options supplied to grep" option :grep_options, desc: "Additional options supplied to grep"
option :follow, aliases: "-f", desc: "Follow log on primary server (or specific host set by --hosts)" option :follow, aliases: "-f", desc: "Follow log on primary server (or specific host set by --hosts)"
option :skip_timestamps, type: :boolean, aliases: "-T", desc: "Skip appending timestamps to logging output" option :skip_timestamps, type: :boolean, aliases: "-T", desc: "Skip appending timestamps to logging output"
option :container_id, desc: "Docker container ID to fetch logs"
def logs def logs
# FIXME: Catch when app containers aren't running # FIXME: Catch when app containers aren't running
grep = options[:grep] grep = options[:grep]
grep_options = options[:grep_options] grep_options = options[:grep_options]
since = options[:since] since = options[:since]
container_id = options[:container_id]
timestamps = !options[:skip_timestamps] timestamps = !options[:skip_timestamps]
if options[:follow] if options[:follow]
@@ -207,8 +215,8 @@ class Kamal::Cli::App < Kamal::Cli::Base
role = KAMAL.roles_on(KAMAL.primary_host).first role = KAMAL.roles_on(KAMAL.primary_host).first
app = KAMAL.app(role: role, host: host) app = KAMAL.app(role: role, host: host)
info app.follow_logs(host: KAMAL.primary_host, timestamps: timestamps, lines: lines, grep: grep, grep_options: grep_options) info app.follow_logs(host: KAMAL.primary_host, container_id: container_id, timestamps: timestamps, lines: lines, grep: grep, grep_options: grep_options)
exec app.follow_logs(host: KAMAL.primary_host, timestamps: timestamps, lines: lines, grep: grep, grep_options: grep_options) exec app.follow_logs(host: KAMAL.primary_host, container_id: container_id, timestamps: timestamps, lines: lines, grep: grep, grep_options: grep_options)
end end
else else
lines = options[:lines].presence || ((since || grep) ? nil : 100) # Default to 100 lines if since or grep isn't set lines = options[:lines].presence || ((since || grep) ? nil : 100) # Default to 100 lines if since or grep isn't set
@@ -218,7 +226,7 @@ class Kamal::Cli::App < Kamal::Cli::Base
roles.each do |role| roles.each do |role|
begin begin
puts_by_host host, capture_with_info(*KAMAL.app(role: role, host: host).logs(timestamps: timestamps, since: since, lines: lines, grep: grep, grep_options: grep_options)) puts_by_host host, capture_with_info(*KAMAL.app(role: role, host: host).logs(container_id: container_id, timestamps: timestamps, since: since, lines: lines, grep: grep, grep_options: grep_options))
rescue SSHKit::Command::Failed rescue SSHKit::Command::Failed
puts_by_host host, "Nothing found" puts_by_host host, "Nothing found"
end end

View File

@@ -45,7 +45,7 @@ class Kamal::Cli::App::Boot
def start_new_version def start_new_version
audit "Booted app version #{version}" audit "Booted app version #{version}"
hostname = "#{host.to_s[0...51].gsub(/\.+$/, '')}-#{SecureRandom.hex(6)}" hostname = "#{host.to_s[0...51].chomp(".")}-#{SecureRandom.hex(6)}"
execute *app.ensure_env_directory execute *app.ensure_env_directory
upload! role.secrets_io(host), role.secrets_path, mode: "0600" upload! role.secrets_io(host), role.secrets_path, mode: "0600"
@@ -91,7 +91,7 @@ class Kamal::Cli::App::Boot
if barrier.close if barrier.close
info "First #{KAMAL.primary_role} container is unhealthy on #{host}, not booting any other roles" info "First #{KAMAL.primary_role} container is unhealthy on #{host}, not booting any other roles"
begin begin
error capture_with_info(*app.logs(version: version)) error capture_with_info(*app.logs(container_id: app.container_id_for_version(version)))
error capture_with_info(*app.container_health_log(version: version)) error capture_with_info(*app.container_health_log(version: version))
rescue SSHKit::Command::Failed rescue SSHKit::Command::Failed
error "Could not fetch logs for #{version}" error "Could not fetch logs for #{version}"

View File

@@ -1,11 +1,17 @@
class Kamal::Cli::Secrets < Kamal::Cli::Base class Kamal::Cli::Secrets < Kamal::Cli::Base
desc "fetch [SECRETS...]", "Fetch secrets from a vault" desc "fetch [SECRETS...]", "Fetch secrets from a vault"
option :adapter, type: :string, aliases: "-a", required: true, desc: "Which vault adapter to use" option :adapter, type: :string, aliases: "-a", required: true, desc: "Which vault adapter to use"
option :account, type: :string, required: true, desc: "The account identifier or username" option :account, type: :string, required: false, desc: "The account identifier or username"
option :from, type: :string, required: false, desc: "A vault or folder to fetch the secrets from" option :from, type: :string, required: false, desc: "A vault or folder to fetch the secrets from"
option :inline, type: :boolean, required: false, hidden: true option :inline, type: :boolean, required: false, hidden: true
def fetch(*secrets) def fetch(*secrets)
results = adapter(options[:adapter]).fetch(secrets, **options.slice(:account, :from).symbolize_keys) adapter = initialize_adapter(options[:adapter])
if adapter.requires_account? && options[:account].blank?
return puts "No value provided for required options '--account'"
end
results = adapter.fetch(secrets, **options.slice(:account, :from).symbolize_keys)
return_or_puts JSON.dump(results).shellescape, inline: options[:inline] return_or_puts JSON.dump(results).shellescape, inline: options[:inline]
end end
@@ -29,7 +35,7 @@ class Kamal::Cli::Secrets < Kamal::Cli::Base
end end
private private
def adapter(adapter) def initialize_adapter(adapter)
Kamal::Secrets::Adapters.lookup(adapter) Kamal::Secrets::Adapters.lookup(adapter)
end end

View File

@@ -36,6 +36,9 @@ registry:
# Configure builder setup. # Configure builder setup.
builder: builder:
arch: amd64 arch: amd64
# Pass in additional build args needed for your Dockerfile.
# args:
# RUBY_VERSION: <%= File.read('.ruby-version').strip %>
# Inject ENV variables into containers (secrets come from .kamal/secrets). # Inject ENV variables into containers (secrets come from .kamal/secrets).
# #

View File

@@ -1,9 +1,13 @@
class Kamal::Commands::Accessory < Kamal::Commands::Base class Kamal::Commands::Accessory < Kamal::Commands::Base
include Proxy
attr_reader :accessory_config attr_reader :accessory_config
delegate :service_name, :image, :hosts, :port, :files, :directories, :cmd, delegate :service_name, :image, :hosts, :port, :files, :directories, :cmd,
:network_args, :publish_args, :env_args, :volume_args, :label_args, :option_args, :network_args, :publish_args, :env_args, :volume_args, :label_args, :option_args,
:secrets_io, :secrets_path, :env_directory, :secrets_io, :secrets_path, :env_directory, :proxy, :running_proxy?,
to: :accessory_config to: :accessory_config
delegate :proxy_container_name, to: :config
def initialize(config, name:) def initialize(config, name:)
super(config) super(config)

View File

@@ -0,0 +1,16 @@
module Kamal::Commands::Accessory::Proxy
delegate :proxy_container_name, to: :config
def deploy(target:)
proxy_exec :deploy, service_name, *proxy.deploy_command_args(target: target)
end
def remove
proxy_exec :remove, service_name
end
private
def proxy_exec(*command)
docker :exec, proxy_container_name, "kamal-proxy", *command
end
end

View File

@@ -47,7 +47,7 @@ class Kamal::Commands::App < Kamal::Commands::Base
end end
def info def info
docker :ps, *filter_args docker :ps, *container_filter_args
end end
@@ -67,7 +67,7 @@ class Kamal::Commands::App < Kamal::Commands::Base
def list_versions(*docker_args, statuses: nil) def list_versions(*docker_args, statuses: nil)
pipe \ pipe \
docker(:ps, *filter_args(statuses: statuses), *docker_args, "--format", '"{{.Names}}"'), docker(:ps, *container_filter_args(statuses: statuses), *docker_args, "--format", '"{{.Names}}"'),
extract_version_from_name extract_version_from_name
end end
@@ -91,11 +91,15 @@ class Kamal::Commands::App < Kamal::Commands::Base
end end
def latest_container(format:, filters: nil) def latest_container(format:, filters: nil)
docker :ps, "--latest", *format, *filter_args(statuses: ACTIVE_DOCKER_STATUSES), argumentize("--filter", filters) docker :ps, "--latest", *format, *container_filter_args(statuses: ACTIVE_DOCKER_STATUSES), argumentize("--filter", filters)
end end
def filter_args(statuses: nil) def container_filter_args(statuses: nil)
argumentize "--filter", filters(statuses: statuses) argumentize "--filter", container_filters(statuses: statuses)
end
def image_filter_args
argumentize "--filter", image_filters
end end
def extract_version_from_name def extract_version_from_name
@@ -103,13 +107,17 @@ class Kamal::Commands::App < Kamal::Commands::Base
%(while read line; do echo ${line##{role.container_prefix}-}; done) %(while read line; do echo ${line##{role.container_prefix}-}; done)
end end
def filters(statuses: nil) def container_filters(statuses: nil)
[ "label=service=#{config.service}" ].tap do |filters| [ "label=service=#{config.service}" ].tap do |filters|
filters << "label=destination=#{config.destination}" if config.destination filters << "label=destination=#{config.destination}"
filters << "label=role=#{role}" if role filters << "label=role=#{role}" if role
statuses&.each do |status| statuses&.each do |status|
filters << "status=#{status}" filters << "status=#{status}"
end end
end end
end end
def image_filters
[ "label=service=#{config.service}" ]
end
end end

View File

@@ -2,7 +2,7 @@ module Kamal::Commands::App::Containers
DOCKER_HEALTH_LOG_FORMAT = "'{{json .State.Health}}'" DOCKER_HEALTH_LOG_FORMAT = "'{{json .State.Health}}'"
def list_containers def list_containers
docker :container, :ls, "--all", *filter_args docker :container, :ls, "--all", *container_filter_args
end end
def list_container_names def list_container_names
@@ -20,7 +20,7 @@ module Kamal::Commands::App::Containers
end end
def remove_containers def remove_containers
docker :container, :prune, "--force", *filter_args docker :container, :prune, "--force", *container_filter_args
end end
def container_health_log(version:) def container_health_log(version:)

View File

@@ -7,13 +7,15 @@ module Kamal::Commands::App::Execution
*command *command
end end
def execute_in_new_container(*command, interactive: false, env:) def execute_in_new_container(*command, interactive: false, detach: false, env:)
docker :run, docker :run,
("-it" if interactive), ("-it" if interactive),
"--rm", ("--detach" if detach),
("--rm" unless detach),
"--network", "kamal", "--network", "kamal",
*role&.env_args(host), *role&.env_args(host),
*argumentize("--env", env), *argumentize("--env", env),
*role.logging_args,
*config.volume_args, *config.volume_args,
*role&.option_args, *role&.option_args,
config.absolute_image, config.absolute_image,

View File

@@ -4,7 +4,7 @@ module Kamal::Commands::App::Images
end end
def remove_images def remove_images
docker :image, :prune, "--all", "--force", *filter_args docker :image, :prune, "--all", "--force", *image_filter_args
end end
def tag_latest_image def tag_latest_image

View File

@@ -1,18 +1,28 @@
module Kamal::Commands::App::Logging module Kamal::Commands::App::Logging
def logs(version: nil, timestamps: true, since: nil, lines: nil, grep: nil, grep_options: nil) def logs(container_id: nil, timestamps: true, since: nil, lines: nil, grep: nil, grep_options: nil)
pipe \ pipe \
version ? container_id_for_version(version) : current_running_container_id, container_id_command(container_id),
"xargs docker logs#{" --timestamps" if timestamps}#{" --since #{since}" if since}#{" --tail #{lines}" if lines} 2>&1", "xargs docker logs#{" --timestamps" if timestamps}#{" --since #{since}" if since}#{" --tail #{lines}" if lines} 2>&1",
("grep '#{grep}'#{" #{grep_options}" if grep_options}" if grep) ("grep '#{grep}'#{" #{grep_options}" if grep_options}" if grep)
end end
def follow_logs(host:, timestamps: true, lines: nil, grep: nil, grep_options: nil) def follow_logs(host:, container_id: nil, timestamps: true, lines: nil, grep: nil, grep_options: nil)
run_over_ssh \ run_over_ssh \
pipe( pipe(
current_running_container_id, container_id_command(container_id),
"xargs docker logs#{" --timestamps" if timestamps}#{" --tail #{lines}" if lines} --follow 2>&1", "xargs docker logs#{" --timestamps" if timestamps}#{" --tail #{lines}" if lines} --follow 2>&1",
(%(grep "#{grep}"#{" #{grep_options}" if grep_options}) if grep) (%(grep "#{grep}"#{" #{grep_options}" if grep_options}) if grep)
), ),
host: host host: host
end end
private
def container_id_command(container_id)
case container_id
when Array then container_id
when String, Symbol then "echo #{container_id}"
else current_running_container_id
end
end
end end

View File

@@ -11,7 +11,7 @@ module Kamal::Commands
end end
def run_over_ssh(*command, host:) def run_over_ssh(*command, host:)
"ssh#{ssh_proxy_args} -t #{config.ssh.user}@#{host} -p #{config.ssh.port} '#{command.join(" ").gsub("'", "'\\\\''")}'" "ssh#{ssh_proxy_args}#{ssh_keys_args} -t #{config.ssh.user}@#{host} -p #{config.ssh.port} '#{command.join(" ").gsub("'", "'\\\\''")}'"
end end
def container_id_for(container_name:, only_running: false) def container_id_for(container_name:, only_running: false)
@@ -94,5 +94,15 @@ module Kamal::Commands
" -o ProxyCommand='#{config.ssh.proxy.command_line_template}'" " -o ProxyCommand='#{config.ssh.proxy.command_line_template}'"
end end
end end
def ssh_keys_args
"#{ ssh_keys.join("") if ssh_keys}" + "#{" -o IdentitiesOnly=yes" if config.ssh&.keys_only}"
end
def ssh_keys
config.ssh.keys&.map do |key|
" -i #{key}"
end
end
end end
end end

View File

@@ -6,7 +6,7 @@ class Kamal::Commands::Builder::Base < Kamal::Commands::Base
delegate :argumentize, to: Kamal::Utils delegate :argumentize, to: Kamal::Utils
delegate \ delegate \
:args, :secrets, :dockerfile, :target, :arches, :local_arches, :remote_arches, :remote, :args, :secrets, :dockerfile, :target, :arches, :local_arches, :remote_arches, :remote,
:cache_from, :cache_to, :ssh, :provenance, :driver, :docker_driver?, :cache_from, :cache_to, :ssh, :provenance, :sbom, :driver, :docker_driver?,
to: :builder_config to: :builder_config
def clean def clean
@@ -37,7 +37,7 @@ class Kamal::Commands::Builder::Base < Kamal::Commands::Base
end end
def build_options def build_options
[ *build_tags, *build_cache, *build_labels, *build_args, *build_secrets, *build_dockerfile, *build_target, *build_ssh, *builder_provenance ] [ *build_tags, *build_cache, *build_labels, *build_args, *build_secrets, *build_dockerfile, *build_target, *build_ssh, *builder_provenance, *builder_sbom ]
end end
def build_context def build_context
@@ -101,6 +101,10 @@ class Kamal::Commands::Builder::Base < Kamal::Commands::Base
argumentize "--provenance", provenance unless provenance.nil? argumentize "--provenance", provenance unless provenance.nil?
end end
def builder_sbom
argumentize "--sbom", sbom unless sbom.nil?
end
def builder_config def builder_config
config.builder config.builder
end end

View File

@@ -14,7 +14,7 @@ class Kamal::Configuration
include Validation include Validation
PROXY_MINIMUM_VERSION = "v0.8.2" PROXY_MINIMUM_VERSION = "v0.8.4"
PROXY_HTTP_PORT = 80 PROXY_HTTP_PORT = 80
PROXY_HTTPS_PORT = 443 PROXY_HTTPS_PORT = 443
PROXY_LOG_MAX_SIZE = "10m" PROXY_LOG_MAX_SIZE = "10m"
@@ -37,7 +37,7 @@ class Kamal::Configuration
if file.exist? if file.exist?
# Newer Psych doesn't load aliases by default # Newer Psych doesn't load aliases by default
load_method = YAML.respond_to?(:unsafe_load) ? :unsafe_load : :load load_method = YAML.respond_to?(:unsafe_load) ? :unsafe_load : :load
YAML.send(load_method, ERB.new(IO.read(file)).result).symbolize_keys YAML.send(load_method, ERB.new(File.read(file)).result).symbolize_keys
else else
raise "Configuration file not found in #{file}" raise "Configuration file not found in #{file}"
end end

View File

@@ -5,7 +5,7 @@ class Kamal::Configuration::Accessory
delegate :argumentize, :optionize, to: Kamal::Utils delegate :argumentize, :optionize, to: Kamal::Utils
attr_reader :name, :accessory_config, :env attr_reader :name, :accessory_config, :env, :proxy
def initialize(name, config:) def initialize(name, config:)
@name, @config, @accessory_config = name.inquiry, config, config.raw_config["accessories"][name] @name, @config, @accessory_config = name.inquiry, config, config.raw_config["accessories"][name]
@@ -20,6 +20,8 @@ class Kamal::Configuration::Accessory
config: accessory_config.fetch("env", {}), config: accessory_config.fetch("env", {}),
secrets: config.secrets, secrets: config.secrets,
context: "accessories/#{name}/env" context: "accessories/#{name}/env"
initialize_proxy if running_proxy?
end end
def service_name def service_name
@@ -106,6 +108,17 @@ class Kamal::Configuration::Accessory
accessory_config["cmd"] accessory_config["cmd"]
end end
def running_proxy?
@accessory_config["proxy"].present?
end
def initialize_proxy
@proxy = Kamal::Configuration::Proxy.new \
config: config,
proxy_config: accessory_config["proxy"],
context: "accessories/#{name}/proxy"
end
private private
attr_accessor :config attr_accessor :config
@@ -129,7 +142,7 @@ class Kamal::Configuration::Accessory
end end
def read_dynamic_file(local_file) def read_dynamic_file(local_file)
StringIO.new(ERB.new(IO.read(local_file)).result) StringIO.new(ERB.new(File.read(local_file)).result)
end end
def expand_remote_file(remote_file) def expand_remote_file(remote_file)
@@ -176,7 +189,9 @@ class Kamal::Configuration::Accessory
def hosts_from_roles def hosts_from_roles
if accessory_config.key?("roles") if accessory_config.key?("roles")
accessory_config["roles"].flat_map { |role| config.role(role).hosts } accessory_config["roles"].flat_map do |role|
config.role(role)&.hosts || raise(Kamal::ConfigurationError, "Unknown role in accessories config: '#{role}'")
end
end end
end end

View File

@@ -115,6 +115,10 @@ class Kamal::Configuration::Builder
builder_config["provenance"] builder_config["provenance"]
end end
def sbom
builder_config["sbom"]
end
def git_clone? def git_clone?
Kamal::Git.used? && builder_config["context"].nil? Kamal::Git.used? && builder_config["context"].nil?
end end

View File

@@ -43,8 +43,8 @@ accessories:
# Port mappings # Port mappings
# #
# See https://docs.docker.com/network/, and especially note the warning about the security # See [https://docs.docker.com/network/](https://docs.docker.com/network/), and
# implications of exposing ports publicly. # especially note the warning about the security implications of exposing ports publicly.
port: "127.0.0.1:3306:3306" port: "127.0.0.1:3306:3306"
# Labels # Labels
@@ -98,3 +98,7 @@ accessories:
# Defaults to kamal: # Defaults to kamal:
network: custom network: custom
# Proxy
#
proxy:
...

View File

@@ -5,12 +5,12 @@
# For example, for a Rails app, you might open a console with: # For example, for a Rails app, you might open a console with:
# #
# ```shell # ```shell
# kamal app exec -i -r console "rails console" # kamal app exec -i --reuse "bin/rails console"
# ``` # ```
# #
# By defining an alias, like this: # By defining an alias, like this:
aliases: aliases:
console: app exec -r console -i "rails console" console: app exec -i --reuse "bin/rails console"
# You can now open the console with: # You can now open the console with:
# #
# ```shell # ```shell

View File

@@ -108,3 +108,9 @@ builder:
# It is used to configure provenance attestations for the build result. # It is used to configure provenance attestations for the build result.
# The value can also be a boolean to enable or disable provenance attestations. # The value can also be a boolean to enable or disable provenance attestations.
provenance: mode=max provenance: mode=max
# SBOM (Software Bill of Materials)
#
# It is used to configure SBOM generation for the build result.
# The value can also be a boolean to enable or disable SBOM generation.
sbom: true

View File

@@ -46,9 +46,22 @@ proxy:
# The host value must point to the server we are deploying to, and port 443 must be # The host value must point to the server we are deploying to, and port 443 must be
# open for the Let's Encrypt challenge to succeed. # open for the Let's Encrypt challenge to succeed.
# #
# If you set `ssl` to `true`, `kamal-proxy` will stop forwarding headers to your app,
# unless you explicitly set `forward_headers: true`
#
# Defaults to `false`: # Defaults to `false`:
ssl: true ssl: true
# Forward headers
#
# Whether to forward the `X-Forwarded-For` and `X-Forwarded-Proto` headers.
#
# If you are behind a trusted proxy, you can set this to `true` to forward the headers.
#
# By default, kamal-proxy will not forward the headers if the `ssl` option is set to `true`, and
# will forward them if it is set to `false`.
forward_headers: true
# Response timeout # Response timeout
# #
# How long to wait for requests to complete before timing out, defaults to 30 seconds: # How long to wait for requests to complete before timing out, defaults to 30 seconds:
@@ -93,13 +106,3 @@ proxy:
response_headers: response_headers:
- X-Request-ID - X-Request-ID
- X-Request-Start - X-Request-Start
# Forward headers
#
# Whether to forward the `X-Forwarded-For` and `X-Forwarded-Proto` headers.
#
# If you are behind a trusted proxy, you can set this to `true` to forward the headers.
#
# By default, kamal-proxy will not forward the headers if the `ssl` option is set to `true`, and
# will forward them if it is set to `false`.
forward_headers: true

View File

@@ -2,6 +2,10 @@
# #
# The default registry is Docker Hub, but you can change it using `registry/server`. # The default registry is Docker Hub, but you can change it using `registry/server`.
# #
# By default, Docker Hub creates public repositories. To avoid making your images public,
# set up a private repository before deploying, or change the default repository privacy
# settings to private in your [Docker Hub settings](https://hub.docker.com/repository-settings/default-privacy).
#
# A reference to a secret (in this case, `DOCKER_REGISTRY_TOKEN`) will look up the secret # A reference to a secret (in this case, `DOCKER_REGISTRY_TOKEN`) will look up the secret
# in the local environment: # in the local environment:
registry: registry:

View File

@@ -32,7 +32,7 @@ class Kamal::Secrets
private private
def secrets def secrets
@secrets ||= secrets_files.inject({}) do |secrets, secrets_file| @secrets ||= secrets_files.inject({}) do |secrets, secrets_file|
secrets.merge!(::Dotenv.parse(secrets_file)) secrets.merge!(::Dotenv.parse(secrets_file, overwrite: true))
end end
end end

View File

@@ -0,0 +1,42 @@
class Kamal::Secrets::Adapters::AwsSecretsManager < Kamal::Secrets::Adapters::Base
private
def login(_account)
nil
end
def fetch_secrets(secrets, account:, session:)
{}.tap do |results|
get_from_secrets_manager(secrets, account: account).each do |secret|
secret_name = secret["Name"]
secret_string = JSON.parse(secret["SecretString"])
secret_string.each do |key, value|
results["#{secret_name}/#{key}"] = value
end
rescue JSON::ParserError
results["#{secret_name}"] = secret["SecretString"]
end
end
end
def get_from_secrets_manager(secrets, account:)
`aws secretsmanager batch-get-secret-value --secret-id-list #{secrets.map(&:shellescape).join(" ")} --profile #{account.shellescape}`.tap do |secrets|
raise RuntimeError, "Could not read #{secrets} from AWS Secrets Manager" unless $?.success?
secrets = JSON.parse(secrets)
return secrets["SecretValues"] unless secrets["Errors"].present?
raise RuntimeError, secrets["Errors"].map { |error| "#{error['SecretId']}: #{error['Message']}" }.join(" ")
end
end
def check_dependencies!
raise RuntimeError, "AWS CLI is not installed" unless cli_installed?
end
def cli_installed?
`aws --version 2> /dev/null`
$?.success?
end
end

View File

@@ -1,13 +1,20 @@
class Kamal::Secrets::Adapters::Base class Kamal::Secrets::Adapters::Base
delegate :optionize, to: Kamal::Utils delegate :optionize, to: Kamal::Utils
def fetch(secrets, account:, from: nil) def fetch(secrets, account: nil, from: nil)
raise RuntimeError, "Missing required option '--account'" if requires_account? && account.blank?
check_dependencies! check_dependencies!
session = login(account) session = login(account)
full_secrets = secrets.map { |secret| [ from, secret ].compact.join("/") } full_secrets = secrets.map { |secret| [ from, secret ].compact.join("/") }
fetch_secrets(full_secrets, account: account, session: session) fetch_secrets(full_secrets, account: account, session: session)
end end
def requires_account?
true
end
private private
def login(...) def login(...)
raise NotImplementedError raise NotImplementedError

View File

@@ -0,0 +1,53 @@
class Kamal::Secrets::Adapters::Doppler < Kamal::Secrets::Adapters::Base
def requires_account?
false
end
private
def login(*)
unless loggedin?
`doppler login -y`
raise RuntimeError, "Failed to login to Doppler" unless $?.success?
end
end
def loggedin?
`doppler me --json 2> /dev/null`
$?.success?
end
def fetch_secrets(secrets, **)
project_and_config_flags = ""
unless service_token_set?
project, config, _ = secrets.first.split("/")
unless project && config
raise RuntimeError, "Missing project or config from '--from=project/config' option"
end
project_and_config_flags = "-p #{project.shellescape} -c #{config.shellescape}"
end
secret_names = secrets.collect { |s| s.split("/").last }
items = `doppler secrets get #{secret_names.map(&:shellescape).join(" ")} --json #{project_and_config_flags}`
raise RuntimeError, "Could not read #{secrets} from Doppler" unless $?.success?
items = JSON.parse(items)
items.transform_values { |value| value["computed"] }
end
def service_token_set?
ENV["DOPPLER_TOKEN"] && ENV["DOPPLER_TOKEN"][0, 5] == "dp.st"
end
def check_dependencies!
raise RuntimeError, "Doppler CLI is not installed" unless cli_installed?
end
def cli_installed?
`doppler --version 2> /dev/null`
$?.success?
end
end

View File

@@ -0,0 +1,5 @@
class Kamal::Secrets::Adapters::TestOptionalAccount < Kamal::Secrets::Adapters::Test
def requires_account?
false
end
end

View File

@@ -1,3 +1,3 @@
module Kamal module Kamal
VERSION = "2.3.0" VERSION = "2.4.0"
end end

View File

@@ -19,7 +19,7 @@ class CliAppTest < CliTestCase
.returns("12345678") # running version .returns("12345678") # running version
SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)
.with(:sh, "-c", "'docker ps --latest --format '\\''{{.Names}}'\\'' --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --format '\\''{{.Names}}'\\'' --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting'", "|", :head, "-1", "|", "while read line; do echo ${line#app-web-}; done", raise_on_non_zero_exit: false) .with(:sh, "-c", "'docker ps --latest --format '\\''{{.Names}}'\\'' --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --format '\\''{{.Names}}'\\'' --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting'", "|", :head, "-1", "|", "while read line; do echo ${line#app-web-}; done", raise_on_non_zero_exit: false)
.returns("123") # old version .returns("123") # old version
SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)
@@ -63,7 +63,7 @@ class CliAppTest < CliTestCase
.returns("12345678") # running version .returns("12345678") # running version
SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)
.with(:sh, "-c", "'docker ps --latest --format '\\''{{.Names}}'\\'' --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --format '\\''{{.Names}}'\\'' --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting'", "|", :head, "-1", "|", "while read line; do echo ${line#app-web-}; done", raise_on_non_zero_exit: false) .with(:sh, "-c", "'docker ps --latest --format '\\''{{.Names}}'\\'' --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --format '\\''{{.Names}}'\\'' --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting'", "|", :head, "-1", "|", "while read line; do echo ${line#app-web-}; done", raise_on_non_zero_exit: false)
.returns("123").twice # old version .returns("123").twice # old version
SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)
@@ -92,7 +92,7 @@ class CliAppTest < CliTestCase
.returns("12345678") # running version .returns("12345678") # running version
SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)
.with(:sh, "-c", "'docker ps --latest --format '\\''{{.Names}}'\\'' --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --format '\\''{{.Names}}'\\'' --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting'", "|", :head, "-1", "|", "while read line; do echo ${line#app-web-}; done", raise_on_non_zero_exit: false) .with(:sh, "-c", "'docker ps --latest --format '\\''{{.Names}}'\\'' --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --format '\\''{{.Names}}'\\'' --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting'", "|", :head, "-1", "|", "while read line; do echo ${line#app-web-}; done", raise_on_non_zero_exit: false)
.returns("123") # old version .returns("123") # old version
run_command("boot", config: :with_env_tags).tap do |output| run_command("boot", config: :with_env_tags).tap do |output|
@@ -196,17 +196,17 @@ class CliAppTest < CliTestCase
test "stop" do test "stop" do
run_command("stop").tap do |output| run_command("stop").tap do |output|
assert_match "sh -c 'docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting' | head -1 | xargs docker stop", output assert_match "sh -c 'docker ps --latest --quiet --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting' | head -1 | xargs docker stop", output
end end
end end
test "stale_containers" do test "stale_containers" do
SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)
.with(:docker, :ps, "--filter", "label=service=app", "--filter", "label=role=web", "--format", "\"{{.Names}}\"", "|", "while read line; do echo ${line#app-web-}; done", raise_on_non_zero_exit: false) .with(:docker, :ps, "--filter", "label=service=app", "--filter", "label=destination=", "--filter", "label=role=web", "--format", "\"{{.Names}}\"", "|", "while read line; do echo ${line#app-web-}; done", raise_on_non_zero_exit: false)
.returns("12345678\n87654321\n") .returns("12345678\n87654321\n")
SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)
.with(:sh, "-c", "'docker ps --latest --format '\\''{{.Names}}'\\'' --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --format '\\''{{.Names}}'\\'' --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting'", "|", :head, "-1", "|", "while read line; do echo ${line#app-web-}; done", raise_on_non_zero_exit: false) .with(:sh, "-c", "'docker ps --latest --format '\\''{{.Names}}'\\'' --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --format '\\''{{.Names}}'\\'' --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting'", "|", :head, "-1", "|", "while read line; do echo ${line#app-web-}; done", raise_on_non_zero_exit: false)
.returns("12345678\n") .returns("12345678\n")
run_command("stale_containers").tap do |output| run_command("stale_containers").tap do |output|
@@ -216,11 +216,11 @@ class CliAppTest < CliTestCase
test "stop stale_containers" do test "stop stale_containers" do
SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)
.with(:docker, :ps, "--filter", "label=service=app", "--filter", "label=role=web", "--format", "\"{{.Names}}\"", "|", "while read line; do echo ${line#app-web-}; done", raise_on_non_zero_exit: false) .with(:docker, :ps, "--filter", "label=service=app", "--filter", "label=destination=", "--filter", "label=role=web", "--format", "\"{{.Names}}\"", "|", "while read line; do echo ${line#app-web-}; done", raise_on_non_zero_exit: false)
.returns("12345678\n87654321\n") .returns("12345678\n87654321\n")
SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)
.with(:sh, "-c", "'docker ps --latest --format '\\''{{.Names}}'\\'' --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --format '\\''{{.Names}}'\\'' --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting'", "|", :head, "-1", "|", "while read line; do echo ${line#app-web-}; done", raise_on_non_zero_exit: false) .with(:sh, "-c", "'docker ps --latest --format '\\''{{.Names}}'\\'' --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --format '\\''{{.Names}}'\\'' --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting'", "|", :head, "-1", "|", "while read line; do echo ${line#app-web-}; done", raise_on_non_zero_exit: false)
.returns("12345678\n") .returns("12345678\n")
run_command("stale_containers", "--stop").tap do |output| run_command("stale_containers", "--stop").tap do |output|
@@ -231,13 +231,13 @@ class CliAppTest < CliTestCase
test "details" do test "details" do
run_command("details").tap do |output| run_command("details").tap do |output|
assert_match "docker ps --filter label=service=app --filter label=role=web", output assert_match "docker ps --filter label=service=app --filter label=destination= --filter label=role=web", output
end end
end end
test "remove" do test "remove" do
run_command("remove").tap do |output| run_command("remove").tap do |output|
assert_match /#{Regexp.escape("sh -c 'docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting' | head -1 | xargs docker stop")}/, output assert_match /#{Regexp.escape("sh -c 'docker ps --latest --quiet --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting' | head -1 | xargs docker stop")}/, output
assert_match /#{Regexp.escape("docker container prune --force --filter label=service=app")}/, output assert_match /#{Regexp.escape("docker container prune --force --filter label=service=app")}/, output
assert_match /#{Regexp.escape("docker image prune --all --force --filter label=service=app")}/, output assert_match /#{Regexp.escape("docker image prune --all --force --filter label=service=app")}/, output
end end
@@ -263,26 +263,50 @@ class CliAppTest < CliTestCase
test "exec" do test "exec" do
run_command("exec", "ruby -v").tap do |output| run_command("exec", "ruby -v").tap do |output|
assert_match "docker run --rm --network kamal --env-file .kamal/apps/app/env/roles/web.env dhh/app:latest ruby -v", output assert_match "docker run --rm --network kamal --env-file .kamal/apps/app/env/roles/web.env --log-opt max-size=\"10m\" dhh/app:latest ruby -v", output
end end
end end
test "exec separate arguments" do test "exec separate arguments" do
run_command("exec", "ruby", " -v").tap do |output| run_command("exec", "ruby", " -v").tap do |output|
assert_match "docker run --rm --network kamal --env-file .kamal/apps/app/env/roles/web.env dhh/app:latest ruby -v", output assert_match "docker run --rm --network kamal --env-file .kamal/apps/app/env/roles/web.env --log-opt max-size=\"10m\" dhh/app:latest ruby -v", output
end
end
test "exec detach" do
run_command("exec", "--detach", "ruby -v").tap do |output|
assert_match "docker run --detach --network kamal --env-file .kamal/apps/app/env/roles/web.env --log-opt max-size=\"10m\" dhh/app:latest ruby -v", output
end
end
test "exec detach with reuse" do
assert_raises(ArgumentError, "Detach is not compatible with reuse") do
run_command("exec", "--detach", "--reuse", "ruby -v")
end
end
test "exec detach with interactive" do
assert_raises(ArgumentError, "Detach is not compatible with interactive") do
run_command("exec", "--interactive", "--detach", "ruby -v")
end
end
test "exec detach with interactive and reuse" do
assert_raises(ArgumentError, "Detach is not compatible with interactive or reuse") do
run_command("exec", "--interactive", "--detach", "--reuse", "ruby -v")
end end
end end
test "exec with reuse" do test "exec with reuse" do
run_command("exec", "--reuse", "ruby -v").tap do |output| run_command("exec", "--reuse", "ruby -v").tap do |output|
assert_match "sh -c 'docker ps --latest --format '\\''{{.Names}}'\\'' --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --format '\\''{{.Names}}'\\'' --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting' | head -1 | while read line; do echo ${line#app-web-}; done", output # Get current version assert_match "sh -c 'docker ps --latest --format '\\''{{.Names}}'\\'' --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --format '\\''{{.Names}}'\\'' --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting' | head -1 | while read line; do echo ${line#app-web-}; done", output # Get current version
assert_match "docker exec app-web-999 ruby -v", output assert_match "docker exec app-web-999 ruby -v", output
end end
end end
test "exec interactive" do test "exec interactive" do
SSHKit::Backend::Abstract.any_instance.expects(:exec) SSHKit::Backend::Abstract.any_instance.expects(:exec)
.with("ssh -t root@1.1.1.1 -p 22 'docker run -it --rm --network kamal --env-file .kamal/apps/app/env/roles/web.env dhh/app:latest ruby -v'") .with("ssh -t root@1.1.1.1 -p 22 'docker run -it --rm --network kamal --env-file .kamal/apps/app/env/roles/web.env --log-opt max-size=\"10m\" dhh/app:latest ruby -v'")
run_command("exec", "-i", "ruby -v").tap do |output| run_command("exec", "-i", "ruby -v").tap do |output|
assert_match "Get most recent version available as an image...", output assert_match "Get most recent version available as an image...", output
assert_match "Launching interactive command with version latest via SSH from new container on 1.1.1.1...", output assert_match "Launching interactive command with version latest via SSH from new container on 1.1.1.1...", output
@@ -294,7 +318,7 @@ class CliAppTest < CliTestCase
.with("ssh -t root@1.1.1.1 -p 22 'docker exec -it app-web-999 ruby -v'") .with("ssh -t root@1.1.1.1 -p 22 'docker exec -it app-web-999 ruby -v'")
run_command("exec", "-i", "--reuse", "ruby -v").tap do |output| run_command("exec", "-i", "--reuse", "ruby -v").tap do |output|
assert_match "Get current version of running container...", output assert_match "Get current version of running container...", output
assert_match "Running /usr/bin/env sh -c 'docker ps --latest --format '\\''{{.Names}}'\\'' --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --format '\\''{{.Names}}'\\'' --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting' | head -1 | while read line; do echo ${line#app-web-}; done on 1.1.1.1", output assert_match "Running /usr/bin/env sh -c 'docker ps --latest --format '\\''{{.Names}}'\\'' --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --format '\\''{{.Names}}'\\'' --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting' | head -1 | while read line; do echo ${line#app-web-}; done on 1.1.1.1", output
assert_match "Launching interactive command with version 999 via SSH from existing container on 1.1.1.1...", output assert_match "Launching interactive command with version 999 via SSH from existing container on 1.1.1.1...", output
end end
end end
@@ -313,46 +337,53 @@ class CliAppTest < CliTestCase
test "logs" do test "logs" do
SSHKit::Backend::Abstract.any_instance.stubs(:exec) SSHKit::Backend::Abstract.any_instance.stubs(:exec)
.with("ssh -t root@1.1.1.1 'sh -c 'docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting' | head -1| xargs docker logs --timestamps --tail 10 2>&1'") .with("ssh -t root@1.1.1.1 'sh -c 'docker ps --latest --quiet --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting' | head -1| xargs docker logs --timestamps --tail 10 2>&1'")
assert_match "sh -c 'docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting' | head -1 | xargs docker logs --timestamps --tail 100 2>&1", run_command("logs") assert_match "sh -c 'docker ps --latest --quiet --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting' | head -1 | xargs docker logs --timestamps --tail 100 2>&1", run_command("logs")
assert_match "sh -c 'docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting' | head -1 | xargs docker logs --timestamps 2>&1 | grep 'hey'", run_command("logs", "--grep", "hey") assert_match "sh -c 'docker ps --latest --quiet --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting' | head -1 | xargs docker logs --timestamps 2>&1 | grep 'hey'", run_command("logs", "--grep", "hey")
assert_match "sh -c 'docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting' | head -1 | xargs docker logs --timestamps 2>&1 | grep 'hey' -C 2", run_command("logs", "--grep", "hey", "--grep-options", "-C 2") assert_match "sh -c 'docker ps --latest --quiet --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting' | head -1 | xargs docker logs --timestamps 2>&1 | grep 'hey' -C 2", run_command("logs", "--grep", "hey", "--grep-options", "-C 2")
end end
test "logs with follow" do test "logs with follow" do
SSHKit::Backend::Abstract.any_instance.stubs(:exec) SSHKit::Backend::Abstract.any_instance.stubs(:exec)
.with("ssh -t root@1.1.1.1 -p 22 'sh -c '\\''docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''\\'\\'''\\''{{.ID}}'\\''\\'\\'''\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting'\\'' | head -1 | xargs docker logs --timestamps --tail 10 --follow 2>&1'") .with("ssh -t root@1.1.1.1 -p 22 'sh -c '\\''docker ps --latest --quiet --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''\\'\\'''\\''{{.ID}}'\\''\\'\\'''\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting'\\'' | head -1 | xargs docker logs --timestamps --tail 10 --follow 2>&1'")
assert_match "sh -c '\\''docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''\\'\\'''\\''{{.ID}}'\\''\\'\\'''\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting'\\'' | head -1 | xargs docker logs --timestamps --tail 10 --follow 2>&1", run_command("logs", "--follow") assert_match "sh -c '\\''docker ps --latest --quiet --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''\\'\\'''\\''{{.ID}}'\\''\\'\\'''\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting'\\'' | head -1 | xargs docker logs --timestamps --tail 10 --follow 2>&1", run_command("logs", "--follow")
end
test "logs with follow and container_id" do
SSHKit::Backend::Abstract.any_instance.stubs(:exec)
.with("ssh -t root@1.1.1.1 -p 22 'echo ID123 | xargs docker logs --timestamps --tail 10 --follow 2>&1'")
assert_match "echo ID123 | xargs docker logs --timestamps --tail 10 --follow 2>&1", run_command("logs", "--follow", "--container-id", "ID123")
end end
test "logs with follow and grep" do test "logs with follow and grep" do
SSHKit::Backend::Abstract.any_instance.stubs(:exec) SSHKit::Backend::Abstract.any_instance.stubs(:exec)
.with("ssh -t root@1.1.1.1 -p 22 'sh -c '\\''docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''\\'\\'''\\''{{.ID}}'\\''\\'\\'''\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting'\\'' | head -1 | xargs docker logs --timestamps --follow 2>&1 | grep \"hey\"'") .with("ssh -t root@1.1.1.1 -p 22 'sh -c '\\''docker ps --latest --quiet --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''\\'\\'''\\''{{.ID}}'\\''\\'\\'''\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting'\\'' | head -1 | xargs docker logs --timestamps --follow 2>&1 | grep \"hey\"'")
assert_match "sh -c '\\''docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''\\'\\'''\\''{{.ID}}'\\''\\'\\'''\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting'\\'' | head -1 | xargs docker logs --timestamps --follow 2>&1 | grep \"hey\"", run_command("logs", "--follow", "--grep", "hey") assert_match "sh -c '\\''docker ps --latest --quiet --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''\\'\\'''\\''{{.ID}}'\\''\\'\\'''\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting'\\'' | head -1 | xargs docker logs --timestamps --follow 2>&1 | grep \"hey\"", run_command("logs", "--follow", "--grep", "hey")
end end
test "logs with follow, grep and grep options" do test "logs with follow, grep and grep options" do
SSHKit::Backend::Abstract.any_instance.stubs(:exec) SSHKit::Backend::Abstract.any_instance.stubs(:exec)
.with("ssh -t root@1.1.1.1 -p 22 'sh -c '\\''docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''\\'\\'''\\''{{.ID}}'\\''\\'\\'''\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting'\\'' | head -1 | xargs docker logs --timestamps --follow 2>&1 | grep \"hey\" -C 2'") .with("ssh -t root@1.1.1.1 -p 22 'sh -c '\\''docker ps --latest --quiet --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''\\'\\'''\\''{{.ID}}'\\''\\'\\'''\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting'\\'' | head -1 | xargs docker logs --timestamps --follow 2>&1 | grep \"hey\" -C 2'")
assert_match "sh -c '\\''docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''\\'\\'''\\''{{.ID}}'\\''\\'\\'''\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting'\\'' | head -1 | xargs docker logs --timestamps --follow 2>&1 | grep \"hey\" -C 2", run_command("logs", "--follow", "--grep", "hey", "--grep-options", "-C 2") assert_match "sh -c '\\''docker ps --latest --quiet --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''\\'\\'''\\''{{.ID}}'\\''\\'\\'''\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting'\\'' | head -1 | xargs docker logs --timestamps --follow 2>&1 | grep \"hey\" -C 2", run_command("logs", "--follow", "--grep", "hey", "--grep-options", "-C 2")
end end
test "version" do test "version" do
run_command("version").tap do |output| run_command("version").tap do |output|
assert_match "sh -c 'docker ps --latest --format '\\''{{.Names}}'\\'' --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --format '\\''{{.Names}}'\\'' --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting' | head -1 | while read line; do echo ${line#app-web-}; done", output assert_match "sh -c 'docker ps --latest --format '\\''{{.Names}}'\\'' --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --format '\\''{{.Names}}'\\'' --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting' | head -1 | while read line; do echo ${line#app-web-}; done", output
end end
end end
test "version through main" do test "version through main" do
stdouted { Kamal::Cli::Main.start([ "app", "version", "-c", "test/fixtures/deploy_with_accessories.yml", "--hosts", "1.1.1.1" ]) }.tap do |output| stdouted { Kamal::Cli::Main.start([ "app", "version", "-c", "test/fixtures/deploy_with_accessories.yml", "--hosts", "1.1.1.1" ]) }.tap do |output|
assert_match "sh -c 'docker ps --latest --format '\\''{{.Names}}'\\'' --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --format '\\''{{.Names}}'\\'' --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting' | head -1 | while read line; do echo ${line#app-web-}; done", output assert_match "sh -c 'docker ps --latest --format '\\''{{.Names}}'\\'' --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --format '\\''{{.Names}}'\\'' --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting' | head -1 | while read line; do echo ${line#app-web-}; done", output
end end
end end

View File

@@ -250,7 +250,7 @@ class CliMainTest < CliTestCase
.with(:docker, :container, :ls, "--all", "--filter", "name=^app-#{role}-123$", "--quiet") .with(:docker, :container, :ls, "--all", "--filter", "name=^app-#{role}-123$", "--quiet")
.returns("version-to-rollback\n").at_least_once .returns("version-to-rollback\n").at_least_once
SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)
.with(:sh, "-c", "'docker ps --latest --format '\\''{{.Names}}'\\'' --filter label=service=app --filter label=role=#{role} --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --format '\\''{{.Names}}'\\'' --filter label=service=app --filter label=role=#{role} --filter status=running --filter status=restarting'", "|", :head, "-1", "|", "while read line; do echo ${line#app-#{role}-}; done", raise_on_non_zero_exit: false) .with(:sh, "-c", "'docker ps --latest --format '\\''{{.Names}}'\\'' --filter label=service=app --filter label=destination= --filter label=role=#{role} --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --format '\\''{{.Names}}'\\'' --filter label=service=app --filter label=destination= --filter label=role=#{role} --filter status=running --filter status=restarting'", "|", :head, "-1", "|", "while read line; do echo ${line#app-#{role}-}; done", raise_on_non_zero_exit: false)
.returns("version-to-rollback\n").at_least_once .returns("version-to-rollback\n").at_least_once
end end
@@ -280,7 +280,7 @@ class CliMainTest < CliTestCase
.with(:docker, :container, :ls, "--all", "--filter", "name=^app-web-123$", "--quiet") .with(:docker, :container, :ls, "--all", "--filter", "name=^app-web-123$", "--quiet")
.returns("123").at_least_once .returns("123").at_least_once
SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info) SSHKit::Backend::Abstract.any_instance.expects(:capture_with_info)
.with(:sh, "-c", "'docker ps --latest --format '\\''{{.Names}}'\\'' --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --format '\\''{{.Names}}'\\'' --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting'", "|", :head, "-1", "|", "while read line; do echo ${line#app-web-}; done", raise_on_non_zero_exit: false) .with(:sh, "-c", "'docker ps --latest --format '\\''{{.Names}}'\\'' --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --format '\\''{{.Names}}'\\'' --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting'", "|", :head, "-1", "|", "while read line; do echo ${line#app-web-}; done", raise_on_non_zero_exit: false)
.returns("").at_least_once .returns("").at_least_once
run_command("rollback", "123").tap do |output| run_command("rollback", "123").tap do |output|

View File

@@ -7,6 +7,18 @@ class CliSecretsTest < CliTestCase
run_command("fetch", "foo", "bar", "baz", "--account", "myaccount", "--adapter", "test") run_command("fetch", "foo", "bar", "baz", "--account", "myaccount", "--adapter", "test")
end end
test "fetch missing --acount" do
assert_equal \
"No value provided for required options '--account'",
run_command("fetch", "foo", "bar", "baz", "--adapter", "test")
end
test "fetch without required --account" do
assert_equal \
"\\{\\\"foo\\\":\\\"oof\\\",\\\"bar\\\":\\\"rab\\\",\\\"baz\\\":\\\"zab\\\"\\}",
run_command("fetch", "foo", "bar", "baz", "--adapter", "test_optional_account")
end
test "extract" do test "extract" do
assert_equal "oof", run_command("extract", "foo", "{\"foo\":\"oof\", \"bar\":\"rab\", \"baz\":\"zab\"}") assert_equal "oof", run_command("extract", "foo", "{\"foo\":\"oof\", \"bar\":\"rab\", \"baz\":\"zab\"}")
end end

View File

@@ -39,7 +39,10 @@ class CommandsAccessoryTest < ActiveSupport::TestCase
"busybox" => { "busybox" => {
"service" => "custom-busybox", "service" => "custom-busybox",
"image" => "busybox:latest", "image" => "busybox:latest",
"host" => "1.1.1.7" "host" => "1.1.1.7",
"proxy" => {
"host" => "busybox.example.com"
}
} }
} }
} }
@@ -166,6 +169,18 @@ class CommandsAccessoryTest < ActiveSupport::TestCase
new_command(:mysql).remove_image.join(" ") new_command(:mysql).remove_image.join(" ")
end end
test "deploy" do
assert_equal \
"docker exec kamal-proxy kamal-proxy deploy custom-busybox --target=\"172.1.0.2:80\" --host=\"busybox.example.com\" --deploy-timeout=\"30s\" --drain-timeout=\"30s\" --buffer-requests --buffer-responses --log-request-header=\"Cache-Control\" --log-request-header=\"Last-Modified\" --log-request-header=\"User-Agent\"",
new_command(:busybox).deploy(target: "172.1.0.2").join(" ")
end
test "remove" do
assert_equal \
"docker exec kamal-proxy kamal-proxy remove custom-busybox",
new_command(:busybox).remove.join(" ")
end
private private
def new_command(accessory) def new_command(accessory)
Kamal::Commands::Accessory.new(Kamal::Configuration.new(@config), name: accessory) Kamal::Commands::Accessory.new(Kamal::Configuration.new(@config), name: accessory)

View File

@@ -79,18 +79,18 @@ class CommandsAppTest < ActiveSupport::TestCase
test "stop" do test "stop" do
assert_equal \ assert_equal \
"sh -c 'docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting' | head -1 | xargs docker stop", "sh -c 'docker ps --latest --quiet --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting' | head -1 | xargs docker stop",
new_command.stop.join(" ") new_command.stop.join(" ")
end end
test "stop with custom drain timeout" do test "stop with custom drain timeout" do
@config[:drain_timeout] = 20 @config[:drain_timeout] = 20
assert_equal \ assert_equal \
"sh -c 'docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting' | head -1 | xargs docker stop", "sh -c 'docker ps --latest --quiet --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting' | head -1 | xargs docker stop",
new_command.stop.join(" ") new_command.stop.join(" ")
assert_equal \ assert_equal \
"sh -c 'docker ps --latest --quiet --filter label=service=app --filter label=role=workers --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=role=workers --filter status=running --filter status=restarting' | head -1 | xargs docker stop -t 20", "sh -c 'docker ps --latest --quiet --filter label=service=app --filter label=destination= --filter label=role=workers --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=destination= --filter label=role=workers --filter status=running --filter status=restarting' | head -1 | xargs docker stop -t 20",
new_command(role: "workers").stop.join(" ") new_command(role: "workers").stop.join(" ")
end end
@@ -102,7 +102,7 @@ class CommandsAppTest < ActiveSupport::TestCase
test "info" do test "info" do
assert_equal \ assert_equal \
"docker ps --filter label=service=app --filter label=role=web", "docker ps --filter label=service=app --filter label=destination= --filter label=role=web",
new_command.info.join(" ") new_command.info.join(" ")
end end
@@ -153,100 +153,124 @@ class CommandsAppTest < ActiveSupport::TestCase
test "logs" do test "logs" do
assert_equal \ assert_equal \
"sh -c 'docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting' | head -1 | xargs docker logs --timestamps 2>&1", "sh -c 'docker ps --latest --quiet --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting' | head -1 | xargs docker logs --timestamps 2>&1",
new_command.logs.join(" ") new_command.logs.join(" ")
end end
test "logs with container_id" do
assert_equal \
"echo C137 | xargs docker logs --timestamps 2>&1",
new_command.logs(container_id: "C137").join(" ")
end
test "logs with since" do test "logs with since" do
assert_equal \ assert_equal \
"sh -c 'docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting' | head -1 | xargs docker logs --timestamps --since 5m 2>&1", "sh -c 'docker ps --latest --quiet --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting' | head -1 | xargs docker logs --timestamps --since 5m 2>&1",
new_command.logs(since: "5m").join(" ") new_command.logs(since: "5m").join(" ")
end end
test "logs with lines" do test "logs with lines" do
assert_equal \ assert_equal \
"sh -c 'docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting' | head -1 | xargs docker logs --timestamps --tail 100 2>&1", "sh -c 'docker ps --latest --quiet --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting' | head -1 | xargs docker logs --timestamps --tail 100 2>&1",
new_command.logs(lines: "100").join(" ") new_command.logs(lines: "100").join(" ")
end end
test "logs with since and lines" do test "logs with since and lines" do
assert_equal \ assert_equal \
"sh -c 'docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting' | head -1 | xargs docker logs --timestamps --since 5m --tail 100 2>&1", "sh -c 'docker ps --latest --quiet --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting' | head -1 | xargs docker logs --timestamps --since 5m --tail 100 2>&1",
new_command.logs(since: "5m", lines: "100").join(" ") new_command.logs(since: "5m", lines: "100").join(" ")
end end
test "logs with grep" do test "logs with grep" do
assert_equal \ assert_equal \
"sh -c 'docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting' | head -1 | xargs docker logs --timestamps 2>&1 | grep 'my-id'", "sh -c 'docker ps --latest --quiet --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting' | head -1 | xargs docker logs --timestamps 2>&1 | grep 'my-id'",
new_command.logs(grep: "my-id").join(" ") new_command.logs(grep: "my-id").join(" ")
end end
test "logs with grep and grep options" do test "logs with grep and grep options" do
assert_equal \ assert_equal \
"sh -c 'docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting' | head -1 | xargs docker logs --timestamps 2>&1 | grep 'my-id' -C 2", "sh -c 'docker ps --latest --quiet --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting' | head -1 | xargs docker logs --timestamps 2>&1 | grep 'my-id' -C 2",
new_command.logs(grep: "my-id", grep_options: "-C 2").join(" ") new_command.logs(grep: "my-id", grep_options: "-C 2").join(" ")
end end
test "logs with since, grep and grep options" do test "logs with since, grep and grep options" do
assert_equal \ assert_equal \
"sh -c 'docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting' | head -1 | xargs docker logs --timestamps --since 5m 2>&1 | grep 'my-id' -C 2", "sh -c 'docker ps --latest --quiet --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting' | head -1 | xargs docker logs --timestamps --since 5m 2>&1 | grep 'my-id' -C 2",
new_command.logs(since: "5m", grep: "my-id", grep_options: "-C 2").join(" ") new_command.logs(since: "5m", grep: "my-id", grep_options: "-C 2").join(" ")
end end
test "logs with since and grep" do test "logs with since and grep" do
assert_equal \ assert_equal \
"sh -c 'docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting' | head -1 | xargs docker logs --timestamps --since 5m 2>&1 | grep 'my-id'", "sh -c 'docker ps --latest --quiet --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting' | head -1 | xargs docker logs --timestamps --since 5m 2>&1 | grep 'my-id'",
new_command.logs(since: "5m", grep: "my-id").join(" ") new_command.logs(since: "5m", grep: "my-id").join(" ")
end end
test "follow logs" do test "follow logs" do
assert_equal \ assert_equal \
"ssh -t root@app-1 -p 22 'sh -c '\\''docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''\\'\\'''\\''{{.ID}}'\\''\\'\\'''\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting'\\'' | head -1 | xargs docker logs --timestamps --follow 2>&1'", "ssh -t root@app-1 -p 22 'sh -c '\\''docker ps --latest --quiet --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''\\'\\'''\\''{{.ID}}'\\''\\'\\'''\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting'\\'' | head -1 | xargs docker logs --timestamps --follow 2>&1'",
new_command.follow_logs(host: "app-1") new_command.follow_logs(host: "app-1")
assert_equal \ assert_equal \
"ssh -t root@app-1 -p 22 'sh -c '\\''docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''\\'\\'''\\''{{.ID}}'\\''\\'\\'''\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting'\\'' | head -1 | xargs docker logs --timestamps --follow 2>&1 | grep \"Completed\"'", "ssh -t root@app-1 -p 22 'sh -c '\\''docker ps --latest --quiet --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''\\'\\'''\\''{{.ID}}'\\''\\'\\'''\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting'\\'' | head -1 | xargs docker logs --timestamps --follow 2>&1 | grep \"Completed\"'",
new_command.follow_logs(host: "app-1", grep: "Completed") new_command.follow_logs(host: "app-1", grep: "Completed")
assert_equal \ assert_equal \
"ssh -t root@app-1 -p 22 'sh -c '\\''docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''\\'\\'''\\''{{.ID}}'\\''\\'\\'''\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting'\\'' | head -1 | xargs docker logs --timestamps --tail 123 --follow 2>&1'", "ssh -t root@app-1 -p 22 'echo ID321 | xargs docker logs --timestamps --follow 2>&1'",
new_command.follow_logs(host: "app-1", container_id: "ID321")
assert_equal \
"ssh -t root@app-1 -p 22 'sh -c '\\''docker ps --latest --quiet --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''\\'\\'''\\''{{.ID}}'\\''\\'\\'''\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting'\\'' | head -1 | xargs docker logs --timestamps --tail 123 --follow 2>&1'",
new_command.follow_logs(host: "app-1", lines: 123) new_command.follow_logs(host: "app-1", lines: 123)
assert_equal \ assert_equal \
"ssh -t root@app-1 -p 22 'sh -c '\\''docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''\\'\\'''\\''{{.ID}}'\\''\\'\\'''\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting'\\'' | head -1 | xargs docker logs --timestamps --tail 123 --follow 2>&1 | grep \"Completed\"'", "ssh -t root@app-1 -p 22 'sh -c '\\''docker ps --latest --quiet --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''\\'\\'''\\''{{.ID}}'\\''\\'\\'''\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting'\\'' | head -1 | xargs docker logs --timestamps --tail 123 --follow 2>&1 | grep \"Completed\"'",
new_command.follow_logs(host: "app-1", lines: 123, grep: "Completed") new_command.follow_logs(host: "app-1", lines: 123, grep: "Completed")
assert_equal \ assert_equal \
"ssh -t root@app-1 -p 22 'sh -c '\\''docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''\\'\\'''\\''{{.ID}}'\\''\\'\\'''\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting'\\'' | head -1 | xargs docker logs --tail 123 --follow 2>&1 | grep \"Completed\"'", "ssh -t root@app-1 -p 22 'sh -c '\\''docker ps --latest --quiet --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''\\'\\'''\\''{{.ID}}'\\''\\'\\'''\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting'\\'' | head -1 | xargs docker logs --tail 123 --follow 2>&1 | grep \"Completed\"'",
new_command.follow_logs(host: "app-1", timestamps: false, lines: 123, grep: "Completed") new_command.follow_logs(host: "app-1", timestamps: false, lines: 123, grep: "Completed")
end end
test "execute in new container" do test "execute in new container" do
assert_equal \ assert_equal \
"docker run --rm --network kamal --env-file .kamal/apps/app/env/roles/web.env dhh/app:999 bin/rails db:setup", "docker run --rm --network kamal --env-file .kamal/apps/app/env/roles/web.env --log-opt max-size=\"10m\" dhh/app:999 bin/rails db:setup",
new_command.execute_in_new_container("bin/rails", "db:setup", env: {}).join(" ")
end
test "execute in new container with logging" do
@config[:logging] = { "driver" => "local", "options" => { "max-size" => "100m", "max-file" => "3" } }
assert_equal \
"docker run --rm --network kamal --env-file .kamal/apps/app/env/roles/web.env --log-driver \"local\" --log-opt max-size=\"100m\" --log-opt max-file=\"3\" dhh/app:999 bin/rails db:setup",
new_command.execute_in_new_container("bin/rails", "db:setup", env: {}).join(" ") new_command.execute_in_new_container("bin/rails", "db:setup", env: {}).join(" ")
end end
test "execute in new container with env" do test "execute in new container with env" do
assert_equal \ assert_equal \
"docker run --rm --network kamal --env-file .kamal/apps/app/env/roles/web.env --env foo=\"bar\" dhh/app:999 bin/rails db:setup", "docker run --rm --network kamal --env-file .kamal/apps/app/env/roles/web.env --env foo=\"bar\" --log-opt max-size=\"10m\" dhh/app:999 bin/rails db:setup",
new_command.execute_in_new_container("bin/rails", "db:setup", env: { "foo" => "bar" }).join(" ") new_command.execute_in_new_container("bin/rails", "db:setup", env: { "foo" => "bar" }).join(" ")
end end
test "execute in new detached container" do
assert_equal \
"docker run --detach --network kamal --env-file .kamal/apps/app/env/roles/web.env --log-opt max-size=\"10m\" dhh/app:999 bin/rails db:setup",
new_command.execute_in_new_container("bin/rails", "db:setup", detach: true, env: {}).join(" ")
end
test "execute in new container with tags" do test "execute in new container with tags" do
@config[:servers] = [ { "1.1.1.1" => "tag1" } ] @config[:servers] = [ { "1.1.1.1" => "tag1" } ]
@config[:env]["tags"] = { "tag1" => { "ENV1" => "value1" } } @config[:env]["tags"] = { "tag1" => { "ENV1" => "value1" } }
assert_equal \ assert_equal \
"docker run --rm --network kamal --env ENV1=\"value1\" --env-file .kamal/apps/app/env/roles/web.env dhh/app:999 bin/rails db:setup", "docker run --rm --network kamal --env ENV1=\"value1\" --env-file .kamal/apps/app/env/roles/web.env --log-opt max-size=\"10m\" dhh/app:999 bin/rails db:setup",
new_command.execute_in_new_container("bin/rails", "db:setup", env: {}).join(" ") new_command.execute_in_new_container("bin/rails", "db:setup", env: {}).join(" ")
end end
test "execute in new container with custom options" do test "execute in new container with custom options" do
@config[:servers] = { "web" => { "hosts" => [ "1.1.1.1" ], "options" => { "mount" => "somewhere", "cap-add" => true } } } @config[:servers] = { "web" => { "hosts" => [ "1.1.1.1" ], "options" => { "mount" => "somewhere", "cap-add" => true } } }
assert_equal \ assert_equal \
"docker run --rm --network kamal --env-file .kamal/apps/app/env/roles/web.env --mount \"somewhere\" --cap-add dhh/app:999 bin/rails db:setup", "docker run --rm --network kamal --env-file .kamal/apps/app/env/roles/web.env --log-opt max-size=\"10m\" --mount \"somewhere\" --cap-add dhh/app:999 bin/rails db:setup",
new_command.execute_in_new_container("bin/rails", "db:setup", env: {}).join(" ") new_command.execute_in_new_container("bin/rails", "db:setup", env: {}).join(" ")
end end
@@ -263,7 +287,7 @@ class CommandsAppTest < ActiveSupport::TestCase
end end
test "execute in new container over ssh" do test "execute in new container over ssh" do
assert_match %r{docker run -it --rm --network kamal --env-file .kamal/apps/app/env/roles/web.env dhh/app:999 bin/rails c}, assert_match %r{docker run -it --rm --network kamal --env-file .kamal/apps/app/env/roles/web.env --log-opt max-size="10m" dhh/app:999 bin/rails c},
new_command.execute_in_new_container_over_ssh("bin/rails", "c", env: {}) new_command.execute_in_new_container_over_ssh("bin/rails", "c", env: {})
end end
@@ -271,13 +295,13 @@ class CommandsAppTest < ActiveSupport::TestCase
@config[:servers] = [ { "1.1.1.1" => "tag1" } ] @config[:servers] = [ { "1.1.1.1" => "tag1" } ]
@config[:env]["tags"] = { "tag1" => { "ENV1" => "value1" } } @config[:env]["tags"] = { "tag1" => { "ENV1" => "value1" } }
assert_equal "ssh -t root@1.1.1.1 -p 22 'docker run -it --rm --network kamal --env ENV1=\"value1\" --env-file .kamal/apps/app/env/roles/web.env dhh/app:999 bin/rails c'", assert_equal "ssh -t root@1.1.1.1 -p 22 'docker run -it --rm --network kamal --env ENV1=\"value1\" --env-file .kamal/apps/app/env/roles/web.env --log-opt max-size=\"10m\" dhh/app:999 bin/rails c'",
new_command.execute_in_new_container_over_ssh("bin/rails", "c", env: {}) new_command.execute_in_new_container_over_ssh("bin/rails", "c", env: {})
end end
test "execute in new container with custom options over ssh" do test "execute in new container with custom options over ssh" do
@config[:servers] = { "web" => { "hosts" => [ "1.1.1.1" ], "options" => { "mount" => "somewhere", "cap-add" => true } } } @config[:servers] = { "web" => { "hosts" => [ "1.1.1.1" ], "options" => { "mount" => "somewhere", "cap-add" => true } } }
assert_match %r{docker run -it --rm --network kamal --env-file .kamal/apps/app/env/roles/web.env --mount \"somewhere\" --cap-add dhh/app:999 bin/rails c}, assert_match %r{docker run -it --rm --network kamal --env-file .kamal/apps/app/env/roles/web.env --log-opt max-size=\"10m\" --mount \"somewhere\" --cap-add dhh/app:999 bin/rails c},
new_command.execute_in_new_container_over_ssh("bin/rails", "c", env: {}) new_command.execute_in_new_container_over_ssh("bin/rails", "c", env: {})
end end
@@ -315,6 +339,16 @@ class CommandsAppTest < ActiveSupport::TestCase
assert_equal "ssh -J root@2.2.2.2 -t app@1.1.1.1 -p 22 'ls'", new_command.run_over_ssh("ls", host: "1.1.1.1") assert_equal "ssh -J root@2.2.2.2 -t app@1.1.1.1 -p 22 'ls'", new_command.run_over_ssh("ls", host: "1.1.1.1")
end end
test "run over ssh with keys config" do
@config[:ssh] = { "keys" => [ "path_to_key.pem" ] }
assert_equal "ssh -i path_to_key.pem -t root@1.1.1.1 -p 22 'ls'", new_command.run_over_ssh("ls", host: "1.1.1.1")
end
test "run over ssh with keys config with keys_only" do
@config[:ssh] = { "keys" => [ "path_to_key.pem" ], "keys_only" => true }
assert_equal "ssh -i path_to_key.pem -o IdentitiesOnly=yes -t root@1.1.1.1 -p 22 'ls'", new_command.run_over_ssh("ls", host: "1.1.1.1")
end
test "run over ssh with proxy_command" do test "run over ssh with proxy_command" do
@config[:ssh] = { "proxy_command" => "ssh -W %h:%p user@proxy-server" } @config[:ssh] = { "proxy_command" => "ssh -W %h:%p user@proxy-server" }
assert_equal "ssh -o ProxyCommand='ssh -W %h:%p user@proxy-server' -t root@1.1.1.1 -p 22 'ls'", new_command.run_over_ssh("ls", host: "1.1.1.1") assert_equal "ssh -o ProxyCommand='ssh -W %h:%p user@proxy-server' -t root@1.1.1.1 -p 22 'ls'", new_command.run_over_ssh("ls", host: "1.1.1.1")
@@ -322,7 +356,7 @@ class CommandsAppTest < ActiveSupport::TestCase
test "current_running_container_id" do test "current_running_container_id" do
assert_equal \ assert_equal \
"sh -c 'docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting' | head -1", "sh -c 'docker ps --latest --quiet --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --quiet --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting' | head -1",
new_command.current_running_container_id.join(" ") new_command.current_running_container_id.join(" ")
end end
@@ -341,23 +375,23 @@ class CommandsAppTest < ActiveSupport::TestCase
test "current_running_version" do test "current_running_version" do
assert_equal \ assert_equal \
"sh -c 'docker ps --latest --format '\\''{{.Names}}'\\'' --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --format '\\''{{.Names}}'\\'' --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting' | head -1 | while read line; do echo ${line#app-web-}; done", "sh -c 'docker ps --latest --format '\\''{{.Names}}'\\'' --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting --filter ancestor=$(docker image ls --filter reference=dhh/app:latest --format '\\''{{.ID}}'\\'') ; docker ps --latest --format '\\''{{.Names}}'\\'' --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting' | head -1 | while read line; do echo ${line#app-web-}; done",
new_command.current_running_version.join(" ") new_command.current_running_version.join(" ")
end end
test "list_versions" do test "list_versions" do
assert_equal \ assert_equal \
"docker ps --filter label=service=app --filter label=role=web --format \"{{.Names}}\" | while read line; do echo ${line#app-web-}; done", "docker ps --filter label=service=app --filter label=destination= --filter label=role=web --format \"{{.Names}}\" | while read line; do echo ${line#app-web-}; done",
new_command.list_versions.join(" ") new_command.list_versions.join(" ")
assert_equal \ assert_equal \
"docker ps --filter label=service=app --filter label=role=web --filter status=running --filter status=restarting --latest --format \"{{.Names}}\" | while read line; do echo ${line#app-web-}; done", "docker ps --filter label=service=app --filter label=destination= --filter label=role=web --filter status=running --filter status=restarting --latest --format \"{{.Names}}\" | while read line; do echo ${line#app-web-}; done",
new_command.list_versions("--latest", statuses: [ :running, :restarting ]).join(" ") new_command.list_versions("--latest", statuses: [ :running, :restarting ]).join(" ")
end end
test "list_containers" do test "list_containers" do
assert_equal \ assert_equal \
"docker container ls --all --filter label=service=app --filter label=role=web", "docker container ls --all --filter label=service=app --filter label=destination= --filter label=role=web",
new_command.list_containers.join(" ") new_command.list_containers.join(" ")
end end
@@ -370,7 +404,7 @@ class CommandsAppTest < ActiveSupport::TestCase
test "list_container_names" do test "list_container_names" do
assert_equal \ assert_equal \
"docker container ls --all --filter label=service=app --filter label=role=web --format '{{ .Names }}'", "docker container ls --all --filter label=service=app --filter label=destination= --filter label=role=web --format '{{ .Names }}'",
new_command.list_container_names.join(" ") new_command.list_container_names.join(" ")
end end
@@ -389,7 +423,7 @@ class CommandsAppTest < ActiveSupport::TestCase
test "remove_containers" do test "remove_containers" do
assert_equal \ assert_equal \
"docker container prune --force --filter label=service=app --filter label=role=web", "docker container prune --force --filter label=service=app --filter label=destination= --filter label=role=web",
new_command.remove_containers.join(" ") new_command.remove_containers.join(" ")
end end
@@ -408,14 +442,14 @@ class CommandsAppTest < ActiveSupport::TestCase
test "remove_images" do test "remove_images" do
assert_equal \ assert_equal \
"docker image prune --all --force --filter label=service=app --filter label=role=web", "docker image prune --all --force --filter label=service=app",
new_command.remove_images.join(" ") new_command.remove_images.join(" ")
end end
test "remove_images with destination" do test "remove_images with destination" do
@destination = "staging" @destination = "staging"
assert_equal \ assert_equal \
"docker image prune --all --force --filter label=service=app --filter label=destination=staging --filter label=role=web", "docker image prune --all --force --filter label=service=app",
new_command.remove_images.join(" ") new_command.remove_images.join(" ")
end end

View File

@@ -158,6 +158,20 @@ class CommandsBuilderTest < ActiveSupport::TestCase
builder.push.join(" ") builder.push.join(" ")
end end
test "push with sbom" do
builder = new_builder_command(builder: { "sbom" => true })
assert_equal \
"docker buildx build --push --platform linux/amd64 --builder kamal-local-docker-container -t dhh/app:123 -t dhh/app:latest --label service=\"app\" --file Dockerfile --sbom true .",
builder.push.join(" ")
end
test "push with sbom false" do
builder = new_builder_command(builder: { "sbom" => false })
assert_equal \
"docker buildx build --push --platform linux/amd64 --builder kamal-local-docker-container -t dhh/app:123 -t dhh/app:latest --label service=\"app\" --file Dockerfile --sbom false .",
builder.push.join(" ")
end
test "mirror count" do test "mirror count" do
command = new_builder_command command = new_builder_command
assert_equal "docker info --format '{{index .RegistryConfig.Mirrors 0}}'", command.first_mirror.join(" ") assert_equal "docker info --format '{{index .RegistryConfig.Mirrors 0}}'", command.first_mirror.join(" ")

View File

@@ -63,6 +63,9 @@ class ConfigurationAccessoryTest < ActiveSupport::TestCase
"options" => { "options" => {
"cpus" => "4", "cpus" => "4",
"memory" => "2GB" "memory" => "2GB"
},
"proxy" => {
"host" => "monitoring.example.com"
} }
} }
} }
@@ -161,4 +164,9 @@ class ConfigurationAccessoryTest < ActiveSupport::TestCase
@deploy[:accessories]["mysql"]["network"] = "database" @deploy[:accessories]["mysql"]["network"] = "database"
assert_equal [ "--network", "database" ], @config.accessory(:mysql).network_args assert_equal [ "--network", "database" ], @config.accessory(:mysql).network_args
end end
test "proxy" do
assert @config.accessory(:monitoring).running_proxy?
assert_equal [ "monitoring.example.com" ], @config.accessory(:monitoring).proxy.hosts
end
end end

View File

@@ -144,6 +144,16 @@ class ConfigurationBuilderTest < ActiveSupport::TestCase
assert_equal "mode=max", config.builder.provenance assert_equal "mode=max", config.builder.provenance
end end
test "sbom" do
assert_nil config.builder.sbom
end
test "setting sbom" do
@deploy[:builder]["sbom"] = true
assert_equal true, config.builder.sbom
end
test "local disabled but no remote set" do test "local disabled but no remote set" do
@deploy[:builder]["local"] = false @deploy[:builder]["local"] = false

View File

@@ -20,6 +20,7 @@ COPY *.sh .
COPY app/ app/ COPY app/ app/
COPY app_with_roles/ app_with_roles/ COPY app_with_roles/ app_with_roles/
COPY app_with_traefik/ app_with_traefik/ COPY app_with_traefik/ app_with_traefik/
COPY app_with_proxied_accessory/ app_with_proxied_accessory/
RUN rm -rf /root/.ssh RUN rm -rf /root/.ssh
RUN ln -s /shared/ssh /root/.ssh RUN ln -s /shared/ssh /root/.ssh
@@ -30,6 +31,7 @@ RUN git config --global user.name "Deployer"
RUN cd app && git init && git add . && git commit -am "Initial version" RUN cd app && git init && git add . && git commit -am "Initial version"
RUN cd app_with_roles && git init && git add . && git commit -am "Initial version" RUN cd app_with_roles && git init && git add . && git commit -am "Initial version"
RUN cd app_with_traefik && git init && git add . && git commit -am "Initial version" RUN cd app_with_traefik && git init && git add . && git commit -am "Initial version"
RUN cd app_with_proxied_accessory && git init && git add . && git commit -am "Initial version"
HEALTHCHECK --interval=1s CMD pgrep sleep HEALTHCHECK --interval=1s CMD pgrep sleep

View File

@@ -0,0 +1,9 @@
FROM registry:4443/nginx:1-alpine-slim
COPY default.conf /etc/nginx/conf.d/default.conf
ARG COMMIT_SHA
RUN echo $COMMIT_SHA > /usr/share/nginx/html/version
RUN mkdir -p /usr/share/nginx/html/versions && echo "version" > /usr/share/nginx/html/versions/$COMMIT_SHA
RUN mkdir -p /usr/share/nginx/html/versions && echo "hidden" > /usr/share/nginx/html/versions/.hidden
RUN echo "Up!" > /usr/share/nginx/html/up

View File

@@ -0,0 +1,44 @@
service: app_with_proxied_accessory
image: app_with_proxied_accessory
servers:
- vm1
env:
clear:
CLEAR_TOKEN: 4321
CLEAR_TAG: ""
HOST_TOKEN: "${HOST_TOKEN}"
asset_path: /usr/share/nginx/html/versions
proxy:
host: 127.0.0.1
registry:
server: registry:4443
username: root
password: root
builder:
driver: docker
arch: <%= Kamal::Utils.docker_arch %>
args:
COMMIT_SHA: <%= `git rev-parse HEAD` %>
accessories:
busybox:
service: custom-busybox
image: registry:4443/busybox:1.36.0
cmd: sh -c 'echo "Starting busybox..."; trap exit term; while true; do sleep 1; done'
roles:
- web
netcat:
service: netcat
image: registry:4443/busybox:1.36.0
cmd: >
sh -c 'echo "Starting netcat..."; while true; do echo -e "HTTP/1.1 200 OK\r\nContent-Length: 11\r\n\r\nHello Ruby" | nc -l -p 80; done'
roles:
- web
port: 12345:80
proxy:
host: netcat
ssl: false
healthcheck:
interval: 1
timeout: 1
path: "/"

View File

@@ -0,0 +1,17 @@
server {
listen 80;
listen [::]:80;
server_name localhost;
location / {
root /usr/share/nginx/html;
index index.html index.htm;
}
# redirect server error pages to the static page /50x.html
#
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /usr/share/nginx/html;
}
}

View File

@@ -0,0 +1,63 @@
require_relative "integration_test"
class ProxiedAccessoryTest < IntegrationTest
test "boot, stop, start, restart, logs, remove" do
@app = "app_with_proxied_accessory"
kamal :deploy
kamal :accessory, :boot, :netcat
assert_accessory_running :netcat
assert_netcat_is_up
kamal :accessory, :stop, :netcat
assert_accessory_not_running :netcat
assert_netcat_not_found
kamal :accessory, :start, :netcat
assert_accessory_running :netcat
assert_netcat_is_up
kamal :accessory, :restart, :netcat
assert_accessory_running :netcat
assert_netcat_is_up
kamal :accessory, :remove, :netcat, "-y"
assert_accessory_not_running :netcat
assert_netcat_not_found
end
private
def assert_accessory_running(name)
assert_match /registry:4443\/busybox:1.36.0 "sh -c 'echo \\"Start/, accessory_details(name)
end
def assert_accessory_not_running(name)
assert_no_match /registry:4443\/busybox:1.36.0 "sh -c 'echo \\"Start/, accessory_details(name)
end
def accessory_details(name)
kamal :accessory, :details, name, capture: true
end
def assert_netcat_is_up
response = netcat_response
debug_response_code(response, "200")
assert_equal "200", response.code
end
def assert_netcat_not_found
response = netcat_response
debug_response_code(response, "404")
assert_equal "404", response.code
end
def netcat_response
uri = URI.parse("http://127.0.0.1:12345/up")
http = Net::HTTP.new(uri.host, uri.port)
request = Net::HTTP::Get.new(uri)
request["Host"] = "netcat"
http.request(request)
end
end

View File

@@ -0,0 +1,169 @@
require "test_helper"
class AwsSecretsManagerAdapterTest < SecretAdapterTestCase
test "fails when errors are present" do
stub_ticks.with("aws --version 2> /dev/null")
stub_ticks
.with("aws secretsmanager batch-get-secret-value --secret-id-list unknown1 unknown2 --profile default")
.returns(<<~JSON)
{
"SecretValues": [],
"Errors": [
{
"SecretId": "unknown1",
"ErrorCode": "ResourceNotFoundException",
"Message": "Secrets Manager can't find the specified secret."
},
{
"SecretId": "unknown2",
"ErrorCode": "ResourceNotFoundException",
"Message": "Secrets Manager can't find the specified secret."
}
]
}
JSON
error = assert_raises RuntimeError do
JSON.parse(shellunescape(run_command("fetch", "unknown1", "unknown2")))
end
assert_equal [ "unknown1: Secrets Manager can't find the specified secret.", "unknown2: Secrets Manager can't find the specified secret." ].join(" "), error.message
end
test "fetch" do
stub_ticks.with("aws --version 2> /dev/null")
stub_ticks
.with("aws secretsmanager batch-get-secret-value --secret-id-list secret/KEY1 secret/KEY2 secret2/KEY3 --profile default")
.returns(<<~JSON)
{
"SecretValues": [
{
"ARN": "arn:aws:secretsmanager:us-east-1:aaaaaaaaaaaa:secret:secret",
"Name": "secret",
"VersionId": "vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv",
"SecretString": "{\\"KEY1\\":\\"VALUE1\\", \\"KEY2\\":\\"VALUE2\\"}",
"VersionStages": [
"AWSCURRENT"
],
"CreatedDate": "2024-01-01T00:00:00.000000"
},
{
"ARN": "arn:aws:secretsmanager:us-east-1:aaaaaaaaaaaa:secret:secret2",
"Name": "secret2",
"VersionId": "vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv",
"SecretString": "{\\"KEY3\\":\\"VALUE3\\"}",
"VersionStages": [
"AWSCURRENT"
],
"CreatedDate": "2024-01-01T00:00:00.000000"
}
],
"Errors": []
}
JSON
json = JSON.parse(shellunescape(run_command("fetch", "secret/KEY1", "secret/KEY2", "secret2/KEY3")))
expected_json = {
"secret/KEY1"=>"VALUE1",
"secret/KEY2"=>"VALUE2",
"secret2/KEY3"=>"VALUE3"
}
assert_equal expected_json, json
end
test "fetch with string value" do
stub_ticks.with("aws --version 2> /dev/null")
stub_ticks
.with("aws secretsmanager batch-get-secret-value --secret-id-list secret secret2/KEY1 --profile default")
.returns(<<~JSON)
{
"SecretValues": [
{
"ARN": "arn:aws:secretsmanager:us-east-1:aaaaaaaaaaaa:secret:secret",
"Name": "secret",
"VersionId": "vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv",
"SecretString": "a-string-secret",
"VersionStages": [
"AWSCURRENT"
],
"CreatedDate": "2024-01-01T00:00:00.000000"
},
{
"ARN": "arn:aws:secretsmanager:us-east-1:aaaaaaaaaaaa:secret:secret2",
"Name": "secret2",
"VersionId": "vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv",
"SecretString": "{\\"KEY2\\":\\"VALUE2\\"}",
"VersionStages": [
"AWSCURRENT"
],
"CreatedDate": "2024-01-01T00:00:00.000000"
}
],
"Errors": []
}
JSON
json = JSON.parse(shellunescape(run_command("fetch", "secret", "secret2/KEY1")))
expected_json = {
"secret"=>"a-string-secret",
"secret2/KEY2"=>"VALUE2"
}
assert_equal expected_json, json
end
test "fetch with secret names" do
stub_ticks.with("aws --version 2> /dev/null")
stub_ticks
.with("aws secretsmanager batch-get-secret-value --secret-id-list secret/KEY1 secret/KEY2 --profile default")
.returns(<<~JSON)
{
"SecretValues": [
{
"ARN": "arn:aws:secretsmanager:us-east-1:aaaaaaaaaaaa:secret:secret",
"Name": "secret",
"VersionId": "vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv",
"SecretString": "{\\"KEY1\\":\\"VALUE1\\", \\"KEY2\\":\\"VALUE2\\"}",
"VersionStages": [
"AWSCURRENT"
],
"CreatedDate": "2024-01-01T00:00:00.000000"
}
],
"Errors": []
}
JSON
json = JSON.parse(shellunescape(run_command("fetch", "--from", "secret", "KEY1", "KEY2")))
expected_json = {
"secret/KEY1"=>"VALUE1",
"secret/KEY2"=>"VALUE2"
}
assert_equal expected_json, json
end
test "fetch without CLI installed" do
stub_ticks_with("aws --version 2> /dev/null", succeed: false)
error = assert_raises RuntimeError do
JSON.parse(shellunescape(run_command("fetch", "SECRET1")))
end
assert_equal "AWS CLI is not installed", error.message
end
private
def run_command(*command)
stdouted do
Kamal::Cli::Secrets.start \
[ *command,
"-c", "test/fixtures/deploy_with_accessories.yml",
"--adapter", "aws_secrets_manager",
"--account", "default" ]
end
end
end

View File

@@ -0,0 +1,186 @@
require "test_helper"
class DopplerAdapterTest < SecretAdapterTestCase
setup do
`true` # Ensure $? is 0
end
test "fetch" do
stub_ticks_with("doppler --version 2> /dev/null", succeed: true)
stub_ticks.with("doppler me --json 2> /dev/null")
stub_ticks
.with("doppler secrets get SECRET1 FSECRET1 FSECRET2 --json -p my-project -c prd")
.returns(<<~JSON)
{
"SECRET1": {
"computed":"secret1",
"computedVisibility":"unmasked",
"note":""
},
"FSECRET1": {
"computed":"fsecret1",
"computedVisibility":"unmasked",
"note":""
},
"FSECRET2": {
"computed":"fsecret2",
"computedVisibility":"unmasked",
"note":""
}
}
JSON
json = JSON.parse(
shellunescape run_command("fetch", "--from", "my-project/prd", "SECRET1", "FSECRET1", "FSECRET2")
)
expected_json = {
"SECRET1"=>"secret1",
"FSECRET1"=>"fsecret1",
"FSECRET2"=>"fsecret2"
}
assert_equal expected_json, json
end
test "fetch having DOPPLER_TOKEN" do
ENV["DOPPLER_TOKEN"] = "dp.st.xxxxxxxxxxxxxxxxxxxxxx"
stub_ticks_with("doppler --version 2> /dev/null", succeed: true)
stub_ticks.with("doppler me --json 2> /dev/null")
stub_ticks
.with("doppler secrets get SECRET1 FSECRET1 FSECRET2 --json ")
.returns(<<~JSON)
{
"SECRET1": {
"computed":"secret1",
"computedVisibility":"unmasked",
"note":""
},
"FSECRET1": {
"computed":"fsecret1",
"computedVisibility":"unmasked",
"note":""
},
"FSECRET2": {
"computed":"fsecret2",
"computedVisibility":"unmasked",
"note":""
}
}
JSON
json = JSON.parse(
shellunescape run_command("fetch", "SECRET1", "FSECRET1", "FSECRET2")
)
expected_json = {
"SECRET1"=>"secret1",
"FSECRET1"=>"fsecret1",
"FSECRET2"=>"fsecret2"
}
assert_equal expected_json, json
ENV.delete("DOPPLER_TOKEN")
end
test "fetch with folder in secret" do
stub_ticks_with("doppler --version 2> /dev/null", succeed: true)
stub_ticks.with("doppler me --json 2> /dev/null")
stub_ticks
.with("doppler secrets get SECRET1 FSECRET1 FSECRET2 --json -p my-project -c prd")
.returns(<<~JSON)
{
"SECRET1": {
"computed":"secret1",
"computedVisibility":"unmasked",
"note":""
},
"FSECRET1": {
"computed":"fsecret1",
"computedVisibility":"unmasked",
"note":""
},
"FSECRET2": {
"computed":"fsecret2",
"computedVisibility":"unmasked",
"note":""
}
}
JSON
json = JSON.parse(
shellunescape run_command("fetch", "my-project/prd/SECRET1", "my-project/prd/FSECRET1", "my-project/prd/FSECRET2")
)
expected_json = {
"SECRET1"=>"secret1",
"FSECRET1"=>"fsecret1",
"FSECRET2"=>"fsecret2"
}
assert_equal expected_json, json
end
test "fetch without --from" do
stub_ticks_with("doppler --version 2> /dev/null", succeed: true)
stub_ticks.with("doppler me --json 2> /dev/null")
error = assert_raises RuntimeError do
run_command("fetch", "FSECRET1", "FSECRET2")
end
assert_equal "Missing project or config from '--from=project/config' option", error.message
end
test "fetch with signin" do
stub_ticks_with("doppler --version 2> /dev/null", succeed: true)
stub_ticks_with("doppler me --json 2> /dev/null", succeed: false)
stub_ticks_with("doppler login -y", succeed: true).returns("")
stub_ticks.with("doppler secrets get SECRET1 --json -p my-project -c prd").returns(single_item_json)
json = JSON.parse(shellunescape(run_command("fetch", "--from", "my-project/prd", "SECRET1")))
expected_json = {
"SECRET1"=>"secret1"
}
assert_equal expected_json, json
end
test "fetch without CLI installed" do
stub_ticks_with("doppler --version 2> /dev/null", succeed: false)
error = assert_raises RuntimeError do
JSON.parse(shellunescape(run_command("fetch", "HOST", "PORT")))
end
assert_equal "Doppler CLI is not installed", error.message
end
private
def run_command(*command)
stdouted do
Kamal::Cli::Secrets.start \
[ *command,
"-c", "test/fixtures/deploy_with_accessories.yml",
"--adapter", "doppler" ]
end
end
def single_item_json
<<~JSON
{
"SECRET1": {
"computed":"secret1",
"computedVisibility":"unmasked",
"note":""
}
}
JSON
end
end

View File

@@ -2,6 +2,7 @@ require "bundler/setup"
require "active_support/test_case" require "active_support/test_case"
require "active_support/testing/autorun" require "active_support/testing/autorun"
require "active_support/testing/stream" require "active_support/testing/stream"
require "rails/test_unit/line_filtering"
require "debug" require "debug"
require "mocha/minitest" # using #stubs that can alter returns require "mocha/minitest" # using #stubs that can alter returns
require "minitest/autorun" # using #stub that take args require "minitest/autorun" # using #stub that take args
@@ -32,6 +33,7 @@ end
class ActiveSupport::TestCase class ActiveSupport::TestCase
include ActiveSupport::Testing::Stream include ActiveSupport::Testing::Stream
extend Rails::LineFiltering
private private
def stdouted def stdouted