Compare commits

...

117 Commits

Author SHA1 Message Date
David Heinemeier Hansson
5911914e95 Bump version for 0.5.1 2023-02-03 20:48:21 +01:00
David Heinemeier Hansson
3daecf696a Extract proper auditor and audit everything 2023-02-03 20:45:32 +01:00
David Heinemeier Hansson
497c57e3e5 Style 2023-02-03 20:44:43 +01:00
David Heinemeier Hansson
8a42fd2f30 Fix signature 2023-02-03 20:43:22 +01:00
David Heinemeier Hansson
2182cfb5c7 Bump version for 0.5.0 2023-02-03 17:49:47 +01:00
David Heinemeier Hansson
5c9a602d76 Fixed host 2023-02-03 17:46:41 +01:00
David Heinemeier Hansson
b964e04f93 Bring accessory execution in line with app 2023-02-03 17:24:36 +01:00
David Heinemeier Hansson
1fb2c71f65 Follow same dot style 2023-02-03 17:22:55 +01:00
David Heinemeier Hansson
58417f610f Dupe comment 2023-02-03 17:20:14 +01:00
David Heinemeier Hansson
5856a77a53 Bring accessory execution in line with app 2023-02-03 17:19:20 +01:00
David Heinemeier Hansson
5ed3ea9d26 Grouping by spacing 2023-02-03 17:18:58 +01:00
David Heinemeier Hansson
59199cc69a Fix bug 2023-02-03 17:18:47 +01:00
David Heinemeier Hansson
c453b947e0 Add exec tests 2023-02-03 17:18:42 +01:00
David Heinemeier Hansson
87e54d41e4 Need two stubs! 2023-02-03 17:03:26 +01:00
David Heinemeier Hansson
64b91daab1 Drop concerns
Not enough reuse possible
2023-02-03 16:55:34 +01:00
David Heinemeier Hansson
13e22f8a34 Repository really is app specific, since it relies on versions 2023-02-03 16:45:52 +01:00
David Heinemeier Hansson
8848335fbc Extract executions into separate concern 2023-02-03 16:39:26 +01:00
David Heinemeier Hansson
a3fe8856c9 Fix test 2023-02-03 16:27:16 +01:00
David Heinemeier Hansson
d263b0ffa5 Extract xargs helper 2023-02-03 16:27:10 +01:00
David Heinemeier Hansson
3c1053fedd Clarify exec modes and drop tailored versions 2023-02-03 16:07:25 +01:00
David Heinemeier Hansson
a3d998508b Proper versioning for console and bash 2023-02-03 15:16:40 +01:00
David Heinemeier Hansson
3d71ecdf80 Only say if you're going to do it 2023-02-03 15:16:30 +01:00
David Heinemeier Hansson
37e216f2b7 Add some more tests 2023-02-03 15:08:44 +01:00
David Heinemeier Hansson
17e75ec2c9 No more reboot 2023-02-03 15:06:43 +01:00
David Heinemeier Hansson
7621784235 Bring back regular version with narration 2023-02-03 15:05:34 +01:00
David Heinemeier Hansson
687b8c9def Rely on shared --version 2023-02-03 14:41:39 +01:00
David Heinemeier Hansson
13d4eb4017 Narrate multi-stage actions 2023-02-03 14:41:30 +01:00
David Heinemeier Hansson
78f0be9c76 Only multi-stage actions should talk 2023-02-03 14:33:49 +01:00
David Heinemeier Hansson
839a0df40e Boot now does its own stopping 2023-02-03 14:31:56 +01:00
David Heinemeier Hansson
74c493def4 Don't actually need reboot now that boot can do that 2023-02-03 14:31:11 +01:00
David Heinemeier Hansson
22bbedf298 Show current running version 2023-02-03 14:08:00 +01:00
David Heinemeier Hansson
15a213eec6 Escape pipe and test for xargs 2023-02-03 14:07:52 +01:00
David Heinemeier Hansson
67f9ffe961 xargs when piping 2023-02-03 14:07:37 +01:00
David Heinemeier Hansson
25e52d6c93 Fix escaping 2023-02-03 14:07:20 +01:00
David Heinemeier Hansson
2023c377ab Reboot if running 2023-02-03 13:52:31 +01:00
David Heinemeier Hansson
3bd2559c03 Version comes from config 2023-02-03 13:52:10 +01:00
David Heinemeier Hansson
ad26bce5a2 Add mocha for testing 2023-02-03 13:48:34 +01:00
David Heinemeier Hansson
aed7425b42 Streamline version handling 2023-02-03 13:21:11 +01:00
David Heinemeier Hansson
fadb73da39 Replace stub value 2023-02-03 13:20:10 +01:00
David Heinemeier Hansson
8024949fe7 Remove only specific container needed for rebooting 2023-02-03 13:20:03 +01:00
David Heinemeier Hansson
004c154abb Reset MRSK between invocations in CLI tests
Don't love having #reset, but whatever for now.
2023-02-03 13:15:14 +01:00
David Heinemeier Hansson
35b42cc885 Fix tests 2023-02-02 18:05:56 +01:00
David Heinemeier Hansson
6d80005f5d Run boot and console on relevant versions
Instead of just defaulting to local hash version
2023-02-02 18:05:03 +01:00
David Heinemeier Hansson
c8f673ef7c Add images command to see what's on the server for the service repository 2023-02-02 16:53:46 +01:00
David Heinemeier Hansson
212d5ec783 Merge pull request #31 from fschueller/accessory-class
Align config class name with file name
2023-02-02 15:50:50 +01:00
David Heinemeier Hansson
f88685a525 Extract CliTestCase 2023-02-02 15:37:41 +01:00
David Heinemeier Hansson
08908c3925 Fix test 2023-02-02 15:31:33 +01:00
David Heinemeier Hansson
48a9f599b8 It's all of them 2023-02-02 15:31:27 +01:00
David Heinemeier Hansson
7cc64299c8 Add app reboot 2023-02-02 15:28:36 +01:00
David Heinemeier Hansson
7494f08978 Cleanup 2023-02-02 15:28:36 +01:00
David Heinemeier Hansson
2b232b41ce Unbundle remove so parts can be triggered individually 2023-02-02 15:28:36 +01:00
David Heinemeier Hansson
c28065fd42 Fix doc 2023-02-02 15:28:36 +01:00
Farah Schüller
80b90ab689 Align config class name with file name
`Mrsk::Configuration::Assessory` -> `Mrsk::Configuration::Accessory` thus
aligning with the name of the file.
2023-02-02 12:44:48 +01:00
David Heinemeier Hansson
d71950f5e4 Merge pull request #30 from azolf/improve-test-coverage
Improve test coverage
2023-02-02 10:51:20 +01:00
David Heinemeier Hansson
00d194e3f3 Bump version for 0.4.0 2023-02-01 15:09:37 +01:00
David Heinemeier Hansson
3f44e25b63 Allow dynamic accessory files to reference declared ENVs 2023-02-01 14:45:56 +01:00
David Heinemeier Hansson
4c8b1a3e04 No longer needed 2023-02-01 14:11:52 +01:00
David Heinemeier Hansson
f06d639583 Add quiet mode
Only log errors
2023-02-01 14:10:51 +01:00
David Heinemeier Hansson
cdd77445d0 Not used 2023-02-01 14:04:57 +01:00
David Heinemeier Hansson
71f8f164ca Expose ssh_run 2023-02-01 14:04:51 +01:00
David Heinemeier Hansson
1840f667d3 Accessory already knows its host 2023-02-01 14:04:36 +01:00
David Heinemeier Hansson
00afd5c6fc Yield accessory 2023-02-01 13:30:04 +01:00
David Heinemeier Hansson
e17a7e28cb Missing ) 2023-02-01 13:29:14 +01:00
David Heinemeier Hansson
88b5e52b9f Exec over ssh with accessory 2023-02-01 13:28:29 +01:00
David Heinemeier Hansson
bc0ae84eb1 Needn't pass existing ENVs either 2023-02-01 13:20:47 +01:00
David Heinemeier Hansson
cb6fdbefc8 Exec can't mount 2023-02-01 13:19:01 +01:00
Amirhosein Zolfaghari
5bf3c36001 added more test cases for traefik command 2023-02-01 11:53:25 +03:30
Amirhosein Zolfaghari
afb7b43f1a added registry command tests 2023-02-01 11:48:47 +03:30
Amirhosein Zolfaghari
4f57976efe ignore useless files 2023-02-01 11:48:47 +03:30
David Heinemeier Hansson
444e33721a This is still there 2023-01-31 20:13:45 +01:00
David Heinemeier Hansson
ca86573d89 Custom cmd args for Traefik 2023-01-31 20:11:42 +01:00
David Heinemeier Hansson
e317935ab3 Already getting timestamps from Rails log 2023-01-30 19:19:35 +01:00
David Heinemeier Hansson
767991afe3 Clearer still 2023-01-30 16:59:44 +01:00
David Heinemeier Hansson
7e191dc267 Document use of .env 2023-01-30 16:59:10 +01:00
David Heinemeier Hansson
0f0529c785 Use dotenv to load .env 2023-01-30 16:39:38 +01:00
David Heinemeier Hansson
3ebf8d7777 Fix interpolation 2023-01-30 13:59:44 +01:00
David Heinemeier Hansson
cd8570d776 Catch all other exceptions too 2023-01-30 13:52:24 +01:00
David Heinemeier Hansson
7c72dfcb5d Include env validation of new config
So we fail fast when required ENVs are missing!
2023-01-30 13:50:15 +01:00
David Heinemeier Hansson
52d75508ea Ensure there's some cap on output
Need to DRY this out
2023-01-30 12:49:52 +01:00
David Heinemeier Hansson
ea6144e664 Set ENV verbose too to display backtraces 2023-01-30 12:49:52 +01:00
David Heinemeier Hansson
d1559949ba Merge pull request #26 from adammiribyan/explicit-clear-only
Allow "clear" only env configuration
2023-01-29 16:13:50 +01:00
David Heinemeier Hansson
60c2d45bdc Merge pull request #25 from dzhulk/docker-exec-options-fix
Exclude volume_args from `docker exec` arguments
2023-01-29 16:12:16 +01:00
Adam Miribyan
afefd32379 Allow "clear" only env configuration 2023-01-28 17:19:07 +01:00
David Heinemeier Hansson
c23928348b Bump version for 0.3.1 2023-01-27 17:04:52 +01:00
Murat Dzhulkuttiev
4937673aac Merge branch 'rails:main' into docker-exec-options-fix 2023-01-27 20:04:41 +04:00
David Heinemeier Hansson
979b7d80ba Need the command, not config 2023-01-27 16:57:02 +01:00
Murat Dzhulkuttiev
c1cf834dfc Exclude volume args from docker exec arguments 2023-01-27 22:29:31 +07:00
David Heinemeier Hansson
0111fcc4e4 Bump version for 0.3.0 2023-01-27 16:19:31 +01:00
David Heinemeier Hansson
407e1cc028 Protect accessory cli from missing accessory 2023-01-27 16:12:18 +01:00
David Heinemeier Hansson
f58e5e0935 Better error reporting and failure capture for build push 2023-01-27 15:56:07 +01:00
David Heinemeier Hansson
03fdb9a9ac Chain builder setup for better resiliency
Context may already exist while buildx does not
2023-01-27 15:41:28 +01:00
David Heinemeier Hansson
a5ebb30de2 Include accessories in main details 2023-01-27 15:20:27 +01:00
David Heinemeier Hansson
ec18a2a1c4 Tolerable error reporting 2023-01-27 15:04:27 +01:00
David Heinemeier Hansson
9af09256d9 Nicer output 2023-01-26 22:17:02 +01:00
David Heinemeier Hansson
29a8a52cef Execute over SSH too 2023-01-26 16:17:00 +01:00
David Heinemeier Hansson
de0a3f8ee8 Only catch what we can carry 2023-01-26 16:16:47 +01:00
David Heinemeier Hansson
08cac72475 Allow skipping master key 2023-01-24 13:19:12 +01:00
David Heinemeier Hansson
200f12a4a1 Single setup command 2023-01-23 14:13:17 +01:00
David Heinemeier Hansson
f0d88a5ffe Bootstrap accessory hosts too 2023-01-23 14:13:10 +01:00
David Heinemeier Hansson
d6a6f000f9 Inspect accessories too 2023-01-23 14:12:50 +01:00
David Heinemeier Hansson
15495fb48c Allow partial overwrites 2023-01-23 14:12:43 +01:00
David Heinemeier Hansson
05f84cdbef Makes it easier to resume remove 2023-01-23 14:12:27 +01:00
David Heinemeier Hansson
03488bc67a Add managed accessory directories 2023-01-23 13:36:47 +01:00
David Heinemeier Hansson
eceafbedf4 Better explaining variables 2023-01-23 12:50:44 +01:00
David Heinemeier Hansson
e1d518216a Add dynamic file expansion 2023-01-23 12:45:49 +01:00
David Heinemeier Hansson
52d10394f7 Ensure uploads are readable 2023-01-23 12:45:36 +01:00
David Heinemeier Hansson
ddf52da132 Add exec and bash commands to accessories 2023-01-23 12:45:20 +01:00
David Heinemeier Hansson
747e0fd4c2 Fix tests 2023-01-23 10:58:31 +01:00
David Heinemeier Hansson
6177673870 Get details on all accessories 2023-01-23 10:39:22 +01:00
David Heinemeier Hansson
78e50f23cd All boot/remove for all accessories 2023-01-23 10:38:03 +01:00
David Heinemeier Hansson
699f271e6e No need for protecting against re-invocation 2023-01-23 10:37:49 +01:00
David Heinemeier Hansson
148c43fe29 Extract make_directory_for 2023-01-23 10:37:19 +01:00
David Heinemeier Hansson
cd44014069 Commands should do all the actual work 2023-01-23 10:35:22 +01:00
David Heinemeier Hansson
1bcc65bc56 Must use absolute path 2023-01-23 10:04:55 +01:00
David Heinemeier Hansson
62cc986c54 Cleanup files directory too 2023-01-23 10:04:46 +01:00
David Heinemeier Hansson
7b1ffbfd6d Unify docs 2023-01-23 10:04:36 +01:00
David Heinemeier Hansson
8af7e48a90 Add file mapping to accessories 2023-01-23 09:43:57 +01:00
40 changed files with 1122 additions and 223 deletions

2
.gitignore vendored
View File

@@ -1,2 +1,4 @@
.byebug_history .byebug_history
*.gem *.gem
coverage/*
.DS_Store

View File

@@ -4,4 +4,5 @@ git_source(:github) { |repo| "https://github.com/#{repo}.git" }
gemspec gemspec
gem "debug" gem "debug"
gem "mocha"
gem "railties" gem "railties"

View File

@@ -1,8 +1,9 @@
PATH PATH
remote: . remote: .
specs: specs:
mrsk (0.2.0) mrsk (0.5.1)
activesupport (>= 7.0) activesupport (>= 7.0)
dotenv (~> 2.8)
sshkit (~> 1.21) sshkit (~> 1.21)
thor (~> 1.2) thor (~> 1.2)
@@ -33,6 +34,7 @@ GEM
debug (1.7.1) debug (1.7.1)
irb (>= 1.5.0) irb (>= 1.5.0)
reline (>= 0.3.1) reline (>= 0.3.1)
dotenv (2.8.1)
erubi (1.12.0) erubi (1.12.0)
i18n (1.12.0) i18n (1.12.0)
concurrent-ruby (~> 1.0) concurrent-ruby (~> 1.0)
@@ -44,6 +46,8 @@ GEM
nokogiri (>= 1.5.9) nokogiri (>= 1.5.9)
method_source (1.0.0) method_source (1.0.0)
minitest (5.17.0) minitest (5.17.0)
mocha (2.0.2)
ruby2_keywords (>= 0.0.5)
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-ssh (7.0.1) net-ssh (7.0.1)
@@ -72,6 +76,7 @@ GEM
rake (13.0.6) rake (13.0.6)
reline (0.3.2) reline (0.3.2)
io-console (~> 0.5) io-console (~> 0.5)
ruby2_keywords (0.0.5)
sshkit (1.21.3) sshkit (1.21.3)
net-scp (>= 1.1.2) net-scp (>= 1.1.2)
net-ssh (>= 2.8.0) net-ssh (>= 2.8.0)
@@ -90,6 +95,7 @@ PLATFORMS
DEPENDENCIES DEPENDENCIES
debug debug
mocha
mrsk! mrsk!
railties railties

View File

@@ -46,6 +46,15 @@ Kubernetes is a beast. Running it yourself on your own hardware is not for the f
## Configuration ## Configuration
### Using .env file to load required environment variables
MRSK uses [dotenv](https://github.com/bkeepers/dotenv) to automatically load environment variables set in the `.env` file present in the application root. This file can be used to set variables like `MRSK_REGISTRY_PASSWORD` or database passwords. But for this reason you must ensure that .env files are not checked into Git or included in your Dockerfile! The format is just key-value like:
```bash
MRSK_REGISTRY_PASSWORD=pw
DB_PASSWORD=secret123
```
### Using another registry than Docker Hub ### Using another registry than Docker Hub
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`:
@@ -226,6 +235,18 @@ RUN --mount=type=secret,id=GITHUB_TOKEN \
bundle install bundle install
``` ```
### Using command arguments for Traefik
You can customize the traefik command line:
```yaml
traefik:
accesslog: true
accesslog.format: json
metrics.prometheus: true
metrics.prometheus.buckets: 0.1,0.3,1.2,5.0
```
### Configuring build args for new images ### Configuring build args for new images
Build arguments that aren't secret can also be configured: Build arguments that aren't secret can also be configured:
@@ -244,6 +265,14 @@ ARG RUBY_VERSION
FROM ruby:$RUBY_VERSION-slim as base FROM ruby:$RUBY_VERSION-slim as base
``` ```
### Using without RAILS_MASTER_KEY
If you're using MRSK with older Rails apps that predate RAILS_MASTER_KEY, or with a non-Rails app, you can skip the default usage and reference:
```yaml
skip_master_key: true
```
### Using accessories for database, cache, search services ### Using accessories for database, cache, search services
You can manage your accessory services via MRSK as well. The services will build off public images, and will not be automatically updated when you deploy: You can manage your accessory services via MRSK as well. The services will build off public images, and will not be automatically updated when you deploy:
@@ -273,9 +302,9 @@ Now run `mrsk accessory start mysql` to start the MySQL server on 1.1.1.3. See `
## Commands ## Commands
### Running remote execution and runners ### Running commands on servers
If you need to execute commands inside the Rails containers, you can use `mrsk app exec` and `mrsk app runner`. Examples: You can execute one-off commands on the servers:
```bash ```bash
# Runs command on all servers # Runs command on all servers
@@ -318,13 +347,25 @@ Database adapter sqlite3
Database schema version 20221231233303 Database schema version 20221231233303
# Run Rails runner on primary server # Run Rails runner on primary server
mrsk app runner -p 'puts Rails.application.config.time_zone' mrsk app exec -p 'bin/rails runner "puts Rails.application.config.time_zone"'
UTC UTC
``` ```
### Running a Rails console ### Running interactive commands over SSH
You can run interactive commands, like a Rails console or a bash session, on a server (default is primary, use `--hosts` to connect to another):
```bash
# Starts a bash session in a new container made from the most recent app image
mrsk app exec -i bash
# Starts a bash session in the currently running container for the app
mrsk app exec -i --reuse bash
# Starts a Rails console in a new container made from the most recent app image
mrsk app exec -i 'bin/rails console'
```
If you need to interact with the production console for the app, you can use `mrsk app console`, which will start a Rails console session on the primary host. You can start the console on a different host using `mrsk app console --host 192.168.0.2`. Be mindful that this is a live wire! Any changes made to the production database will take effect immeditately.
### Running details to see state of containers ### Running details to see state of containers

View File

@@ -1,5 +1,17 @@
#!/usr/bin/env ruby #!/usr/bin/env ruby
# Prevent failures from being reported twice.
Thread.report_on_exception = false
require "dotenv/load"
require "mrsk/cli" require "mrsk/cli"
Mrsk::Cli::Main.start(ARGV) begin
Mrsk::Cli::Main.start(ARGV)
rescue SSHKit::Runner::ExecuteError => e
puts " \e[31mERROR (#{e.cause.class}): #{e.cause.message}\e[0m"
puts e.cause.backtrace if ENV["VERBOSE"]
rescue => e
puts " \e[31mERROR (#{e.class}): #{e.message}\e[0m"
puts e.backtrace if ENV["VERBOSE"]
end

View File

@@ -1,41 +1,129 @@
require "mrsk/cli/base" require "mrsk/cli/base"
class Mrsk::Cli::Accessory < Mrsk::Cli::Base class Mrsk::Cli::Accessory < Mrsk::Cli::Base
desc "boot [NAME]", "Boot accessory service on host" desc "boot [NAME]", "Boot accessory service on host (use NAME=all to boot all accessories)"
def boot(name) def boot(name)
accessory = MRSK.accessory(name) if name == "all"
on(accessory.host) { execute *accessory.run } MRSK.accessory_names.each { |accessory_name| boot(accessory_name) }
else
with_accessory(name) do |accessory|
directories(name)
upload(name)
on(accessory.host) do
execute *MRSK.auditor.record("accessory #{name} boot"), verbosity: :debug
execute *accessory.run
end
end
end
end
desc "upload [NAME]", "Upload accessory files to host"
def upload(name)
with_accessory(name) do |accessory|
on(accessory.host) do
execute *MRSK.auditor.record("accessory #{name} upload files"), verbosity: :debug
accessory.files.each do |(local, remote)|
accessory.ensure_local_file_present(local)
execute *accessory.make_directory_for(remote)
upload! local, remote
execute :chmod, "755", remote
end
end
end
end
desc "directories [NAME]", "Create accessory directories on host"
def directories(name)
with_accessory(name) do |accessory|
on(accessory.host) do
execute *MRSK.auditor.record("accessory #{name} create directories"), verbosity: :debug
accessory.directories.keys.each do |host_path|
execute *accessory.make_directory(host_path)
end
end
end
end end
desc "reboot [NAME]", "Reboot accessory on host (stop container, remove container, start new container)" desc "reboot [NAME]", "Reboot accessory on host (stop container, remove container, start new container)"
def reboot(name) def reboot(name)
invoke :stop, [ name ] with_accessory(name) do |accessory|
invoke :remove_container, [ name ] stop(name)
invoke :boot, [ name ] remove_container(name)
boot(name)
end
end end
desc "start [NAME]", "Start existing accessory on host" desc "start [NAME]", "Start existing accessory on host"
def start(name) def start(name)
accessory = MRSK.accessory(name) with_accessory(name) do |accessory|
on(accessory.host) { execute *accessory.start } on(accessory.host) do
execute *MRSK.auditor.record("accessory #{name} start"), verbosity: :debug
execute *accessory.start
end
end
end end
desc "stop [NAME]", "Stop accessory on host" desc "stop [NAME]", "Stop accessory on host"
def stop(name) def stop(name)
accessory = MRSK.accessory(name) with_accessory(name) do |accessory|
on(accessory.host) { execute *accessory.stop } on(accessory.host) do
execute *MRSK.auditor.record("accessory #{name} stop"), verbosity: :debug
execute *accessory.stop, raise_on_non_zero_exit: false
end
end
end end
desc "restart [NAME]", "Restart accessory on host" desc "restart [NAME]", "Restart accessory on host"
def restart(name) def restart(name)
invoke :stop, [ name ] with_accessory(name) do
invoke :start, [ name ] stop(name)
start(name)
end
end end
desc "details [NAME]", "Display details about accessory on host" desc "details [NAME]", "Display details about accessory on host (use NAME=all to boot all accessories)"
def details(name) def details(name)
accessory = MRSK.accessory(name) if name == "all"
on(accessory.host) { puts capture_with_info(*accessory.info) } MRSK.accessory_names.each { |accessory_name| details(accessory_name) }
else
with_accessory(name) do |accessory|
on(accessory.host) { puts capture_with_info(*accessory.info) }
end
end
end
desc "exec [NAME] [CMD]", "Execute a custom command on servers"
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"
def exec(name, cmd)
with_accessory(name) do |accessory|
case
when options[:interactive] && options[:reuse]
say "Launching interactive command with via SSH from existing container...", :magenta
run_locally { exec accessory.execute_in_existing_container_over_ssh(cmd) }
when options[:interactive]
say "Launching interactive command via SSH from new container...", :magenta
run_locally { exec accessory.execute_in_new_container_over_ssh(cmd) }
when options[:reuse]
say "Launching command from existing container...", :magenta
on(accessory.host) do
execute *MRSK.auditor.record("accessory #{name} cmd '#{cmd}'"), verbosity: :debug
capture_with_info(*accessory.execute_in_existing_container(cmd))
end
else
say "Launching command from new container...", :magenta
on(accessory.host) do
execute *MRSK.auditor.record("accessory #{name} cmd '#{cmd}'"), verbosity: :debug
capture_with_info(*accessory.execute_in_new_container(cmd))
end
end
end
end end
desc "logs [NAME]", "Show log lines from accessory on host" desc "logs [NAME]", "Show log lines from accessory on host"
@@ -44,42 +132,84 @@ class Mrsk::Cli::Accessory < Mrsk::Cli::Base
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 :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)"
def logs(name) def logs(name)
accessory = MRSK.accessory(name) with_accessory(name) do |accessory|
grep = options[:grep]
grep = options[:grep] if options[:follow]
run_locally do
info "Following logs on #{accessory.host}..."
info accessory.follow_logs(grep: grep)
exec accessory.follow_logs(grep: grep)
end
else
since = options[:since]
lines = options[:lines].presence || ((since || grep) ? nil : 100) # Default to 100 lines if since or grep isn't set
if options[:follow] on(accessory.host) do
run_locally do puts capture_with_info(*accessory.logs(since: since, lines: lines, grep: grep))
info "Following logs on #{accessory.host}..." end
info accessory.follow_logs(grep: grep)
exec accessory.follow_logs(grep: grep)
end
else
since = options[:since]
lines = options[:lines]
on(accessory.host) do
puts capture_with_info(*accessory.logs(since: since, lines: lines, grep: grep))
end end
end end
end end
desc "remove [NAME]", "Remove accessory container and image from host" desc "remove [NAME]", "Remove accessory container and image from host (use NAME=all to boot all accessories)"
def remove(name) def remove(name)
invoke :stop, [ name ] if name == "all"
invoke :remove_container, [ name ] MRSK.accessory_names.each { |accessory_name| remove(accessory_name) }
invoke :remove_image, [ name ] else
with_accessory(name) do
stop(name)
remove_container(name)
remove_image(name)
remove_service_directory(name)
end
end
end end
desc "remove_container [NAME]", "Remove accessory container from host" desc "remove_container [NAME]", "Remove accessory container from host"
def remove_container(name) def remove_container(name)
accessory = MRSK.accessory(name) with_accessory(name) do |accessory|
on(accessory.host) { execute *accessory.remove_container } on(accessory.host) do
execute *MRSK.auditor.record("accessory #{name} remove container"), verbosity: :debug
execute *accessory.remove_container
end
end
end end
desc "remove_container [NAME]", "Remove accessory image from servers" desc "remove_image [NAME]", "Remove accessory image from host"
def remove_image(name) def remove_image(name)
accessory = MRSK.accessory(name) with_accessory(name) do |accessory|
on(accessory.host) { execute *accessory.remove_image } on(accessory.host) do
execute *MRSK.auditor.record("accessory #{name} remove image"), verbosity: :debug
execute *accessory.remove_image
end
end
end end
desc "remove_service_directory [NAME]", "Remove accessory directory used for uploaded files and data directories from host"
def remove_service_directory(name)
with_accessory(name) do |accessory|
on(accessory.host) do
execute *MRSK.auditor.record("accessory #{name} remove service directory"), verbosity: :debug
execute *accessory.remove_service_directory
end
end
end
private
def with_accessory(name)
if accessory = MRSK.accessory(name)
yield accessory
else
error_on_missing_accessory(name)
end
end
def error_on_missing_accessory(name)
options = MRSK.accessory_names.presence
error \
"No accessory by the name of '#{name}'" +
(options ? " (options: #{options.to_sentence})" : "")
end
end end

View File

@@ -1,37 +1,52 @@
require "mrsk/cli/base" require "mrsk/cli/base"
class Mrsk::Cli::App < Mrsk::Cli::Base class Mrsk::Cli::App < Mrsk::Cli::Base
desc "boot", "Boot app on servers (or start them if they've already been booted)" desc "boot", "Boot app on servers (or reboot app if already running)"
def boot def boot
MRSK.config.roles.each do |role| cli = self
on(role.hosts) do |host|
begin say "Ensure no other version of the app is running...", :magenta
execute *MRSK.app.run(role: role.name) stop
rescue SSHKit::Command::Failed => e
if e.message =~ /already in use/ say "Get most recent version available as an image...", :magenta unless options[:version]
error "Container with same version already deployed on #{host}, starting that instead" using_version(options[:version] || most_recent_version_available) do |version|
execute *MRSK.app.start, host: host say "Start container with version #{version} (or reboot if already running)...", :magenta
else
raise MRSK.config.roles.each do |role|
on(role.hosts) do |host|
execute *MRSK.auditor.record("app boot version #{version}"), verbosity: :debug
begin
execute *MRSK.app.run(role: role.name)
rescue SSHKit::Command::Failed => e
if e.message =~ /already in use/
error "Rebooting container with same version already deployed on #{host}"
cli.remove_container version
execute *MRSK.app.run(role: role.name)
else
raise
end
end end
end end
end end
end end
end end
desc "start", "Start existing app on servers (use --version=<git-hash> to designate specific version)" desc "start", "Start existing app on servers (use --version=<git-hash> to designate specific version)"
option :version, desc: "Defaults to the most recent git-hash in local repository"
def start def start
if (version = options[:version]).present? on(MRSK.hosts) do
on(MRSK.hosts) { execute *MRSK.app.start(version: version) } execute *MRSK.auditor.record("app start version #{MRSK.version}"), verbosity: :debug
else execute *MRSK.app.start, raise_on_non_zero_exit: false
on(MRSK.hosts) { execute *MRSK.app.start, raise_on_non_zero_exit: false }
end end
end end
desc "stop", "Stop app on servers" desc "stop", "Stop app on servers"
def stop def stop
on(MRSK.hosts) { execute *MRSK.app.stop, raise_on_non_zero_exit: false } on(MRSK.hosts) do
execute *MRSK.auditor.record("app stop"), verbosity: :debug
execute *MRSK.app.stop, raise_on_non_zero_exit: false
end
end end
desc "details", "Display details about app containers" desc "details", "Display details about app containers"
@@ -40,38 +55,57 @@ class Mrsk::Cli::App < Mrsk::Cli::Base
end end
desc "exec [CMD]", "Execute a custom command on servers" desc "exec [CMD]", "Execute a custom command on servers"
option :run, type: :boolean, default: false, desc: "Start a new container to run the command rather than reusing existing" 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"
def exec(cmd) def exec(cmd)
runner = options[:run] ? :run_exec : :exec case
on(MRSK.hosts) { |host| puts_by_host host, capture_with_info(*MRSK.app.send(runner, cmd)) } when options[:interactive] && options[:reuse]
end say "Get current version of running container...", :magenta unless options[:version]
using_version(options[:version] || current_running_version) do |version|
say "Launching interactive command with version #{version} via SSH from existing container on #{MRSK.primary_host}...", :magenta
run_locally { exec MRSK.app.execute_in_existing_container_over_ssh(cmd, host: MRSK.primary_host) }
end
desc "console", "Start Rails Console on primary host (or specific host set by --hosts)" when options[:interactive]
def console say "Get most recent version available as an image...", :magenta unless options[:version]
run_locally do using_version(options[:version] || most_recent_version_available) do |version|
info "Launching Rails console on #{MRSK.primary_host}" say "Launching interactive command with version #{version} via SSH from new container on #{MRSK.primary_host}...", :magenta
exec MRSK.app.console(host: MRSK.primary_host) run_locally { exec MRSK.app.execute_in_new_container_over_ssh(cmd, host: MRSK.primary_host) }
end
when options[:reuse]
say "Get current version of running container...", :magenta unless options[:version]
using_version(options[:version] || current_running_version) do |version|
say "Launching command with version #{version} from existing container...", :magenta
on(MRSK.hosts) do |host|
execute *MRSK.auditor.record("app cmd '#{cmd}' with version #{version}"), verbosity: :debug
puts_by_host host, capture_with_info(*MRSK.app.execute_in_existing_container(cmd))
end
end
else
say "Get most recent version available as an image...", :magenta unless options[:version]
using_version(options[:version] || most_recent_version_available) do |version|
say "Launching command with version #{version} from new container...", :magenta
on(MRSK.hosts) do |host|
execute *MRSK.auditor.record("app cmd '#{cmd}' with version #{version}"), verbosity: :debug
puts_by_host host, capture_with_info(*MRSK.app.execute_in_new_container(cmd))
end
end
end end
end end
desc "bash", "Start a bash session on primary host (or specific host set by --hosts)"
def bash
run_locally do
info "Launching bash session on #{MRSK.primary_host}"
exec MRSK.app.bash(host: MRSK.primary_host)
end
end
desc "runner [EXPRESSION]", "Execute Rails runner with given expression"
def runner(expression)
on(MRSK.hosts) { |host| puts_by_host host, capture_with_info(*MRSK.app.exec("bin/rails", "runner", "'#{expression}'")) }
end
desc "containers", "List all the app containers currently on servers" desc "containers", "List all the app containers currently on servers"
def containers def containers
on(MRSK.hosts) { |host| puts_by_host host, capture_with_info(*MRSK.app.list_containers) } on(MRSK.hosts) { |host| puts_by_host host, capture_with_info(*MRSK.app.list_containers) }
end end
desc "images", "List all the app images currently on servers"
def images
on(MRSK.hosts) { |host| puts_by_host host, capture_with_info(*MRSK.app.list_images) }
end
desc "current", "Return the current running container ID" desc "current", "Return the current running container ID"
def current def current
on(MRSK.hosts) { |host| puts_by_host host, capture_with_info(*MRSK.app.current_container_id) } on(MRSK.hosts) { |host| puts_by_host host, capture_with_info(*MRSK.app.current_container_id) }
@@ -95,7 +129,7 @@ class Mrsk::Cli::App < Mrsk::Cli::Base
end end
else else
since = options[:since] since = options[:since]
lines = options[:lines] lines = options[:lines].presence || ((since || grep) ? nil : 100) # Default to 100 lines if since or grep isn't set
on(MRSK.hosts) do |host| on(MRSK.hosts) do |host|
begin begin
@@ -108,16 +142,64 @@ class Mrsk::Cli::App < Mrsk::Cli::Base
end end
desc "remove", "Remove app containers and images from servers" desc "remove", "Remove app containers and images from servers"
option :only, default: "", desc: "Use 'containers' or 'images'"
def remove def remove
case options[:only] remove_containers
when "containers" remove_images
on(MRSK.hosts) { execute *MRSK.app.remove_containers } end
when "images"
on(MRSK.hosts) { execute *MRSK.app.remove_images } desc "remove_container [VERSION]", "Remove app container with given version from servers"
else def remove_container(version)
on(MRSK.hosts) { execute *MRSK.app.remove_containers } on(MRSK.hosts) do
on(MRSK.hosts) { execute *MRSK.app.remove_images } execute *MRSK.auditor.record("app remove container #{version}"), verbosity: :debug
execute *MRSK.app.remove_container(version: version)
end end
end end
desc "remove_containers", "Remove all app containers from servers"
def remove_containers
on(MRSK.hosts) do
execute *MRSK.auditor.record("app remove containers"), verbosity: :debug
execute *MRSK.app.remove_containers
end
end
desc "remove_images", "Remove all app images from servers"
def remove_images
on(MRSK.hosts) do
execute *MRSK.auditor.record("app remove images"), verbosity: :debug
execute *MRSK.app.remove_images
end
end
desc "current_version", "Shows the version currently running"
def current_version
on(MRSK.hosts) { |host| puts_by_host host, capture_with_info(*MRSK.app.current_running_version).strip }
end
private
def using_version(new_version)
if new_version
begin
old_version = MRSK.config.version
MRSK.config.version = new_version
yield new_version
ensure
MRSK.config.version = old_version
end
else
yield MRSK.config.version
end
end
def most_recent_version_available(host: MRSK.primary_host)
version = nil
on(host) { version = capture_with_info(*MRSK.app.most_recent_version_from_available_images).strip }
version.presence
end
def current_running_version(host: MRSK.primary_host)
version = nil
on(host) { version = capture_with_info(*MRSK.app.current_running_version).strip }
version.presence
end
end end

View File

@@ -8,6 +8,7 @@ module Mrsk::Cli
def self.exit_on_failure?() true end def self.exit_on_failure?() true end
class_option :verbose, type: :boolean, aliases: "-v", desc: "Detailed logging" class_option :verbose, type: :boolean, aliases: "-v", desc: "Detailed logging"
class_option :quiet, type: :boolean, aliases: "-q", desc: "Minimal logging"
class_option :version, desc: "Run commands against a specific app version" class_option :version, desc: "Run commands against a specific app version"
@@ -28,12 +29,20 @@ module Mrsk::Cli
MRSK.tap do |commander| MRSK.tap do |commander|
commander.config_file = Pathname.new(File.expand_path(options[:config_file])) commander.config_file = Pathname.new(File.expand_path(options[:config_file]))
commander.destination = options[:destination] commander.destination = options[:destination]
commander.verbose = options[:verbose]
commander.version = options[:version] commander.version = options[:version]
commander.specific_hosts = options[:hosts]&.split(",") commander.specific_hosts = options[:hosts]&.split(",")
commander.specific_roles = options[:roles]&.split(",") commander.specific_roles = options[:roles]&.split(",")
commander.specific_primary! if options[:primary] commander.specific_primary! if options[:primary]
if options[:verbose]
ENV["VERBOSE"] = "1" # For backtraces via cli/start
commander.verbosity = :debug
end
if options[:quiet]
commander.verbosity = :error
end
end end
end end

View File

@@ -9,29 +9,47 @@ class Mrsk::Cli::Build < Mrsk::Cli::Base
desc "push", "Build locally and push app image to registry" desc "push", "Build locally and push app image to registry"
def push def push
verbose = options[:verbose] cli = self
run_locally do run_locally do
begin begin
MRSK.verbosity(:debug) { execute *MRSK.builder.push } MRSK.with_verbosity(:debug) { execute *MRSK.builder.push }
rescue SSHKit::Command::Failed => e rescue SSHKit::Command::Failed => e
error "Missing compatible builder, so creating a new one first" if e.message =~ /(no builder)|(no such file or directory)/
execute *MRSK.builder.create error "Missing compatible builder, so creating a new one first"
MRSK.verbosity(:debug) { execute *MRSK.builder.push }
if cli.create
MRSK.with_verbosity(:debug) { execute *MRSK.builder.push }
end
else
raise
end
end end
end end
end end
desc "pull", "Pull app image from the registry onto servers" desc "pull", "Pull app image from the registry onto servers"
def pull def pull
on(MRSK.hosts) { execute *MRSK.builder.pull } on(MRSK.hosts) do
execute *MRSK.auditor.record("build pull image #{MRSK.version}"), verbosity: :debug
execute *MRSK.builder.pull
end
end end
desc "create", "Create a local build setup" desc "create", "Create a local build setup"
def create def create
run_locally do run_locally do
debug "Using builder: #{MRSK.builder.name}" begin
execute *MRSK.builder.create debug "Using builder: #{MRSK.builder.name}"
execute *MRSK.builder.create
rescue SSHKit::Command::Failed => e
if e.message =~ /stderr=(.*)/
error "Couldn't create remote builder: #{$1}"
false
else
raise
end
end
end end
end end

View File

@@ -9,15 +9,33 @@ require "mrsk/cli/server"
require "mrsk/cli/traefik" require "mrsk/cli/traefik"
class Mrsk::Cli::Main < Mrsk::Cli::Base class Mrsk::Cli::Main < Mrsk::Cli::Base
desc "setup", "Setup all accessories and deploy the app to servers"
def setup
print_runtime do
invoke "mrsk:cli:server:bootstrap"
invoke "mrsk:cli:accessory:boot", [ "all" ]
deploy
end
end
desc "deploy", "Deploy the app to servers" desc "deploy", "Deploy the app to servers"
def deploy def deploy
print_runtime do print_runtime do
say "Ensure Docker is installed...", :magenta
invoke "mrsk:cli:server:bootstrap" invoke "mrsk:cli:server:bootstrap"
say "Log into image registry...", :magenta
invoke "mrsk:cli:registry:login" invoke "mrsk:cli:registry:login"
say "Build and push app image...", :magenta
invoke "mrsk:cli:build:deliver" invoke "mrsk:cli:build:deliver"
say "Ensure Traefik is running...", :magenta
invoke "mrsk:cli:traefik:boot" invoke "mrsk:cli:traefik:boot"
invoke "mrsk:cli:app:stop"
invoke "mrsk:cli:app:boot" invoke "mrsk:cli:app:boot"
say "Prune old containers and images...", :magenta
invoke "mrsk:cli:prune:all" invoke "mrsk:cli:prune:all"
end end
end end
@@ -25,17 +43,23 @@ class Mrsk::Cli::Main < Mrsk::Cli::Base
desc "redeploy", "Deploy new version of the app to servers (without bootstrapping servers, starting Traefik, pruning, and registry login)" desc "redeploy", "Deploy new version of the app to servers (without bootstrapping servers, starting Traefik, pruning, and registry login)"
def redeploy def redeploy
print_runtime do print_runtime do
say "Build and push app image...", :magenta
invoke "mrsk:cli:build:deliver" invoke "mrsk:cli:build:deliver"
invoke "mrsk:cli:app:stop"
invoke "mrsk:cli:app:boot" invoke "mrsk:cli:app:boot"
end end
end end
desc "rollback [VERSION]", "Rollback the app to VERSION (that must already be on servers)" desc "rollback [VERSION]", "Rollback the app to VERSION"
def rollback(version) def rollback(version)
MRSK.version = version
cli = self
cli.say "Stop current version, then start version #{version}...", :magenta
on(MRSK.hosts) do on(MRSK.hosts) do
execute *MRSK.app.stop, raise_on_non_zero_exit: false execute *MRSK.app.stop, raise_on_non_zero_exit: false
execute *MRSK.app.start(version: version) execute *MRSK.app.start
end end
end end
@@ -43,12 +67,20 @@ class Mrsk::Cli::Main < Mrsk::Cli::Base
def details def details
invoke "mrsk:cli:traefik:details" invoke "mrsk:cli:traefik:details"
invoke "mrsk:cli:app:details" invoke "mrsk:cli:app:details"
invoke "mrsk:cli:accessory:details", [ "all" ]
end
desc "audit", "Show audit log from servers"
def audit
on(MRSK.hosts) do |host|
puts_by_host host, capture_with_info(*MRSK.auditor.reveal)
end
end end
desc "config", "Show combined config" desc "config", "Show combined config"
def config def config
run_locally do run_locally do
pp MRSK.config.to_h puts MRSK.config.to_h.to_yaml
end end
end end

View File

@@ -9,11 +9,17 @@ class Mrsk::Cli::Prune < Mrsk::Cli::Base
desc "images", "Prune unused images older than 30 days" desc "images", "Prune unused images older than 30 days"
def images def images
on(MRSK.hosts) { execute *MRSK.prune.images } on(MRSK.hosts) do
execute *MRSK.auditor.record("prune images"), verbosity: :debug
execute *MRSK.prune.images
end
end end
desc "containers", "Prune stopped containers for the service older than 3 days" desc "containers", "Prune stopped containers for the service older than 3 days"
def containers def containers
on(MRSK.hosts) { execute *MRSK.prune.containers } on(MRSK.hosts) do
execute *MRSK.auditor.record("prune containers"), verbosity: :debug
execute *MRSK.prune.containers
end
end end
end end

View File

@@ -3,7 +3,7 @@ require "mrsk/cli/base"
class Mrsk::Cli::Registry < Mrsk::Cli::Base class Mrsk::Cli::Registry < Mrsk::Cli::Base
desc "login", "Login to the registry locally and remotely" desc "login", "Login to the registry locally and remotely"
def login def login
run_locally { execute *MRSK.registry.login } run_locally { execute *MRSK.registry.login }
on(MRSK.hosts) { execute *MRSK.registry.login } on(MRSK.hosts) { execute *MRSK.registry.login }
rescue ArgumentError => e rescue ArgumentError => e
puts e.message puts e.message

View File

@@ -3,6 +3,6 @@ require "mrsk/cli/base"
class Mrsk::Cli::Server < Mrsk::Cli::Base class Mrsk::Cli::Server < Mrsk::Cli::Base
desc "bootstrap", "Ensure Docker is installed on the servers" desc "bootstrap", "Ensure Docker is installed on the servers"
def bootstrap def bootstrap
on(MRSK.hosts) { execute "which docker || (apt-get update -y && apt-get install docker.io -y)" } on(MRSK.hosts + MRSK.accessory_hosts) { execute "which docker || (apt-get update -y && apt-get install docker.io -y)" }
end end
end end

View File

@@ -15,12 +15,18 @@ class Mrsk::Cli::Traefik < Mrsk::Cli::Base
desc "start", "Start existing Traefik on servers" desc "start", "Start existing Traefik on servers"
def start def start
on(MRSK.traefik_hosts) { execute *MRSK.traefik.start, raise_on_non_zero_exit: false } on(MRSK.traefik_hosts) do
execute *MRSK.auditor.record("traefik start"), verbosity: :debug
execute *MRSK.traefik.start, raise_on_non_zero_exit: false
end
end end
desc "stop", "Stop Traefik on servers" desc "stop", "Stop Traefik on servers"
def stop def stop
on(MRSK.traefik_hosts) { execute *MRSK.traefik.stop, raise_on_non_zero_exit: false } on(MRSK.traefik_hosts) do
execute *MRSK.auditor.record("traefik stop"), verbosity: :debug
execute *MRSK.traefik.stop, raise_on_non_zero_exit: false
end
end end
desc "restart", "Restart Traefik on servers" desc "restart", "Restart Traefik on servers"
@@ -50,7 +56,7 @@ class Mrsk::Cli::Traefik < Mrsk::Cli::Base
end end
else else
since = options[:since] since = options[:since]
lines = options[:lines] lines = options[:lines].presence || ((since || grep) ? nil : 100) # Default to 100 lines if since or grep isn't set
on(MRSK.traefik_hosts) do |host| on(MRSK.traefik_hosts) do |host|
puts_by_host host, capture(*MRSK.traefik.logs(since: since, lines: lines, grep: grep)), type: "Traefik" puts_by_host host, capture(*MRSK.traefik.logs(since: since, lines: lines, grep: grep)), type: "Traefik"
@@ -67,11 +73,17 @@ class Mrsk::Cli::Traefik < Mrsk::Cli::Base
desc "remove_container", "Remove Traefik container from servers" desc "remove_container", "Remove Traefik container from servers"
def remove_container def remove_container
on(MRSK.traefik_hosts) { execute *MRSK.traefik.remove_container } on(MRSK.traefik_hosts) do
execute *MRSK.auditor.record("traefik remove container"), verbosity: :debug
execute *MRSK.traefik.remove_container
end
end end
desc "remove_container", "Remove Traefik image from servers" desc "remove_container", "Remove Traefik image from servers"
def remove_image def remove_image
on(MRSK.traefik_hosts) { execute *MRSK.traefik.remove_image } on(MRSK.traefik_hosts) do
execute *MRSK.auditor.record("traefik remove image"), verbosity: :debug
execute *MRSK.traefik.remove_image
end
end end
end end

View File

@@ -3,16 +3,17 @@ require "active_support/core_ext/enumerable"
require "mrsk/configuration" require "mrsk/configuration"
require "mrsk/commands/accessory" require "mrsk/commands/accessory"
require "mrsk/commands/app" require "mrsk/commands/app"
require "mrsk/commands/auditor"
require "mrsk/commands/builder" require "mrsk/commands/builder"
require "mrsk/commands/prune" require "mrsk/commands/prune"
require "mrsk/commands/traefik" require "mrsk/commands/traefik"
require "mrsk/commands/registry" require "mrsk/commands/registry"
class Mrsk::Commander class Mrsk::Commander
attr_accessor :config_file, :destination, :verbose, :version attr_accessor :config_file, :destination, :verbosity, :version
def initialize(config_file: nil, destination: nil, verbose: false) def initialize(config_file: nil, destination: nil, verbosity: :info)
@config_file, @destination, @verbose = config_file, destination, verbose @config_file, @destination, @verbosity = config_file, destination, verbosity
end end
def config def config
@@ -48,6 +49,10 @@ class Mrsk::Commander
specific_hosts || config.accessories.collect(&:host) specific_hosts || config.accessories.collect(&:host)
end end
def accessory_names
config.accessories&.collect(&:name) || []
end
def app def app
@app ||= Mrsk::Commands::App.new(config) @app ||= Mrsk::Commands::App.new(config)
@@ -70,11 +75,15 @@ class Mrsk::Commander
end end
def accessory(name) def accessory(name)
(@accessories ||= {})[name] ||= Mrsk::Commands::Accessory.new(config, name: name) Mrsk::Commands::Accessory.new(config, name: name)
end
def auditor
@auditor ||= Mrsk::Commands::Auditor.new(config)
end end
def verbosity(level) def with_verbosity(level)
old_level = SSHKit.config.output_verbosity old_level = SSHKit.config.output_verbosity
SSHKit.config.output_verbosity = level SSHKit.config.output_verbosity = level
yield yield
@@ -82,6 +91,13 @@ class Mrsk::Commander
SSHKit.config.output_verbosity = old_level SSHKit.config.output_verbosity = old_level
end end
# Test-induced damage!
def reset
@config = @config_file = @destination = @version = nil
@app = @builder = @traefik = @registry = @prune = @auditor = nil
@verbosity = :info
end
private private
def cascading_version def cascading_version
version.presence || ENV["VERSION"] || `git rev-parse HEAD`.strip version.presence || ENV["VERSION"] || `git rev-parse HEAD`.strip
@@ -91,6 +107,6 @@ class Mrsk::Commander
def configure_sshkit_with(config) def configure_sshkit_with(config)
SSHKit::Backend::Netssh.configure { |ssh| ssh.ssh_options = config.ssh_options } SSHKit::Backend::Netssh.configure { |ssh| ssh.ssh_options = config.ssh_options }
SSHKit.config.command_map[:docker] = "docker" # No need to use /usr/bin/env, just clogs up the logs SSHKit.config.command_map[:docker] = "docker" # No need to use /usr/bin/env, just clogs up the logs
SSHKit.config.output_verbosity = :debug if verbose SSHKit.config.output_verbosity = verbosity
end end
end end

View File

@@ -2,7 +2,7 @@ require "mrsk/commands/base"
class Mrsk::Commands::Accessory < Mrsk::Commands::Base class Mrsk::Commands::Accessory < Mrsk::Commands::Base
attr_reader :accessory_config attr_reader :accessory_config
delegate :service_name, :image, :host, :port, :env_args, :volume_args, :label_args, to: :accessory_config delegate :service_name, :image, :host, :port, :files, :directories, :env_args, :volume_args, :label_args, to: :accessory_config
def initialize(config, name:) def initialize(config, name:)
super(config) super(config)
@@ -33,6 +33,7 @@ class Mrsk::Commands::Accessory < Mrsk::Commands::Base
docker :ps, *service_filter docker :ps, *service_filter
end end
def logs(since: nil, lines: nil, grep: nil) def logs(since: nil, lines: nil, grep: nil)
pipe \ pipe \
docker(:logs, service_name, (" --since #{since}" if since), (" -n #{lines}" if lines), "-t", "2>&1"), docker(:logs, service_name, (" --since #{since}" if since), (" -n #{lines}" if lines), "-t", "2>&1"),
@@ -42,8 +43,57 @@ class Mrsk::Commands::Accessory < Mrsk::Commands::Base
def follow_logs(grep: nil) def follow_logs(grep: nil)
run_over_ssh pipe( run_over_ssh pipe(
docker(:logs, service_name, "-t", "-n", "10", "-f", "2>&1"), docker(:logs, service_name, "-t", "-n", "10", "-f", "2>&1"),
("grep '#{grep}'" if grep) (%(grep "#{grep}") if grep)
).join(" "), host: host ).join(" ")
end
def execute_in_existing_container(*command, interactive: false)
docker :exec,
("-it" if interactive),
service_name,
*command
end
def execute_in_new_container(*command, interactive: false)
docker :run,
("-it" if interactive),
"--rm",
*env_args,
*volume_args,
image,
*command
end
def execute_in_existing_container_over_ssh(*command)
run_over_ssh execute_in_existing_container(*command, interactive: true).join(" ")
end
def execute_in_new_container_over_ssh(*command)
run_over_ssh execute_in_new_container(*command, interactive: true).join(" ")
end
def run_over_ssh(command)
super command, host: host
end
def ensure_local_file_present(local_file)
if !local_file.is_a?(StringIO) && !Pathname.new(local_file).exist?
raise "Missing file: #{local_file}"
end
end
def make_directory_for(remote_file)
make_directory Pathname.new(remote_file).dirname.to_s
end
def make_directory(path)
[ :mkdir, "-p", path ]
end
def remove_service_directory
[ :rm, "-rf", service_name ]
end end
def remove_container def remove_container

View File

@@ -7,7 +7,7 @@ class Mrsk::Commands::App < Mrsk::Commands::Base
docker :run, docker :run,
"-d", "-d",
"--restart unless-stopped", "--restart unless-stopped",
"--name", config.service_with_version, "--name", service_with_version,
*rails_master_key_arg, *rails_master_key_arg,
*role.env_args, *role.env_args,
*config.volume_args, *config.volume_args,
@@ -16,40 +16,43 @@ class Mrsk::Commands::App < Mrsk::Commands::Base
role.cmd role.cmd
end end
def start(version: config.version) def start
docker :start, "#{config.service}-#{version}" docker :start, service_with_version
end
def current_container_id
docker :ps, "-q", *service_filter
end end
def stop def stop
pipe current_container_id, "xargs docker stop" pipe current_container_id, xargs(docker(:stop))
end end
def info def info
docker :ps, *service_filter docker :ps, *service_filter
end end
def logs(since: nil, lines: nil, grep: nil) def logs(since: nil, lines: nil, grep: nil)
pipe \ pipe \
current_container_id, current_container_id,
"xargs docker logs#{" --since #{since}" if since}#{" -n #{lines}" if lines} -t 2>&1", "xargs docker logs#{" --since #{since}" if since}#{" -n #{lines}" if lines} 2>&1",
("grep '#{grep}'" if grep) ("grep '#{grep}'" if grep)
end end
def exec(*command, interactive: false) def follow_logs(host:, grep: nil)
run_over_ssh pipe(
current_container_id,
"xargs docker logs -t -n 10 -f 2>&1",
(%(grep "#{grep}") if grep)
).join(" "), host: host
end
def execute_in_existing_container(*command, interactive: false)
docker :exec, docker :exec,
("-it" if interactive), ("-it" if interactive),
*rails_master_key_arg,
*config.env_args,
*config.volume_args,
config.service_with_version, config.service_with_version,
*command *command
end end
def run_exec(*command, interactive: false) def execute_in_new_container(*command, interactive: false)
docker :run, docker :run,
("-it" if interactive), ("-it" if interactive),
"--rm", "--rm",
@@ -60,37 +63,68 @@ class Mrsk::Commands::App < Mrsk::Commands::Base
*command *command
end end
def follow_logs(host:, grep: nil) def execute_in_existing_container_over_ssh(*command, host:)
run_over_ssh pipe( run_over_ssh execute_in_existing_container(*command, interactive: true).join(" "), host: host
current_container_id,
"xargs docker logs -t -n 10 -f 2>&1",
("grep '#{grep}'" if grep)
).join(" "), host: host
end end
def console(host:) def execute_in_new_container_over_ssh(*command, host:)
exec_over_ssh "bin/rails", "c", host: host run_over_ssh execute_in_new_container(*command, interactive: true).join(" "), host: host
end end
def bash(host:)
exec_over_ssh "bash", host: host def current_container_id
docker :ps, "-q", *service_filter
end end
def container_id_for(container_name:)
docker :container, :ls, "-a", "-f", "name=#{container_name}", "-q"
end
def current_running_version
# FIXME: Find more graceful way to extract the version from "app-version" than using sed and tail!
pipe \
docker(:ps, "--filter", "label=service=#{config.service}", "--format", '"{{.Names}}"'),
%(sed 's/-/\\n/g'),
"tail -n 1"
end
def most_recent_version_from_available_images
pipe \
docker(:image, :ls, "--format", '"{{.Tag}}"', config.repository),
"head -n 1"
end
def list_containers def list_containers
docker :container, :ls, "-a", *service_filter docker :container, :ls, "-a", *service_filter
end end
def remove_container(version:)
pipe \
container_id_for(container_name: service_with_version(version)),
xargs(docker(:container, :rm))
end
def remove_containers def remove_containers
docker :container, :prune, "-f", *service_filter docker :container, :prune, "-f", *service_filter
end end
def list_images
docker :image, :ls, config.repository
end
def remove_images def remove_images
docker :image, :prune, "-a", "-f", *service_filter docker :image, :prune, "-a", "-f", *service_filter
end end
private private
def exec_over_ssh(*command, host:) def service_with_version(version = nil)
run_over_ssh run_exec(*command, interactive: true).join(" "), host: host if version
"#{config.service}-#{version}"
else
config.service_with_version
end
end end
def service_filter def service_filter
@@ -98,6 +132,10 @@ class Mrsk::Commands::App < Mrsk::Commands::Base
end end
def rails_master_key_arg def rails_master_key_arg
[ "-e", redact("RAILS_MASTER_KEY=#{config.master_key}") ] if master_key = config.master_key
[ "-e", redact("RAILS_MASTER_KEY=#{master_key}") ]
else
[]
end
end end
end end

View File

@@ -0,0 +1,31 @@
require "active_support/core_ext/time/conversions"
require "mrsk/commands/base"
class Mrsk::Commands::Auditor < Mrsk::Commands::Base
def record(line)
append \
[ :echo, "'#{tags} #{line}'" ],
audit_log_file
end
def reveal
[ :tail, "-n", 50, audit_log_file ]
end
private
def audit_log_file
"mrsk-#{config.service}-audit.log"
end
def tags
"[#{timestamp}] [#{performer}]"
end
def performer
`whoami`.strip
end
def timestamp
Time.now.to_fs(:db)
end
end

View File

@@ -8,6 +8,10 @@ module Mrsk::Commands
@config = config @config = config
end end
def run_over_ssh(command, host:)
"ssh -t #{config.ssh_user}@#{host} '#{command}'"
end
private private
def combine(*commands, by: "&&") def combine(*commands, by: "&&")
commands commands
@@ -16,16 +20,24 @@ module Mrsk::Commands
.tap { |commands| commands.pop } # Remove trailing combiner .tap { |commands| commands.pop } # Remove trailing combiner
end end
def chain(*commands)
combine *commands, by: ";"
end
def pipe(*commands) def pipe(*commands)
combine *commands, by: "|" combine *commands, by: "|"
end end
def append(*commands)
combine *commands, by: ">>"
end
def xargs(command)
[ :xargs, command ].flatten
end
def docker(*args) def docker(*args)
args.compact.unshift :docker args.compact.unshift :docker
end end
def run_over_ssh(command, host:)
"ssh -t #{config.ssh_user}@#{host} '#{command}'"
end
end end
end end

View File

@@ -2,13 +2,13 @@ require "mrsk/commands/builder/native"
class Mrsk::Commands::Builder::Native::Remote < Mrsk::Commands::Builder::Native class Mrsk::Commands::Builder::Native::Remote < Mrsk::Commands::Builder::Native
def create def create
combine \ chain \
create_context, create_context,
create_buildx create_buildx
end end
def remove def remove
combine \ chain \
remove_context, remove_context,
remove_buildx remove_buildx
end end
@@ -25,7 +25,7 @@ class Mrsk::Commands::Builder::Native::Remote < Mrsk::Commands::Builder::Native
end end
def info def info
combine \ chain \
docker(:context, :ls), docker(:context, :ls),
docker(:buildx, :ls) docker(:buildx, :ls)
end end

View File

@@ -9,7 +9,8 @@ class Mrsk::Commands::Traefik < Mrsk::Commands::Base
"-v /var/run/docker.sock:/var/run/docker.sock", "-v /var/run/docker.sock:/var/run/docker.sock",
"traefik", "traefik",
"--providers.docker", "--providers.docker",
"--log.level=DEBUG" "--log.level=DEBUG",
*cmd_args
end end
def start def start
@@ -33,7 +34,7 @@ class Mrsk::Commands::Traefik < Mrsk::Commands::Base
def follow_logs(host:, grep: nil) def follow_logs(host:, grep: nil)
run_over_ssh pipe( run_over_ssh pipe(
docker(:logs, "traefik", "-t", "-n", "10", "-f", "2>&1"), docker(:logs, "traefik", "-t", "-n", "10", "-f", "2>&1"),
("grep '#{grep}'" if grep) (%(grep "#{grep}") if grep)
).join(" "), host: host ).join(" "), host: host
end end
@@ -44,4 +45,9 @@ class Mrsk::Commands::Traefik < Mrsk::Commands::Base
def remove_image def remove_image
docker :image, :prune, "-a", "-f", "--filter", "label=org.opencontainers.image.title=Traefik" docker :image, :prune, "-a", "-f", "--filter", "label=org.opencontainers.image.title=Traefik"
end end
private
def cmd_args
(config.raw_config.dig(:traefik, "args") || { }).collect { |(key, value)| [ "--#{key}", value ] }.flatten
end
end end

View File

@@ -9,13 +9,14 @@ class Mrsk::Configuration
delegate :service, :image, :servers, :env, :labels, :registry, :builder, to: :raw_config, allow_nil: true delegate :service, :image, :servers, :env, :labels, :registry, :builder, to: :raw_config, allow_nil: true
delegate :argumentize, :argumentize_env_with_secrets, to: Mrsk::Utils delegate :argumentize, :argumentize_env_with_secrets, to: Mrsk::Utils
attr_accessor :version
attr_accessor :raw_config attr_accessor :raw_config
class << self class << self
def create_from(base_config_file, destination: nil, version: "missing") def create_from(base_config_file, destination: nil, version: "missing")
new(load_config_file(base_config_file).tap do |config| new(load_config_file(base_config_file).tap do |config|
if destination if destination
config.merge! \ config.deep_merge! \
load_config_file destination_config_file(base_config_file, destination) load_config_file destination_config_file(base_config_file, destination)
end end
end, version: version) end, version: version)
@@ -39,7 +40,7 @@ class Mrsk::Configuration
def initialize(raw_config, version: "missing", validate: true) def initialize(raw_config, version: "missing", validate: true)
@raw_config = ActiveSupport::InheritableOptions.new(raw_config) @raw_config = ActiveSupport::InheritableOptions.new(raw_config)
@version = version @version = version
ensure_required_keys_present if validate valid? if validate
end end
@@ -52,7 +53,7 @@ class Mrsk::Configuration
end end
def accessories def accessories
@accessories ||= raw_config.accessories.keys.collect { |name| Mrsk::Configuration::Assessory.new(name, config: self) } @accessories ||= raw_config.accessories&.keys&.collect { |name| Mrsk::Configuration::Accessory.new(name, config: self) } || []
end end
def accessory(name) def accessory(name)
@@ -73,10 +74,6 @@ class Mrsk::Configuration
end end
def version
@version
end
def repository def repository
[ raw_config.registry["server"], image ].compact.join("/") [ raw_config.registry["server"], image ].compact.join("/")
end end
@@ -115,9 +112,17 @@ class Mrsk::Configuration
end end
def master_key def master_key
ENV["RAILS_MASTER_KEY"] || File.read(Pathname.new(File.expand_path("config/master.key"))) unless raw_config.skip_master_key
ENV["RAILS_MASTER_KEY"] || File.read(Pathname.new(File.expand_path("config/master.key")))
end
end end
def valid?
ensure_required_keys_present && ensure_env_available
end
def to_h def to_h
{ {
roles: role_names, roles: role_names,
@@ -130,12 +135,14 @@ class Mrsk::Configuration
env_args: env_args, env_args: env_args,
volume_args: volume_args, volume_args: volume_args,
ssh_options: ssh_options, ssh_options: ssh_options,
builder: raw_config.builder builder: raw_config.builder,
accessories: raw_config.accessories
}.compact }.compact
end end
private private
# Will raise ArgumentError if any required config keys are missing
def ensure_required_keys_present def ensure_required_keys_present
%i[ service image registry servers ].each do |key| %i[ service image registry servers ].each do |key|
raise ArgumentError, "Missing required configuration for #{key}" unless raw_config[key].present? raise ArgumentError, "Missing required configuration for #{key}" unless raw_config[key].present?
@@ -148,6 +155,16 @@ class Mrsk::Configuration
if raw_config.registry["password"].blank? if raw_config.registry["password"].blank?
raise ArgumentError, "You must specify a password for the registry in config/deploy.yml (or set the ENV variable if that's used)" raise ArgumentError, "You must specify a password for the registry in config/deploy.yml (or set the ENV variable if that's used)"
end end
true
end
# Will raise KeyError if any secret ENVs are missing
def ensure_env_available
env_args
roles.each(&:env_args)
true
end end
def role_names def role_names

View File

@@ -1,4 +1,4 @@
class Mrsk::Configuration::Assessory class Mrsk::Configuration::Accessory
delegate :argumentize, :argumentize_env_with_secrets, to: Mrsk::Utils delegate :argumentize, :argumentize_env_with_secrets, to: Mrsk::Utils
attr_accessor :name, :specifics attr_accessor :name, :specifics
@@ -43,8 +43,22 @@ class Mrsk::Configuration::Assessory
argumentize_env_with_secrets env argumentize_env_with_secrets env
end end
def files
specifics["files"]&.to_h do |local_to_remote_mapping|
local_file, remote_file = local_to_remote_mapping.split(":")
[ expand_local_file(local_file), expand_remote_file(remote_file) ]
end || {}
end
def directories
specifics["directories"]&.to_h do |host_to_container_mapping|
host_relative_path, container_path = host_to_container_mapping.split(":")
[ expand_host_path(host_relative_path), container_path ]
end || {}
end
def volumes def volumes
specifics["volumes"] || [] specific_volumes + remote_files_as_volumes + remote_directories_as_volumes
end end
def volume_args def volume_args
@@ -57,4 +71,53 @@ class Mrsk::Configuration::Assessory
def default_labels def default_labels
{ "service" => service_name } { "service" => service_name }
end end
def expand_local_file(local_file)
if local_file.end_with?("erb")
with_clear_env_loaded { read_dynamic_file(local_file) }
else
Pathname.new(File.expand_path(local_file)).to_s
end
end
def with_clear_env_loaded
(env["clear"] || env).each { |k, v| ENV[k] = v }
yield
ensure
(env["clear"] || env).each { |k, v| ENV.delete(k) }
end
def read_dynamic_file(local_file)
StringIO.new(ERB.new(IO.read(local_file)).result)
end
def expand_remote_file(remote_file)
service_name + remote_file
end
def specific_volumes
specifics["volumes"] || []
end
def remote_files_as_volumes
specifics["files"]&.collect do |local_to_remote_mapping|
_, remote_file = local_to_remote_mapping.split(":")
"#{service_data_directory + remote_file}:#{remote_file}"
end || []
end
def remote_directories_as_volumes
specifics["directories"]&.collect do |host_to_container_mapping|
host_relative_path, container_path = host_to_container_mapping.split(":")
[ expand_host_path(host_relative_path), container_path ].join(":")
end || []
end
def expand_host_path(host_relative_path)
"#{service_data_directory}/#{host_relative_path}"
end
def service_data_directory
"$PWD/#{service_name}"
end
end end

View File

@@ -18,7 +18,7 @@ module Mrsk::Utils
if (secrets = env["secret"]).present? if (secrets = env["secret"]).present?
argumentize("-e", secrets.to_h { |key| [ key, ENV.fetch(key) ] }, redacted: true) + argumentize("-e", env["clear"]) argumentize("-e", secrets.to_h { |key| [ key, ENV.fetch(key) ] }, redacted: true) + argumentize("-e", env["clear"])
else else
argumentize "-e", env argumentize "-e", env.fetch("clear", env)
end end
end end

View File

@@ -1,3 +1,3 @@
module Mrsk module Mrsk
VERSION = "0.2.0" VERSION = "0.5.1"
end end

View File

@@ -15,4 +15,5 @@ Gem::Specification.new do |spec|
spec.add_dependency "activesupport", ">= 7.0" spec.add_dependency "activesupport", ">= 7.0"
spec.add_dependency "sshkit", "~> 1.21" spec.add_dependency "sshkit", "~> 1.21"
spec.add_dependency "thor", "~> 1.2" spec.add_dependency "thor", "~> 1.2"
spec.add_dependency "dotenv", "~> 2.8"
end end

View File

@@ -1,17 +1,38 @@
require "test_helper" require_relative "cli_test_case"
require "active_support/testing/stream"
require "mrsk/cli"
class CliAccessoryTest < ActiveSupport::TestCase class CliAccessoryTest < CliTestCase
include ActiveSupport::Testing::Stream test "upload" do
assert_match "test/fixtures/files/my.cnf app-mysql/etc/mysql/my.cnf", run_command("upload", "mysql")
end
test "directories" do
assert_match "mkdir -p $PWD/app-mysql/data", run_command("directories", "mysql")
end
test "remove service direcotry" do
assert_match "rm -rf app-mysql", run_command("remove_service_directory", "mysql")
end
test "boot" do test "boot" do
ENV["MYSQL_ROOT_PASSWORD"] = "secret123" assert_match "Running docker run --name app-mysql -d --restart unless-stopped -p 3306:3306 -e [REDACTED] -e MYSQL_ROOT_HOST=% --volume $PWD/app-mysql/etc/mysql/my.cnf:/etc/mysql/my.cnf --volume $PWD/app-mysql/data:/var/lib/mysql --label service=app-mysql mysql:5.7 on 1.1.1.3", run_command("boot", "mysql")
command = stdouted { Mrsk::Cli::Accessory.start(["boot", "mysql", "-c", "test/fixtures/deploy_with_accessories.yml"]) }
assert_match "Running docker run --name app-mysql -d --restart unless-stopped -p 3306:3306 -e [REDACTED] -e MYSQL_ROOT_HOST=% --volume /var/lib/mysql:/var/lib/mysql --label service=app-mysql mysql:5.7 on 1.1.1.3", command
ensure
ENV["MYSQL_ROOT_PASSWORD"] = nil
end end
test "exec" do
run_command("exec", "mysql", "mysql -v").tap do |output|
assert_match /Launching command from new container/, output
assert_match /mysql -v/, output
end
end
test "exec with reuse" do
run_command("exec", "mysql", "--reuse", "mysql -v").tap do |output|
assert_match /Launching command from existing container/, output
assert_match %r[docker exec app-mysql mysql -v], output
end
end
private
def run_command(*command)
stdouted { Mrsk::Cli::Accessory.start([*command, "-c", "test/fixtures/deploy_with_accessories.yml"]) }
end
end end

67
test/cli/app_test.rb Normal file
View File

@@ -0,0 +1,67 @@
require_relative "cli_test_case"
class CliAppTest < CliTestCase
test "boot" do
assert_match /Running docker run -d --restart unless-stopped/, run_command("boot")
end
test "boot will reboot if same version is already running" do
run_command("details") # Preheat MRSK const
# Prevent expected failures from outputting to terminal
Thread.report_on_exception = false
MRSK.app.stubs(:run).raises(SSHKit::Command::Failed.new("already in use")).then.returns([ :docker, :run ])
run_command("boot").tap do |output|
assert_match /Rebooting container with same version already deployed/, output # Can't start what's already running
assert_match /docker ps -q --filter label=service=app \| xargs docker stop/, output # Stop what's running
assert_match /docker container ls -a -f name=app-999 -q \| xargs docker container rm/, output # Remove old container
assert_match /docker run/, output # Start new container
end
ensure
Thread.report_on_exception = true
end
test "start" do
run_command("start").tap do |output|
assert_match /docker start app-999/, output
end
end
test "stop" do
run_command("stop").tap do |output|
assert_match /docker ps -q --filter label=service=app \| xargs docker stop/, output
end
end
test "details" do
run_command("details").tap do |output|
assert_match /docker ps --filter label=service=app/, output
end
end
test "remove_container" do
run_command("remove_container", "1234567").tap do |output|
assert_match /docker container ls -a -f name=app-1234567 -q \| xargs docker container rm/, output
end
end
test "exec" do
run_command("exec", "ruby -v").tap do |output|
assert_match /ruby -v/, output
end
end
test "exec with reuse" do
run_command("exec", "--reuse", "ruby -v").tap do |output|
assert_match %r[docker ps --filter label=service=app --format \"{{.Names}}\" | sed 's/-/\\n/g' | tail -n 1], output # Get current version
assert_match %r[docker exec app-999 ruby -v], output
end
end
private
def run_command(*command)
stdouted { Mrsk::Cli::App.start([*command, "-c", "test/fixtures/deploy_with_accessories.yml"]) }
end
end

25
test/cli/cli_test_case.rb Normal file
View File

@@ -0,0 +1,25 @@
require "test_helper"
require "active_support/testing/stream"
require "mrsk/cli"
class CliTestCase < ActiveSupport::TestCase
include ActiveSupport::Testing::Stream
setup do
ENV["VERSION"] = "999"
ENV["RAILS_MASTER_KEY"] = "123"
ENV["MYSQL_ROOT_PASSWORD"] = "secret123"
end
teardown do
ENV.delete("RAILS_MASTER_KEY")
ENV.delete("MYSQL_ROOT_PASSWORD")
ENV.delete("VERSION")
MRSK.reset
end
private
def stdouted
capture(:stdout) { yield }.strip
end
end

View File

@@ -1,13 +1,6 @@
require "test_helper" require_relative "cli_test_case"
require "active_support/testing/stream"
require "mrsk/cli"
class CliMainTest < ActiveSupport::TestCase
include ActiveSupport::Testing::Stream
setup do
end
class CliMainTest < CliTestCase
test "version" do test "version" do
version = stdouted { Mrsk::Cli::Main.new.version } version = stdouted { Mrsk::Cli::Main.new.version }
assert_equal Mrsk::VERSION, version assert_equal Mrsk::VERSION, version

View File

@@ -41,18 +41,20 @@ class CommandsAccessoryTest < ActiveSupport::TestCase
@config = Mrsk::Configuration.new(@config) @config = Mrsk::Configuration.new(@config)
@mysql = Mrsk::Commands::Accessory.new(@config, name: :mysql) @mysql = Mrsk::Commands::Accessory.new(@config, name: :mysql)
@redis = Mrsk::Commands::Accessory.new(@config, name: :redis) @redis = Mrsk::Commands::Accessory.new(@config, name: :redis)
ENV["MYSQL_ROOT_PASSWORD"] = "secret123"
end
teardown do
ENV.delete("MYSQL_ROOT_PASSWORD")
end end
test "run" do test "run" do
ENV["MYSQL_ROOT_PASSWORD"] = "secret123"
assert_equal \ assert_equal \
[:docker, :run, "--name", "app-mysql", "-d", "--restart", "unless-stopped", "-p", "3306:3306", "-e", "MYSQL_ROOT_PASSWORD=secret123", "-e", "MYSQL_ROOT_HOST=%", "--label", "service=app-mysql", "mysql:8.0"], @mysql.run [:docker, :run, "--name", "app-mysql", "-d", "--restart", "unless-stopped", "-p", "3306:3306", "-e", "MYSQL_ROOT_PASSWORD=secret123", "-e", "MYSQL_ROOT_HOST=%", "--label", "service=app-mysql", "mysql:8.0"], @mysql.run
assert_equal \ assert_equal \
[:docker, :run, "--name", "app-redis", "-d", "--restart", "unless-stopped", "-p", "6379:6379", "-e", "SOMETHING=else", "--volume", "/var/lib/redis:/data", "--label", "service=app-redis", "--label", "cache=true", "redis:latest"], @redis.run [:docker, :run, "--name", "app-redis", "-d", "--restart", "unless-stopped", "-p", "6379:6379", "-e", "SOMETHING=else", "--volume", "/var/lib/redis:/data", "--label", "service=app-redis", "--label", "cache=true", "redis:latest"], @redis.run
ensure
ENV["MYSQL_ROOT_PASSWORD"] = nil
end end
test "start" do test "start" do
@@ -67,6 +69,35 @@ class CommandsAccessoryTest < ActiveSupport::TestCase
assert_equal [:docker, :ps, "--filter", "label=service=app-mysql"], @mysql.info assert_equal [:docker, :ps, "--filter", "label=service=app-mysql"], @mysql.info
end end
test "execute in new container" do
assert_equal \
[ :docker, :run, "--rm", "-e", "MYSQL_ROOT_PASSWORD=secret123", "-e", "MYSQL_ROOT_HOST=%", "mysql:8.0", "mysql", "-u", "root" ],
@mysql.execute_in_new_container("mysql", "-u", "root")
end
test "execute in existing container" do
assert_equal \
[ :docker, :exec, "app-mysql", "mysql", "-u", "root" ],
@mysql.execute_in_existing_container("mysql", "-u", "root")
end
test "execute in new container over ssh" do
@mysql.stub(:run_over_ssh, ->(cmd) { cmd }) do
assert_match %r|docker run -it --rm -e MYSQL_ROOT_PASSWORD=secret123 -e MYSQL_ROOT_HOST=% mysql:8.0 mysql -u root|,
@mysql.execute_in_new_container_over_ssh("mysql", "-u", "root")
end
end
test "execute in existing container over ssh" do
@mysql.stub(:run_over_ssh, ->(cmd) { cmd }) do
assert_match %r|docker exec -it app-mysql mysql -u root|,
@mysql.execute_in_existing_container_over_ssh("mysql", "-u", "root")
end
end
test "logs" do test "logs" do
assert_equal [:docker, :logs, "app-mysql", "-t", "2>&1"], @mysql.logs assert_equal [:docker, :logs, "app-mysql", "-t", "2>&1"], @mysql.logs
assert_equal [:docker, :logs, "app-mysql", " --since 5m", " -n 100", "-t", "2>&1", "|", "grep 'thing'"], @mysql.logs(since: "5m", lines: 100, grep: "thing") assert_equal [:docker, :logs, "app-mysql", " --since 5m", " -n 100", "-t", "2>&1", "|", "grep 'thing'"], @mysql.logs(since: "5m", lines: 100, grep: "thing")

View File

@@ -2,14 +2,18 @@ require "test_helper"
require "mrsk/configuration" require "mrsk/configuration"
require "mrsk/commands/app" require "mrsk/commands/app"
ENV["RAILS_MASTER_KEY"] = "456"
class CommandsAppTest < ActiveSupport::TestCase class CommandsAppTest < ActiveSupport::TestCase
setup do setup do
ENV["RAILS_MASTER_KEY"] = "456"
@config = { service: "app", image: "dhh/app", registry: { "username" => "dhh", "password" => "secret" }, servers: [ "1.1.1.1" ] } @config = { service: "app", image: "dhh/app", registry: { "username" => "dhh", "password" => "secret" }, servers: [ "1.1.1.1" ] }
@app = Mrsk::Commands::App.new Mrsk::Configuration.new(@config) @app = Mrsk::Commands::App.new Mrsk::Configuration.new(@config)
end end
teardown do
ENV["RAILS_MASTER_KEY"] = nil
end
test "run" do test "run" do
assert_equal \ assert_equal \
[:docker, :run, "-d", "--restart unless-stopped", "--name", "app-missing", "-e", "RAILS_MASTER_KEY=456", "--label", "service=app", "--label", "role=web", "--label", "traefik.http.routers.app.rule='PathPrefix(`/`)'", "--label", "traefik.http.services.app.loadbalancer.healthcheck.path=/up", "--label", "traefik.http.services.app.loadbalancer.healthcheck.interval=1s", "--label", "traefik.http.middlewares.app.retry.attempts=3", "--label", "traefik.http.middlewares.app.retry.initialinterval=500ms", "dhh/app:missing"], @app.run [:docker, :run, "-d", "--restart unless-stopped", "--name", "app-missing", "-e", "RAILS_MASTER_KEY=456", "--label", "service=app", "--label", "role=web", "--label", "traefik.http.routers.app.rule='PathPrefix(`/`)'", "--label", "traefik.http.services.app.loadbalancer.healthcheck.path=/up", "--label", "traefik.http.services.app.loadbalancer.healthcheck.interval=1s", "--label", "traefik.http.middlewares.app.retry.attempts=3", "--label", "traefik.http.middlewares.app.retry.initialinterval=500ms", "dhh/app:missing"], @app.run
@@ -22,9 +26,38 @@ class CommandsAppTest < ActiveSupport::TestCase
[:docker, :run, "-d", "--restart unless-stopped", "--name", "app-missing", "-e", "RAILS_MASTER_KEY=456", "--volume", "/local/path:/container/path", "--label", "service=app", "--label", "role=web", "--label", "traefik.http.routers.app.rule='PathPrefix(`/`)'", "--label", "traefik.http.services.app.loadbalancer.healthcheck.path=/up", "--label", "traefik.http.services.app.loadbalancer.healthcheck.interval=1s", "--label", "traefik.http.middlewares.app.retry.attempts=3", "--label", "traefik.http.middlewares.app.retry.initialinterval=500ms", "dhh/app:missing"], @app.run [:docker, :run, "-d", "--restart unless-stopped", "--name", "app-missing", "-e", "RAILS_MASTER_KEY=456", "--volume", "/local/path:/container/path", "--label", "service=app", "--label", "role=web", "--label", "traefik.http.routers.app.rule='PathPrefix(`/`)'", "--label", "traefik.http.services.app.loadbalancer.healthcheck.path=/up", "--label", "traefik.http.services.app.loadbalancer.healthcheck.interval=1s", "--label", "traefik.http.middlewares.app.retry.attempts=3", "--label", "traefik.http.middlewares.app.retry.initialinterval=500ms", "dhh/app:missing"], @app.run
end end
test "run with" do
test "execute in new container" do
assert_equal \ assert_equal \
[ :docker, :run, "--rm", "-e", "RAILS_MASTER_KEY=456", "dhh/app:missing", "bin/rails", "db:setup" ], [ :docker, :run, "--rm", "-e", "RAILS_MASTER_KEY=456", "dhh/app:missing", "bin/rails", "db:setup" ],
@app.run_exec("bin/rails", "db:setup") @app.execute_in_new_container("bin/rails", "db:setup")
end
test "execute in existing container" do
assert_equal \
[ :docker, :exec, "app-missing", "bin/rails", "db:setup" ],
@app.execute_in_existing_container("bin/rails", "db:setup")
end
test "execute in new container over ssh" do
@app.stub(:run_over_ssh, ->(cmd, host:) { cmd }) do
assert_match %r|docker run -it --rm -e RAILS_MASTER_KEY=456 dhh/app:missing bin/rails c|,
@app.execute_in_new_container_over_ssh("bin/rails", "c", host: "app-1")
end
end
test "execute in existing container over ssh" do
@app.stub(:run_over_ssh, ->(cmd, host:) { cmd }) do
assert_match %r|docker exec -it app-missing bin/rails c|,
@app.execute_in_existing_container_over_ssh("bin/rails", "c", host: "app-1")
end
end
test "run without master key" do
ENV["RAILS_MASTER_KEY"] = nil
@app = Mrsk::Commands::App.new Mrsk::Configuration.new(@config.tap { |c| c[:skip_master_key] = true })
assert @app.run.exclude?("RAILS_MASTER_KEY=456")
end end
end end

25
test/commands/registry_test.rb Executable file
View File

@@ -0,0 +1,25 @@
require "test_helper"
require "mrsk/configuration"
require "mrsk/commands/registry"
class CommandsRegistryTest < ActiveSupport::TestCase
setup do
@config = { service: "app",
image: "dhh/app",
registry: { "username" => "dhh",
"password" => "secret",
"server" => "hub.docker.com"
},
servers: [ "1.1.1.1" ]
}
@registry = Mrsk::Commands::Registry.new Mrsk::Configuration.new(@config)
end
test "registry login" do
assert_equal [ :docker, :login, "hub.docker.com", "-u", "dhh", "-p", "secret" ], @registry.login
end
test "registry logout" do
assert_equal [:docker, :logout, "hub.docker.com"], @registry.logout
end
end

View File

@@ -0,0 +1,78 @@
require "test_helper"
require "mrsk/configuration"
require "mrsk/commands/traefik"
class CommandsTraefikTest < ActiveSupport::TestCase
setup do
@config = {
service: "app", image: "dhh/app", registry: { "username" => "dhh", "password" => "secret" }, servers: [ "1.1.1.1" ],
traefik: { "args" => { "accesslog.format" => "json", "metrics.prometheus.buckets" => "0.1,0.3,1.2,5.0" } }
}
end
test "run" do
assert_equal \
[:docker, :run, "--name traefik", "-d", "--restart unless-stopped", "-p 80:80", "-v /var/run/docker.sock:/var/run/docker.sock", "traefik", "--providers.docker", "--log.level=DEBUG", "--accesslog.format", "json", "--metrics.prometheus.buckets", "0.1,0.3,1.2,5.0"],
new_command.run
end
test "traefik start" do
assert_equal \
[:docker, :container, :start, 'traefik'], new_command.start
end
test "traefik stop" do
assert_equal \
[:docker, :container, :stop, 'traefik'], new_command.stop
end
test "traefik info" do
assert_equal \
[:docker, :ps, '--filter', 'name=traefik'], new_command.info
end
test "traefik logs" do
assert_equal \
[:docker, :logs, 'traefik', '-t', '2>&1'], new_command.logs
end
test "traefik logs since 2h" do
assert_equal \
[:docker, :logs, 'traefik', ' --since 2h', '-t', '2>&1'], new_command.logs(since: '2h')
end
test "traefik logs last 10 lines" do
assert_equal \
[:docker, :logs, 'traefik', ' -n 10', '-t', '2>&1'], new_command.logs(lines: 10)
end
test "traefik logs with grep hello!" do
assert_equal \
[:docker, :logs, 'traefik', '-t', '2>&1', "|", "grep 'hello!'"], new_command.logs(grep: 'hello!')
end
test "traefik remove container" do
assert_equal \
[:docker, :container, :prune, "-f", "--filter", "label=org.opencontainers.image.title=Traefik"], new_command.remove_container
end
test "traefik remove image" do
assert_equal \
[:docker, :image, :prune, "-a", "-f", "--filter", "label=org.opencontainers.image.title=Traefik"], new_command.remove_image
end
test "traefik follow logs" do
assert_equal \
"ssh -t root@1.1.1.1 'docker logs traefik -t -n 10 -f 2>&1'", new_command.follow_logs(host: @config[:servers].first)
end
test "traefik follow logs with grep hello!" do
assert_equal \
"ssh -t root@1.1.1.1 'docker logs traefik -t -n 10 -f 2>&1 | grep \"hello!\"'", new_command.follow_logs(host: @config[:servers].first, grep: 'hello!')
end
private
def new_command
Mrsk::Commands::Traefik.new(Mrsk::Configuration.new(@config, version: "123"))
end
end

View File

@@ -18,8 +18,15 @@ class ConfigurationAccessoryTest < ActiveSupport::TestCase
}, },
"secret" => [ "secret" => [
"MYSQL_ROOT_PASSWORD" "MYSQL_ROOT_PASSWORD"
] ],
} },
"files" => [
"config/mysql/my.cnf:/etc/mysql/my.cnf",
"db/structure.sql:/docker-entrypoint-initdb.d/structure.sql"
],
"directories" => [
"data:/var/lib/mysql"
]
}, },
"redis" => { "redis" => {
"image" => "redis:latest", "image" => "redis:latest",
@@ -83,7 +90,19 @@ class ConfigurationAccessoryTest < ActiveSupport::TestCase
end end
test "volume args" do test "volume args" do
assert_equal [], @config.accessory(:mysql).volume_args assert_equal ["--volume", "$PWD/app-mysql/etc/mysql/my.cnf:/etc/mysql/my.cnf", "--volume", "$PWD/app-mysql/docker-entrypoint-initdb.d/structure.sql:/docker-entrypoint-initdb.d/structure.sql", "--volume", "$PWD/app-mysql/data:/var/lib/mysql"], @config.accessory(:mysql).volume_args
assert_equal ["--volume", "/var/lib/redis:/data"], @config.accessory(:redis).volume_args assert_equal ["--volume", "/var/lib/redis:/data"], @config.accessory(:redis).volume_args
end end
test "dynamic file expansion" do
@deploy[:accessories]["mysql"]["files"] << "test/fixtures/files/structure.sql.erb:/docker-entrypoint-initdb.d/structure.sql"
@config = Mrsk::Configuration.new(@deploy)
assert_match "This was dynamically expanded", @config.accessory(:mysql).files.keys[2].read
assert_match "%", @config.accessory(:mysql).files.keys[2].read
end
test "directories" do
assert_equal({"$PWD/app-mysql/data"=>"/var/lib/mysql"}, @config.accessory(:mysql).directories)
end
end end

View File

@@ -1,10 +1,10 @@
require "test_helper" require "test_helper"
require "mrsk/configuration" require "mrsk/configuration"
ENV["RAILS_MASTER_KEY"] = "456"
class ConfigurationTest < ActiveSupport::TestCase class ConfigurationTest < ActiveSupport::TestCase
setup do setup do
ENV["RAILS_MASTER_KEY"] = "456"
@deploy = { @deploy = {
service: "app", image: "dhh/app", service: "app", image: "dhh/app",
registry: { "username" => "dhh", "password" => "secret" }, registry: { "username" => "dhh", "password" => "secret" },
@@ -21,6 +21,10 @@ class ConfigurationTest < ActiveSupport::TestCase
@config_with_roles = Mrsk::Configuration.new(@deploy_with_roles) @config_with_roles = Mrsk::Configuration.new(@deploy_with_roles)
end end
teardown do
ENV["RAILS_MASTER_KEY"] = nil
end
test "ensure valid keys" do test "ensure valid keys" do
assert_raise(ArgumentError) do assert_raise(ArgumentError) do
Mrsk::Configuration.new(@deploy.tap { _1.delete(:service) }) Mrsk::Configuration.new(@deploy.tap { _1.delete(:service) })
@@ -101,6 +105,14 @@ class ConfigurationTest < ActiveSupport::TestCase
ENV["PASSWORD"] = nil ENV["PASSWORD"] = nil
end end
test "env args with only clear" do
config = Mrsk::Configuration.new(@deploy.tap { |c| c.merge!({
env: { "clear" => { "PORT" => "3000" } }
}) })
assert_equal [ "-e", "PORT=3000" ], config.env_args
end
test "env args with only secrets" do test "env args with only secrets" do
ENV["PASSWORD"] = "secret123" ENV["PASSWORD"] = "secret123"
config = Mrsk::Configuration.new(@deploy.tap { |c| c.merge!({ config = Mrsk::Configuration.new(@deploy.tap { |c| c.merge!({
@@ -114,15 +126,17 @@ class ConfigurationTest < ActiveSupport::TestCase
end end
test "env args with missing secret" do test "env args with missing secret" do
config = Mrsk::Configuration.new(@deploy.tap { |c| c.merge!({
env: { "secret" => [ "PASSWORD" ] }
}) })
assert_raises(KeyError) do assert_raises(KeyError) do
assert_equal [ "-e", "PASSWORD=secret123" ], config.env_args config = Mrsk::Configuration.new(@deploy.tap { |c| c.merge!({
env: { "secret" => [ "PASSWORD" ] }
}) })
end end
end end
test "valid config" do
assert @config.valid?
end
test "ssh options" do test "ssh options" do
assert_equal "root", @config.ssh_options[:user] assert_equal "root", @config.ssh_options[:user]
@@ -134,6 +148,11 @@ class ConfigurationTest < ActiveSupport::TestCase
assert_equal "456", @config.master_key assert_equal "456", @config.master_key
end end
test "skip master key" do
config = Mrsk::Configuration.new(@deploy.tap { |c| c[:skip_master_key] = true })
assert_nil @config.master_key
end
test "volume_args" do test "volume_args" do
assert_equal ["--volume", "/local/path:/container/path"], @config.volume_args assert_equal ["--volume", "/local/path:/container/path"], @config.volume_args
end end

View File

@@ -17,11 +17,13 @@ accessories:
MYSQL_ROOT_HOST: '%' MYSQL_ROOT_HOST: '%'
secret: secret:
- MYSQL_ROOT_PASSWORD - MYSQL_ROOT_PASSWORD
volumes: files:
- /var/lib/mysql:/var/lib/mysql - test/fixtures/files/my.cnf:/etc/mysql/my.cnf
directories:
- data:/var/lib/mysql
redis: redis:
image: redis:latest image: redis:latest
host: 1.1.1.4 host: 1.1.1.4
port: 6379 port: 6379
volumes: directories:
- /var/lib/redis:/data - data:/data

1
test/fixtures/files/my.cnf vendored Normal file
View File

@@ -0,0 +1 @@
# MySQL Config

2
test/fixtures/files/structure.sql.erb vendored Normal file
View File

@@ -0,0 +1,2 @@
<%= "This was dynamically expanded" %>
<%= ENV["MYSQL_ROOT_HOST"] %>

View File

@@ -2,6 +2,8 @@ require "bundler/setup"
require "active_support/test_case" require "active_support/test_case"
require "active_support/testing/autorun" require "active_support/testing/autorun"
require "debug" require "debug"
require "mocha/minitest" # using #stubs that can alter returns
require "minitest/autorun" # using #stub that take args
require "sshkit" require "sshkit"
ActiveSupport::LogSubscriber.logger = ActiveSupport::Logger.new(STDOUT) if ENV["VERBOSE"] ActiveSupport::LogSubscriber.logger = ActiveSupport::Logger.new(STDOUT) if ENV["VERBOSE"]
@@ -9,8 +11,4 @@ ActiveSupport::LogSubscriber.logger = ActiveSupport::Logger.new(STDOUT) if ENV["
SSHKit.config.backend = SSHKit::Backend::Printer SSHKit.config.backend = SSHKit::Backend::Printer
class ActiveSupport::TestCase class ActiveSupport::TestCase
private
def stdouted
capture(:stdout) { yield }.strip
end
end end