Compare commits

..

376 Commits

Author SHA1 Message Date
David Heinemeier Hansson
9aa57dd0c7 Bump version for 0.6.0 2023-02-05 17:53:43 +01:00
David Heinemeier Hansson
cb9f57356e Load destination ENV file also 2023-02-05 17:52:57 +01:00
David Heinemeier Hansson
02a5726072 Allow destination specific envifying 2023-02-05 16:35:37 +01:00
David Heinemeier Hansson
e865e823d5 Add envify for managing .env file 2023-02-05 16:30:56 +01:00
David Heinemeier Hansson
10cad5c459 Create binstub without bundler, document it all agnostically
You can use MRSK with something other than Rails.
2023-02-05 16:23:34 +01:00
David Heinemeier Hansson
ebcb297582 Merge pull request #24 from chrisdebruin/allow-bastion-server
Allow use of bastion host
2023-02-04 15:44:30 +01:00
David Heinemeier Hansson
0a293ae4d6 Fix and expand testing 2023-02-04 15:43:45 +01:00
Chris de Bruin
bdff11e1fc Allow use of bastion host 2023-02-04 15:38:05 +01:00
David Heinemeier Hansson
9cfb6fb0a9 Merge issue 2023-02-04 15:34:48 +01:00
David Heinemeier Hansson
9ec6f9d74f Merge branch 'main' into allow-bastion-server 2023-02-04 15:33:25 +01:00
David Heinemeier Hansson
45207f0c4f Explain the dance 2023-02-04 15:27:41 +01:00
David Heinemeier Hansson
cf9a402ad8 Stop treating RAILS_MASTER_KEY as special 2023-02-04 15:26:59 +01:00
David Heinemeier Hansson
64a5a790a7 Ensure secret can be used alone 2023-02-04 15:26:43 +01:00
David Heinemeier Hansson
78d4e1e1e9 Easier to read 2023-02-04 15:12:06 +01:00
David Heinemeier Hansson
74c7a6d5de Expand app command testing 2023-02-04 10:31:04 +01:00
David Heinemeier Hansson
340929e7e7 Use a version 2023-02-04 10:20:51 +01:00
David Heinemeier Hansson
6f1a3f5524 Don't need this, just use containers 2023-02-04 10:16:24 +01:00
David Heinemeier Hansson
7077da5a64 Spacing 2023-02-04 10:15:43 +01:00
David Heinemeier Hansson
77c63dcd04 Style 2023-02-04 10:14:35 +01:00
David Heinemeier Hansson
e7ac73be5a Join in run_over_ssh instead of all over 2023-02-04 10:14:31 +01:00
David Heinemeier Hansson
dfca9d8c48 Merge branch 'main' into allow-bastion-server 2023-02-04 10:06:15 +01:00
David Heinemeier Hansson
6032d5651a Merge pull request #35 from rails/zeitwerk
Load with Zeitwerk
2023-02-04 10:05:43 +01:00
Xavier Noria
539752e9bd Load with Zeitwerk 2023-02-03 22:45:12 +01:00
David Heinemeier Hansson
94b28a1b29 Extract method 2023-02-03 20:53:33 +01:00
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
Chris de Bruin
7d95472543 Added -J for ssh proxy 2023-02-03 14:31:09 +01:00
David Heinemeier Hansson
71681cb8be Use single string-based proxy declaration 2023-02-03 14:30:20 +01:00
Chris de Bruin
1fef6ba505 Allow use of bastion host 2023-02-03 14:30:20 +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
David Heinemeier Hansson
92565d58d5 Bump version for 0.2.0 2023-01-23 07:09:37 +01:00
David Heinemeier Hansson
2d0a1c33ae Merge pull request #23 from rails/accessories
Accessories
2023-01-22 22:02:04 +01:00
David Heinemeier Hansson
75bfdaa702 Fix references 2023-01-22 22:00:16 +01:00
David Heinemeier Hansson
c69d6e1569 Fix volume args 2023-01-22 21:58:30 +01:00
David Heinemeier Hansson
25fb08791a Correct merge conflict 2023-01-22 21:58:22 +01:00
David Heinemeier Hansson
6231a8668c Merge branch 'main' into accessories 2023-01-22 21:54:52 +01:00
David Heinemeier Hansson
b74ce02f31 Document accessories 2023-01-22 21:54:13 +01:00
David Heinemeier Hansson
1099b6fa84 Fix tests 2023-01-22 21:51:11 +01:00
David Heinemeier Hansson
247aaeb6ef Fix details to be per accessory 2023-01-22 21:39:12 +01:00
David Heinemeier Hansson
7ec7520d6d Output command and logs 2023-01-22 21:39:02 +01:00
David Heinemeier Hansson
5e15de0394 Use shared filter 2023-01-22 21:38:43 +01:00
David Heinemeier Hansson
bb15f98496 Include env 2023-01-22 21:38:37 +01:00
David Heinemeier Hansson
beb77fd3ef Merge pull request #21 from chrisdebruin/add-support-for-volumes
Added support for volumes
2023-01-22 19:48:16 +01:00
Chris de Bruin
6b19a0b6d4 Rename to volume_args 2023-01-22 17:09:08 +01:00
David Heinemeier Hansson
6b98eb3677 Operate accessories
When you want mysql, redis, and the like under MRSK management
2023-01-22 16:52:57 +01:00
David Heinemeier Hansson
48f8f7cb57 Fix test name from copypasta 2023-01-22 16:22:09 +01:00
Chris de Bruin
86ac1dd2d5 Add support for volumes 2023-01-22 16:21:50 +01:00
David Heinemeier Hansson
4432067585 Merge branch 'main' into add-support-for-volumes 2023-01-22 16:00:14 +01:00
David Heinemeier Hansson
a1c0cf39cb Disambiguate 2023-01-22 15:47:46 +01:00
David Heinemeier Hansson
2213739156 Fix tests 2023-01-22 15:43:47 +01:00
David Heinemeier Hansson
936d346ca6 Use directory for better organization 2023-01-22 15:37:42 +01:00
David Heinemeier Hansson
2af4885b39 Doc tweaks 2023-01-22 11:47:06 +01:00
David Heinemeier Hansson
e9f8eea6c9 Word doesn't add anything 2023-01-22 11:34:58 +01:00
David Heinemeier Hansson
82067cd077 Use similar headline form 2023-01-22 11:32:59 +01:00
David Heinemeier Hansson
48c45a0cf8 Explain reboot procedure 2023-01-22 11:31:19 +01:00
David Heinemeier Hansson
3a9c8455ec Style / presentatino 2023-01-22 11:27:39 +01:00
David Heinemeier Hansson
598e7ab97f Add power to follow logs on app and traefik 2023-01-22 11:27:31 +01:00
David Heinemeier Hansson
6eb0abbb30 Explain traefik: true 2023-01-22 11:00:24 +01:00
David Heinemeier Hansson
34652ca321 Always fetch to fail quick 2023-01-22 11:00:18 +01:00
David Heinemeier Hansson
917d429901 Simpler 2023-01-22 10:51:18 +01:00
David Heinemeier Hansson
a16e5ce886 Use class specific buildx instances
So we don't have to muck with the machine default, and can swap between configurations without tearing down the old builder.
2023-01-22 10:47:22 +01:00
David Heinemeier Hansson
e783950825 Always be verbose about building
Serves as progress indicator, step too long without one
2023-01-22 10:45:05 +01:00
David Heinemeier Hansson
e4dc4c300e Log more aggressively for now 2023-01-22 10:21:50 +01:00
David Heinemeier Hansson
925ac86459 No longer need actual class name with more descriptive name 2023-01-22 10:17:40 +01:00
David Heinemeier Hansson
1795c7c6a4 Doc updates 2023-01-22 10:12:46 +01:00
David Heinemeier Hansson
a3a7fce1e8 Note that it starts with SSH 2023-01-22 10:08:27 +01:00
David Heinemeier Hansson
bfec21c00f Recommend fetch for early bail-out 2023-01-22 10:07:07 +01:00
David Heinemeier Hansson
2ad135c237 No builder definition needed for native multiarch 2023-01-22 10:06:20 +01:00
David Heinemeier Hansson
287798ad57 Add option for remote building of single-arch 2023-01-22 10:06:04 +01:00
David Heinemeier Hansson
5c75404fe9 Add reboot Traefik to apply new start config 2023-01-22 09:44:09 +01:00
Chris de Bruin
2dc0f7cb66 Add support for volumes 2023-01-21 14:48:01 +01:00
David Heinemeier Hansson
652e17f260 Configure Traefik logs and catch all 2023-01-21 12:39:47 +01:00
David Heinemeier Hansson
ff636c3df6 Fix doc line to match new options 2023-01-21 12:39:28 +01:00
David Heinemeier Hansson
885fd5d2c9 Also restrick traefik logs command] 2023-01-21 12:31:55 +01:00
David Heinemeier Hansson
578bf79a7d Include builder options in to_h 2023-01-21 12:30:36 +01:00
David Heinemeier Hansson
fd23fc1dfd Ensure env secrets are merged correctly with roles 2023-01-21 11:32:40 +01:00
David Heinemeier Hansson
dda20eec11 Ensure secret envs are present 2023-01-21 10:58:11 +01:00
David Heinemeier Hansson
f6ca864e06 Add secret envs 2023-01-21 10:56:24 +01:00
David Heinemeier Hansson
3bf56c2fdb Allow custom version to be passed in via CLI 2023-01-20 17:46:09 +01:00
David Heinemeier Hansson
3d66e9ed33 Docs and outdated option 2023-01-20 17:19:37 +01:00
David Heinemeier Hansson
31389bc7b5 Global option for designating primary host only 2023-01-20 17:18:32 +01:00
David Heinemeier Hansson
79b5ed179e Move hosts/roles specification to cli args instead of ENV 2023-01-20 16:57:25 +01:00
David Heinemeier Hansson
0388495819 Extract capture_with_info 2023-01-20 16:32:12 +01:00
David Heinemeier Hansson
5d629d0600 Extract puts_by_host 2023-01-20 16:27:05 +01:00
David Heinemeier Hansson
73c53dd138 Add command to start a bash session 2023-01-20 15:14:24 +01:00
David Heinemeier Hansson
cdc06dff11 Spacing 2023-01-20 15:04:22 +01:00
David Heinemeier Hansson
95d8e7a75c All filters are optional 2023-01-20 14:55:28 +01:00
David Heinemeier Hansson
9551837c17 Allow since as an option
And properly output/grep logs
2023-01-20 14:48:53 +01:00
David Heinemeier Hansson
5f125f509f Flat arrays please 2023-01-20 14:40:08 +01:00
David Heinemeier Hansson
435b558260 Extract pipe pattern 2023-01-20 14:38:27 +01:00
David Heinemeier Hansson
ef9259fdd8 Hash uses except not without 2023-01-20 14:37:43 +01:00
David Heinemeier Hansson
af22c32c94 Get the current running container ID 2023-01-20 14:26:07 +01:00
David Heinemeier Hansson
8e69514b78 Actually use the build secrets! 2023-01-20 14:05:31 +01:00
David Heinemeier Hansson
8a32cc9c84 Traefik hosts can now be more than just web 2023-01-20 13:38:57 +01:00
David Heinemeier Hansson
2cb09be0cd Allow any role to turn on traefik labels 2023-01-20 13:32:12 +01:00
David Heinemeier Hansson
135fcdd9d3 Allow role to set env 2023-01-20 13:26:27 +01:00
David Heinemeier Hansson
c4006ee373 Add comparison to other options 2023-01-20 10:37:39 +01:00
David Heinemeier Hansson
4434b6e09b Merge pull request #17 from anoldguy/switch-to-docker-secrets
Enable docker secrets as a more secure alternative to build args
2023-01-20 10:27:53 +01:00
David Heinemeier Hansson
9bb1fb7166 Move argumentize to Utils 2023-01-20 10:26:36 +01:00
David Heinemeier Hansson
454015b294 Reuse argumentize for build secrets 2023-01-20 10:24:23 +01:00
David Heinemeier Hansson
52fe8d358e Secrets come just as keys 2023-01-20 10:13:03 +01:00
David Heinemeier Hansson
fe453ed38e Setup CI 2023-01-20 10:09:37 +01:00
David Heinemeier Hansson
a8779f7055 Simpler API
No need for redactions, since values aren't shared.
2023-01-20 10:07:17 +01:00
David Heinemeier Hansson
c16d950136 Refine docs on build secrets 2023-01-20 10:04:34 +01:00
Nathan Anderson
e516f427cd Enable docker secrets in the builder as a more secure alternative to build args. 2023-01-18 17:35:36 -05:00
David Heinemeier Hansson
84597e2fcd Damn instance eval 2023-01-17 15:32:36 +01:00
David Heinemeier Hansson
611fbd1dab Aliases and default 2023-01-17 15:19:02 +01:00
David Heinemeier Hansson
77fc10defb Default to 1K lines 2023-01-17 15:18:54 +01:00
David Heinemeier Hansson
5d641b932c Don't repeat the obvious 2023-01-17 15:18:45 +01:00
David Heinemeier Hansson
a342b565e8 Add grep and line configuration to logs 2023-01-17 14:11:27 +01:00
David Heinemeier Hansson
d580630ad2 Docs 2023-01-17 13:58:37 +01:00
David Heinemeier Hansson
7c844bf61d servers are a must key too 2023-01-17 13:42:24 +01:00
David Heinemeier Hansson
3c6309b4dd Add option to see combined config
Easier to realize how merged configs appear
2023-01-17 13:39:33 +01:00
David Heinemeier Hansson
9a84460754 Add option for two-part configs with the destination option 2023-01-17 13:35:55 +01:00
David Heinemeier Hansson
98af1d3d96 Naming 2023-01-17 13:34:59 +01:00
David Heinemeier Hansson
668b4060cb Move tests into directory 2023-01-17 12:18:32 +01:00
David Heinemeier Hansson
cb26fb9dca Run update as well before install (as some servers dont have it available otherwise) 2023-01-16 19:06:00 +01:00
David Heinemeier Hansson
9833a41382 Not interactive 2023-01-15 13:52:37 +01:00
David Heinemeier Hansson
8e58a9385a Allow exec to run in its own container 2023-01-15 13:51:08 +01:00
David Heinemeier Hansson
89161b66a1 Use delegation for shorter access 2023-01-15 13:50:38 +01:00
David Heinemeier Hansson
8fac321973 Forgot a spot 2023-01-15 13:24:47 +01:00
David Heinemeier Hansson
b96d760b9b Add the utils 2023-01-15 13:23:20 +01:00
David Heinemeier Hansson
760a87fe06 Redact build args (since they are often tokens) 2023-01-15 13:15:14 +01:00
David Heinemeier Hansson
bb8a8d3399 Singular form 2023-01-15 12:31:10 +01:00
David Heinemeier Hansson
2a0bcaf776 Shouldn't recommend embedding actual tokens in the config 2023-01-15 10:36:04 +01:00
David Heinemeier Hansson
bafbde52fe Add build args 2023-01-15 10:35:17 +01:00
David Heinemeier Hansson
53cd13a0fa Update README.md 2023-01-14 16:28:14 +01:00
David Heinemeier Hansson
15b0cc1df3 Check for remote/local 2023-01-14 13:07:22 +01:00
David Heinemeier Hansson
3c42d73ea7 Catch registry credentials errors nicer 2023-01-14 13:07:14 +01:00
David Heinemeier Hansson
f32ae43138 Bump version for 0.1.0 2023-01-14 12:35:17 +01:00
David Heinemeier Hansson
c3d2888c51 Update summary 2023-01-14 12:34:56 +01:00
David Heinemeier Hansson
6d1a166fdc Simplify 2023-01-14 12:33:05 +01:00
David Heinemeier Hansson
59be40cf12 Merge pull request #12 from rails/convert-to-thor
Switch to proper standalone executable with Thor
2023-01-14 12:28:24 +01:00
David Heinemeier Hansson
78494bdb0f Just rely on ENV for now 2023-01-14 12:27:38 +01:00
David Heinemeier Hansson
cce3d9ccfb Fix rollback 2023-01-14 12:23:34 +01:00
David Heinemeier Hansson
f0a3466d9d Rollback is clearer 2023-01-14 12:23:30 +01:00
David Heinemeier Hansson
e19e7f9bde Explicitly trying to start a specific version should fail if it can't 2023-01-14 12:23:22 +01:00
David Heinemeier Hansson
0b7af9ac14 Simplify 2023-01-14 12:17:04 +01:00
David Heinemeier Hansson
4551a2b9d7 Always try to log the command we're running remotely 2023-01-14 12:13:31 +01:00
David Heinemeier Hansson
e78da2a925 Update README to match new exec approach 2023-01-14 12:09:09 +01:00
David Heinemeier Hansson
94b3cfd0f4 Ship is cuter, but deploy is clearer
Kill your darlings
2023-01-14 12:07:52 +01:00
David Heinemeier Hansson
e3c1992ae9 Move HOST option to real option 2023-01-14 12:04:41 +01:00
David Heinemeier Hansson
ec31e931bf Add version task 2023-01-14 11:51:46 +01:00
David Heinemeier Hansson
e1e768d7cf Log traefik details commands 2023-01-14 11:51:38 +01:00
David Heinemeier Hansson
c44e224587 Add option to skip binstubs for older apps 2023-01-14 11:44:16 +01:00
David Heinemeier Hansson
fed64ef244 Switch to proper standalone executable with Thor 2023-01-14 11:31:37 +01:00
David Heinemeier Hansson
bf98a0308c Namespace buildx and contexts
To prevent clashes on remote builders
2023-01-13 17:29:53 +01:00
David Heinemeier Hansson
5179d0db37 Go with ship and make it the default 2023-01-13 17:12:46 +01:00
David Heinemeier Hansson
100d68d67e Only install docker if missing 2023-01-13 17:11:01 +01:00
David Heinemeier Hansson
eed8165ec1 Not worth the log noise 2023-01-13 15:44:56 +01:00
David Heinemeier Hansson
be89077917 Bump version for 0.0.3 2023-01-13 10:42:19 +01:00
David Heinemeier Hansson
6bfcc582c8 Singular 2023-01-13 10:30:02 +01:00
David Heinemeier Hansson
fd5172266e More expansive info on builder 2023-01-13 10:28:46 +01:00
David Heinemeier Hansson
e85c8161df Style 2023-01-13 10:28:35 +01:00
David Heinemeier Hansson
a1fc01639e Add build:info to check builder 2023-01-13 10:24:23 +01:00
David Heinemeier Hansson
7e764cbcd9 Explain how to use native builder 2023-01-13 10:18:42 +01:00
David Heinemeier Hansson
f177ee4cfe Make remote builder quack as any other builder 2023-01-13 10:16:28 +01:00
David Heinemeier Hansson
ea9a50ec95 Extract command #combine 2023-01-13 10:00:11 +01:00
David Heinemeier Hansson
6ea06fd04e Log the builder used 2023-01-13 09:49:06 +01:00
David Heinemeier Hansson
6ccb3d2319 Allow for fully native builds too
Skipping multiarch if there's a platform match between dev and prod.
2023-01-13 09:31:47 +01:00
David Heinemeier Hansson
05f1ef5ee8 Registry login actually not necessary 2023-01-12 22:22:22 +01:00
David Heinemeier Hansson
f1a98457b0 Pin platforms 2023-01-12 22:14:05 +01:00
David Heinemeier Hansson
7ae596ef60 Document remote native builds 2023-01-12 21:45:45 +01:00
David Heinemeier Hansson
2257c99189 Add local/remote builder combo for multiarch 2023-01-12 21:35:31 +01:00
David Heinemeier Hansson
5afadb10ca Nicer name for CLI 2023-01-12 18:50:18 +01:00
David Heinemeier Hansson
b3992973d6 Extract builder from app
Building is different from running
2023-01-12 18:16:52 +01:00
David Heinemeier Hansson
08c30a14b9 Use a single builder for MRSK 2023-01-12 18:08:33 +01:00
David Heinemeier Hansson
76d34d2a1c Note quoting issue 2023-01-12 17:42:49 +01:00
David Heinemeier Hansson
184ab18667 Style 2023-01-12 17:38:26 +01:00
David Heinemeier Hansson
87abf06076 Note on exception seen 2023-01-12 17:37:57 +01:00
David Heinemeier Hansson
453570b895 Breakout remove so we can do just containers 2023-01-12 17:37:50 +01:00
David Heinemeier Hansson
f61beb6827 Basic binstub 2023-01-12 17:29:26 +01:00
David Heinemeier Hansson
c481938cdb Reference Traefik docs for more routing rules 2023-01-12 17:16:30 +01:00
David Heinemeier Hansson
7e9b73f86a Add custom labels 2023-01-12 17:15:29 +01:00
David Heinemeier Hansson
1f06b1ff94 Switch to just last 100 log lines for now 2023-01-12 16:00:21 +01:00
David Heinemeier Hansson
d554ae8500 Add back prune 2023-01-12 15:51:01 +01:00
David Heinemeier Hansson
730de486b7 More doc changes 2023-01-12 15:29:56 +01:00
David Heinemeier Hansson
b333c4a05b Simplify presentation of configuration 2023-01-12 15:22:48 +01:00
David Heinemeier Hansson
eec6670dbf Tokens are good too 2023-01-12 15:16:29 +01:00
David Heinemeier Hansson
4aa96d6578 Switch to a Commander base to allow lazy loading config 2023-01-12 14:58:17 +01:00
David Heinemeier Hansson
d3ab10be22 Better require setup 2023-01-12 14:57:34 +01:00
David Heinemeier Hansson
d92318e234 Excess line 2023-01-11 17:58:50 +01:00
David Heinemeier Hansson
e62610069b Correct commadn 2023-01-11 17:46:35 +01:00
David Heinemeier Hansson
a0582c1bdf Explain registry 2023-01-11 17:46:00 +01:00
David Heinemeier Hansson
880ce46c39 Match service name 2023-01-11 17:44:26 +01:00
David Heinemeier Hansson
d049d73547 Realistic looking IP 2023-01-11 17:43:36 +01:00
David Heinemeier Hansson
453fea6c45 Don't rely on ERB interpolation that might fail
Error message isn't good
2023-01-11 17:43:28 +01:00
David Heinemeier Hansson
2694cf5d5f Make init more resilient and communicative 2023-01-11 17:43:07 +01:00
David Heinemeier Hansson
5324fbe3d0 Give feedback on what happened 2023-01-11 17:35:53 +01:00
David Heinemeier Hansson
5e214cde3c Explain where to set this 2023-01-11 17:35:46 +01:00
David Heinemeier Hansson
f61f41ad73 Document app console 2023-01-11 17:28:18 +01:00
David Heinemeier Hansson
d9cdbb87f9 Heads up that this could take a while 2023-01-11 17:26:49 +01:00
David Heinemeier Hansson
543af475d5 Create missing buildx builder if missing automatically 2023-01-11 17:24:32 +01:00
David Heinemeier Hansson
1bb9fe9095 Reuse existing exec command 2023-01-11 17:11:57 +01:00
David Heinemeier Hansson
c6fd4399f1 Hint at which version to start 2023-01-11 17:07:34 +01:00
David Heinemeier Hansson
a4a9f619ad Protect against missing envs 2023-01-11 17:07:22 +01:00
David Heinemeier Hansson
4392bf0ee9 Allow you to turn full verbosity on easily 2023-01-11 17:05:20 +01:00
David Heinemeier Hansson
3b3ab48120 Set a different verbosity level for the duration of the yield 2023-01-11 17:01:19 +01:00
David Heinemeier Hansson
606550d46b Reveal what was pruned 2023-01-11 17:01:12 +01:00
David Heinemeier Hansson
e1b327915f Use error logger instead 2023-01-11 17:01:03 +01:00
David Heinemeier Hansson
9d3871d667 Split out proper Prune command 2023-01-11 16:48:10 +01:00
David Heinemeier Hansson
7d83be2d18 Readability 2023-01-11 16:26:26 +01:00
David Heinemeier Hansson
3e2c48782c Explaining consts 2023-01-11 13:31:25 +01:00
David Heinemeier Hansson
bcdeeff94f Start remote Rails console on primary host 2023-01-10 20:45:15 +01:00
David Heinemeier Hansson
c5249b4a9e Host yield not needed 2023-01-10 20:44:54 +01:00
David Heinemeier Hansson
57e49bb26c Bump version for 0.0.2 2023-01-10 19:16:34 +01:00
David Heinemeier Hansson
1609b43ef8 Temporary fix for #2 2023-01-10 19:15:40 +01:00
David Heinemeier Hansson
f9010c1b75 Only run Traefik on web role 2023-01-10 19:04:35 +01:00
David Heinemeier Hansson
73b7c691d6 Fix references 2023-01-10 18:56:30 +01:00
David Heinemeier Hansson
3473ec7a86 Explain running job servers separately 2023-01-10 17:31:36 +01:00
David Heinemeier Hansson
e8beb362d0 Add role concern with specialized cmds for job running 2023-01-10 17:27:56 +01:00
David Heinemeier Hansson
1cee87d440 Latest bundler 2023-01-10 15:02:25 +01:00
David Heinemeier Hansson
c2e09b9b2f Added debug 2023-01-10 14:24:25 +01:00
David Heinemeier Hansson
78a5d08d3f Switch to host naming
Servers concept will encompass custom cmd and labels. Host is just the IP address.
2023-01-10 14:15:16 +01:00
David Heinemeier Hansson
5ca6f32ee7 Use debug gem 2023-01-10 13:17:28 +01:00
David Heinemeier Hansson
6b098a1e2e Ruby 3.2.0 compatibility 2023-01-10 13:17:18 +01:00
David Heinemeier Hansson
ff5ccac8fe Cleanup 2023-01-09 20:44:54 +01:00
David Heinemeier Hansson
b4edf8eef9 Ignore nil command bits
They might come from conditional options
2023-01-09 18:08:34 +01:00
David Heinemeier Hansson
fe52ce6547 Add command execution 2023-01-09 14:36:33 +01:00
David Heinemeier Hansson
9641ce0edd Update README.md 2023-01-08 18:38:55 +01:00
David Heinemeier Hansson
1dab9c1fb5 More documentation 2023-01-08 16:50:06 +01:00
David Heinemeier Hansson
10d973200d Add command to list containers (to ease rollback) 2023-01-08 16:45:41 +01:00
David Heinemeier Hansson
fdfdff65e9 Explain command map overwrite 2023-01-08 16:33:09 +01:00
David Heinemeier Hansson
94d61f3d9a Proper param array separation 2023-01-08 16:32:31 +01:00
David Heinemeier Hansson
483f686efc Test config labels 2023-01-08 16:29:59 +01:00
David Heinemeier Hansson
51adbc032e Test app#run 2023-01-08 16:29:51 +01:00
David Heinemeier Hansson
dcb3e4d491 Switch envs and labels to param array 2023-01-08 16:29:44 +01:00
David Heinemeier Hansson
55445ae110 Style 2023-01-08 16:22:50 +01:00
David Heinemeier Hansson
998525c93d Switch to cmd array so we can redact 2023-01-08 16:20:06 +01:00
David Heinemeier Hansson
4ec04f8959 Language 2023-01-08 15:13:51 +01:00
David Heinemeier Hansson
3ddf2b9c41 Distinguish run from start 2023-01-08 15:13:45 +01:00
David Heinemeier Hansson
d4210b66d0 Language 2023-01-08 15:13:28 +01:00
David Heinemeier Hansson
7f37abac59 If already started, just carry on 2023-01-08 15:13:24 +01:00
David Heinemeier Hansson
399d32d7d0 Known VERSION means we've already pushed 2023-01-08 15:13:14 +01:00
David Heinemeier Hansson
8d16271150 Make run resilient to same version having already been run 2023-01-08 15:13:03 +01:00
David Heinemeier Hansson
e1724e0cd9 Clarify output 2023-01-08 14:55:51 +01:00
David Heinemeier Hansson
43eac9d414 Use DRY extraction 2023-01-08 14:55:14 +01:00
David Heinemeier Hansson
ffb532a50d Add remove tasks to clean up 2023-01-08 14:55:06 +01:00
David Heinemeier Hansson
23c2cb898c Explain need to match with Dockerfile LABEL 2023-01-08 14:38:03 +01:00
David Heinemeier Hansson
14867a2f61 Allow logging out of registry 2023-01-08 14:18:00 +01:00
David Heinemeier Hansson
4b46449fdf Split out repository to be used alone 2023-01-08 14:07:29 +01:00
David Heinemeier Hansson
87ca059f32 Fix dangling parenthesis 2023-01-08 14:07:08 +01:00
David Heinemeier Hansson
1fcc2d3cfd Remember to use Shellwords later 2023-01-08 13:39:38 +01:00
David Heinemeier Hansson
d43ceb975f Create config stub with mrsk:init 2023-01-08 13:39:29 +01:00
David Heinemeier Hansson
4f06b5f99b Clarify that one server needn't an LB 2023-01-08 12:14:46 +01:00
David Heinemeier Hansson
21df2aefe5 Prune containers first to release images 2023-01-08 12:13:19 +01:00
David Heinemeier Hansson
5979f1d43e Prune by default after deploy 2023-01-08 12:11:44 +01:00
David Heinemeier Hansson
9e7ce59b85 Use a shared prune 2023-01-08 12:08:28 +01:00
David Heinemeier Hansson
6e853786eb Prepare for auto-pruning 2023-01-08 11:54:43 +01:00
David Heinemeier Hansson
e378e9a6dd Not used 2023-01-08 11:54:32 +01:00
David Heinemeier Hansson
6c3a4b1792 Explain rollback 2023-01-08 11:47:04 +01:00
David Heinemeier Hansson
73019bedfb Keep containers around for quick rollback + restarting
Now need to deal with pruning.
2023-01-08 11:45:48 +01:00
David Heinemeier Hansson
e8fc046537 Update README.md 2023-01-08 11:33:09 +01:00
David Heinemeier Hansson
a45a40b996 Done 2023-01-08 11:29:55 +01:00
David Heinemeier Hansson
3cad095e2b Add ERB eval so we can use credentials 2023-01-08 11:11:57 +01:00
David Heinemeier Hansson
cc3619173d Split out push/pull and aggregate in deliver 2023-01-08 10:07:32 +01:00
David Heinemeier Hansson
ddb4d549f2 Need setup 2023-01-08 10:07:13 +01:00
David Heinemeier Hansson
7f220ea987 Bootstrap entirely clean new server 2023-01-08 10:07:08 +01:00
David Heinemeier Hansson
4cbc4aa9b7 Update README.md 2023-01-08 09:37:12 +01:00
David Heinemeier Hansson
9c6cd33dec Ensure we're logged in 2023-01-08 09:35:55 +01:00
David Heinemeier Hansson
ef87cd5634 Explain registry configuration 2023-01-08 09:35:45 +01:00
David Heinemeier Hansson
9d9a9c4116 Only need absolute_image 2023-01-07 22:02:28 +01:00
69 changed files with 3601 additions and 224 deletions

27
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,27 @@
name: CI
on: [push, pull_request]
jobs:
tests:
strategy:
matrix:
ruby-version:
- "2.7"
- "3.1"
- "3.2"
continue-on-error: [false]
name: ${{ format('Tests (Ruby {0})', matrix.ruby-version) }}
runs-on: ubuntu-latest
continue-on-error: ${{ matrix.continue-on-error }}
steps:
- uses: actions/checkout@v2
- name: Install Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: ${{ matrix.ruby-version }}
bundler-cache: true
- name: Run tests
run: bin/test

2
.gitignore vendored
View File

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

View File

@@ -1,9 +1,8 @@
source 'https://rubygems.org'
git_source(:github) { |repo| "https://github.com/#{repo}.git" }
# Specify your gem's dependencies in importmap-rails.gemspec.
gemspec
group :test do
gem "byebug"
end
gem "debug"
gem "mocha"
gem "railties"

View File

@@ -1,9 +1,12 @@
PATH
remote: .
specs:
mrsk (0.0.1)
railties (>= 7.0.0)
mrsk (0.6.0)
activesupport (>= 7.0)
dotenv (~> 2.8)
sshkit (~> 1.21)
thor (~> 1.2)
zeitwerk (~> 2.5)
GEM
remote: https://rubygems.org/
@@ -27,21 +30,33 @@ GEM
minitest (>= 5.1)
tzinfo (~> 2.0)
builder (3.2.4)
byebug (11.1.3)
concurrent-ruby (1.1.10)
crass (1.0.6)
debug (1.7.1)
irb (>= 1.5.0)
reline (>= 0.3.1)
dotenv (2.8.1)
erubi (1.12.0)
i18n (1.12.0)
concurrent-ruby (~> 1.0)
io-console (0.6.0)
irb (1.6.2)
reline (>= 0.3.0)
loofah (2.19.1)
crass (~> 1.0.2)
nokogiri (>= 1.5.9)
method_source (1.0.0)
minitest (5.17.0)
mocha (2.0.2)
ruby2_keywords (>= 0.0.5)
net-scp (4.0.0)
net-ssh (>= 2.6.5, < 8.0.0)
net-ssh (7.0.1)
nokogiri (1.13.10-arm64-darwin)
nokogiri (1.14.0-arm64-darwin)
racc (~> 1.4)
nokogiri (1.14.0-x86_64-darwin)
racc (~> 1.4)
nokogiri (1.14.0-x86_64-linux)
racc (~> 1.4)
racc (1.6.2)
rack (2.2.5)
@@ -60,6 +75,9 @@ GEM
thor (~> 1.0)
zeitwerk (~> 2.5)
rake (13.0.6)
reline (0.3.2)
io-console (~> 0.5)
ruby2_keywords (0.0.5)
sshkit (1.21.3)
net-scp (>= 1.1.2)
net-ssh (>= 2.8.0)
@@ -74,11 +92,14 @@ PLATFORMS
arm64-darwin-22
x86_64-darwin-20
x86_64-darwin-21
x86_64-darwin-22
x86_64-linux
DEPENDENCIES
byebug
debug
mocha
mrsk!
railties
BUNDLED WITH
2.2.33
2.4.3

462
README.md
View File

@@ -1,55 +1,455 @@
# MRSK
MRSK lets you do zero-downtime deploys of Rails apps packed as containers to any host running Docker. It uses the dynamic reverse-proxy Traefik to hold requests while the new application container is started and the old one is wound down. It works across multiple hosts at the same time, using SSHKit to execute commands.
MRSK deploys web apps in containers to servers running Docker with zero downtime. It uses the dynamic reverse-proxy Traefik to hold requests while the new application container is started and the old one is stopped. It works seamlessly across multiple hosts, using SSHKit to execute commands.
## Installation
Create a configuration file for MRSK in `config/deploy.yml` that looks like this:
Install MRSK globally with `gem install mrsk`. Then, inside your app directory, run `mrsk init` (or `mrsk init --bundle` within Rails apps where you want a bin/mrsk binstub). Now edit the new file `config/deploy.yml`. It could look as simple as this:
```yaml
service: my-app
image: name/my-app
service: hey
image: 37s/hey
servers:
- xxx.xxx.xxx.xxx
- xxx.xxx.xxx.xxx
- 192.168.0.1
- 192.168.0.2
registry:
username: registry-user-name
password: <%= ENV.fetch("MRSK_REGISTRY_PASSWORD") %>
env:
DATABASE_URL: mysql2://username@localhost/database_name/
REDIS_URL: redis://host:6379/1
secret:
- RAILS_MASTER_KEY
```
Then first login to the Docker Hub registry on the servers:
Then edit your `.env` file to add your registry password as `MRSK_REGISTRY_PASSWORD` (and your `RAILS_MASTER_KEY` for production with a Rails app).
Now you're ready to deploy to the servers:
```
rake mrsk:registry:login DOCKER_USER=name DOCKER_PASSWORD=pw
```
Now you're ready to deploy a multi-arch image (FIXME: currently you need to manually run `docker buildx create --use` once first):
```
rake mrsk:deploy
mrsk deploy
```
This will:
1. Build the image using the standard Dockerfile in the root of the application.
2. Push the image to the registry.
3. Pull the image on all the servers.
4. Ensure Traefik is running and accepting traffic on port 80.
5. Stop any containers running a previous versions of the app.
6. Start a new container with the version of the app that matches the current git version hash.
1. Connect to the servers over SSH (using root by default, authenticated by your loaded ssh key)
2. Install Docker on any server that might be missing it (using apt-get)
3. Log into the registry both locally and remotely
4. Build the image using the standard Dockerfile in the root of the application.
5. Push the image to the registry.
6. Pull the image from the registry on the servers.
7. Ensure Traefik is running and accepting traffic on port 80.
8. Stop any containers running a previous versions of the app.
9. Start a new container with the version of the app that matches the current git version hash.
10. Prune unused images and stopped containers to ensure servers don't fill up.
Voila! All the servers are now serving the app on port 80, and you're ready to put them behind a load balancer to serve live traffic.
Voila! All the servers are now serving the app on port 80. If you're just running a single server, you're ready to go. If you're running multiple servers, you need to put a load balancer in front of them.
## Why not just run Capistrano or Kubernetes?
MRSK basically is Capistrano for Containers, which allow us to use vanilla servers as the hosts. No need to ensure that the servers have just the right version of Ruby or other dependencies you need. That all lives in the Docker image now. You can boot a brand new Ubuntu (or whatever) server, add it to the deploy servers of MRSK, and it'll be auto-provisioned with Docker, and run right away. Docker's layer caching also allows for quicker deployments with less mucking about on the server. And the images built for MRSK can be used for CI or later introspection.
Kubernetes is a beast. Running it yourself on your own hardware is not for the faint of heart. It's a fine option if you want to run on someone else's platform, like Render or Fly, but if you'd like the freedom to move between cloud and your own hardware, or even mix the two, MRSK is much simpler. You can see everything that's going on, it's just basic Docker commands being called.
## 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
The default registry is Docker Hub, but you can change it using `registry/server`:
```yaml
registry:
server: registry.digitalocean.com
username: registry-user-name
password: <%= ENV.fetch("MRSK_REGISTRY_PASSWORD") %>
```
### Using a different SSH user than root
The default SSH user is root, but you can change it using `ssh/user`:
```yaml
ssh:
user: app
```
### Using a proxy SSH host
If you need to connect to server through a proxy host, you can use `ssh/proxy`:
```yaml
ssh:
proxy: "192.168.0.1" # defaults to root as the user
```
Or with specific user:
```yaml
ssh:
proxy: "app@192.168.0.1"
```
### Using env variables
You can inject env variables into the app containers using `env`:
```yaml
env:
DATABASE_URL: mysql2://db1/hey_production/
REDIS_URL: redis://redis1:6379/1
```
### Using secret env variables
If you have env variables that are secret, you can divide the `env` block into `clear` and `secret`:
```yaml
env:
clear:
DATABASE_URL: mysql2://db1/hey_production/
REDIS_URL: redis://redis1:6379/1
secret:
- DATABASE_PASSWORD
- REDIS_PASSWORD
```
The list of secret env variables will be expanded at run time from your local machine. So a reference to a secret `DATABASE_PASSWORD` will look for `ENV["DATABASE_PASSWORD"]` on the machine running MRSK. Just like with build secrets.
If the referenced secret ENVs are missing, the configuration will be halted with a `KeyError` exception.
Note: Marking an ENV as secret currently only redacts its value in the output for MRSK. The ENV is still injected in the clear into the container at runtime.
### Using volumes
You can add custom volumes into the app containers using `volumes`:
```yaml
volumes:
- "/local/path:/container/path"
```
### Using different roles for servers
If your application uses separate hosts for running jobs or other roles beyond the default web running, you can specify these hosts in a dedicated role with a new entrypoint command like so:
```yaml
servers:
web:
- 192.168.0.1
- 192.168.0.2
job:
hosts:
- 192.168.0.3
- 192.168.0.4
cmd: bin/jobs
```
Note: Traefik will only by default be installed and run on the servers in the `web` role (and on all servers if no roles are defined). If you need Traefik on hosts in other roles than `web`, add `traefik: true`:
```yaml
servers:
web:
- 192.168.0.1
- 192.168.0.2
web2:
traefik: true
hosts:
- 192.168.0.3
- 192.168.0.4
```
### Using container labels
You can specialize the default Traefik rules by setting labels on the containers that are being started:
```
labels:
traefik.http.routers.hey.rule: '''Host(`app.hey.com`)'''
```
Note: The extra quotes are needed to ensure the rule is passed in correctly!
This allows you to run multiple applications on the same server sharing the same Traefik instance and port.
See https://doc.traefik.io/traefik/routing/routers/#rule for a full list of available routing rules.
The labels can also be applied on a per-role basis:
```yaml
servers:
web:
- 192.168.0.1
- 192.168.0.2
job:
hosts:
- 192.168.0.3
- 192.168.0.4
cmd: bin/jobs
labels:
my-label: "50"
```
### Using remote builder for native multi-arch
If you're developing on ARM64 (like Apple Silicon), but you want to deploy on AMD64 (x86 64-bit), you can use multi-archecture images. By default, MRSK will setup a local buildx configuration that does this through QEMU emulation. But this can be quite slow, especially on the first build.
If you want to speed up this process by using a remote AMD64 host to natively build the AMD64 part of the image, while natively building the ARM64 part locally, you can do so using builder options:
```yaml
builder:
local:
arch: arm64
host: unix:///Users/<%= `whoami`.strip %>/.docker/run/docker.sock
remote:
arch: amd64
host: ssh://root@192.168.0.1
```
Note: You must have Docker running on the remote host being used as a builder. This instance should only be shared for builds using the same registry and credentials.
### Using remote builder for single-arch
If you're developing on ARM64 (like Apple Silicon), want to deploy on AMD64 (x86 64-bit), but don't need to run the image locally (or on other ARM64 hosts), you can configure a remote builder that just targets AMD64. This is a bit faster than building with multi-arch, as there's nothing to build locally.
```yaml
builder:
remote:
arch: amd64
host: ssh://root@192.168.0.1
```
### Using native builder when multi-arch isn't needed
If you're developing on the same architecture as the one you're deploying on, you can speed up the build by forgoing both multi-arch and remote building:
```yaml
builder:
multiarch: false
```
This is also a good option if you're running MRSK from a CI server that shares architecture with the deployment servers.
### Using build secrets for new images
Some images need a secret passed in during build time, like a GITHUB_TOKEN to give access to private gem repositories. This can be done by having the secret in ENV, then referencing it in the builder configuration:
```yaml
builder:
secrets:
- GITHUB_TOKEN
```
This build secret can then be referenced in the Dockerfile:
```
# Copy Gemfiles
COPY Gemfile Gemfile.lock ./
# Install dependencies, including private repositories via access token
RUN --mount=type=secret,id=GITHUB_TOKEN \
BUNDLE_GITHUB__COM=x-access-token:$(cat /run/secrets/GITHUB_TOKEN) \
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
Build arguments that aren't secret can also be configured:
```yaml
builder:
args:
RUBY_VERSION: 3.2.0
```
This build argument can then be used in the Dockerfile:
```
# Private repositories need an access token during the build
ARG RUBY_VERSION
FROM ruby:$RUBY_VERSION-slim as base
```
### 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:
```yaml
accessories:
mysql:
image: mysql:5.7
host: 1.1.1.3
port: 3306
env:
clear:
MYSQL_ROOT_HOST: '%'
secret:
- MYSQL_ROOT_PASSWORD
volumes:
- /var/lib/mysql:/var/lib/mysql
redis:
image: redis:latest
host: 1.1.1.4
port: "36379:6379"
volumes:
- /var/lib/redis:/data
```
Now run `mrsk accessory start mysql` to start the MySQL server on 1.1.1.3. See `mrsk accessory` for all the commands possible.
### Using a generated .env file
If you're using a centralized secret store, like 1Password, you can create `.env.erb` as a template which looks up the secrets. Example of a .env.erb file:
```erb
<% if (session_token = `op signin --account my-one-password-account --raw`.strip) != "" %># Generated by mrsk envify
GITHUB_TOKEN=<%= `gh config get -h github.com oauth_token`.strip %>
MRSK_REGISTRY_PASSWORD=<%= `op read "op://Vault/Docker Hub/password" -n --session #{session_token}` %>
RAILS_MASTER_KEY=<%= `op read "op://Vault/My App/RAILS_MASTER_SECRET" -n --session #{session_token}` %>
MYSQL_ROOT_PASSWORD=<%= `op read "op://Vault/My App/MYSQL_ROOT_PASSWORD" -n --session #{session_token}` %>
<% else raise ArgumentError, "Session token missing" end %>
```
This template can safely be checked into git. Then everyone deploying the app can run `mrsk envify` when they setup the app for the first time or passwords change to get the correct `.env` file.
If you need separate env variables for different destinations, you can set them with `.env.destination.erb` for the template, which will generate `.env.staging` when run with `mrsk envify -d staging`.
## Commands
### Running commands on servers
You can execute one-off commands on the servers:
```bash
# Runs command on all servers
mrsk app exec 'ruby -v'
App Host: 192.168.0.1
ruby 3.1.3p185 (2022-11-24 revision 1a6b16756e) [x86_64-linux]
App Host: 192.168.0.2
ruby 3.1.3p185 (2022-11-24 revision 1a6b16756e) [x86_64-linux]
# Runs command on primary server
mrsk app exec --primary 'cat .ruby-version'
App Host: 192.168.0.1
3.1.3
# Runs Rails command on all servers
mrsk app exec 'bin/rails about'
App Host: 192.168.0.1
About your application's environment
Rails version 7.1.0.alpha
Ruby version ruby 3.1.3p185 (2022-11-24 revision 1a6b16756e) [x86_64-linux]
RubyGems version 3.3.26
Rack version 2.2.5
Middleware ActionDispatch::HostAuthorization, Rack::Sendfile, ActionDispatch::Static, ActionDispatch::Executor, Rack::Runtime, Rack::MethodOverride, ActionDispatch::RequestId, ActionDispatch::RemoteIp, Rails::Rack::Logger, ActionDispatch::ShowExceptions, ActionDispatch::DebugExceptions, ActionDispatch::Callbacks, ActionDispatch::Cookies, ActionDispatch::Session::CookieStore, ActionDispatch::Flash, ActionDispatch::ContentSecurityPolicy::Middleware, ActionDispatch::PermissionsPolicy::Middleware, Rack::Head, Rack::ConditionalGet, Rack::ETag, Rack::TempfileReaper
Application root /rails
Environment production
Database adapter sqlite3
Database schema version 20221231233303
App Host: 192.168.0.2
About your application's environment
Rails version 7.1.0.alpha
Ruby version ruby 3.1.3p185 (2022-11-24 revision 1a6b16756e) [x86_64-linux]
RubyGems version 3.3.26
Rack version 2.2.5
Middleware ActionDispatch::HostAuthorization, Rack::Sendfile, ActionDispatch::Static, ActionDispatch::Executor, Rack::Runtime, Rack::MethodOverride, ActionDispatch::RequestId, ActionDispatch::RemoteIp, Rails::Rack::Logger, ActionDispatch::ShowExceptions, ActionDispatch::DebugExceptions, ActionDispatch::Callbacks, ActionDispatch::Cookies, ActionDispatch::Session::CookieStore, ActionDispatch::Flash, ActionDispatch::ContentSecurityPolicy::Middleware, ActionDispatch::PermissionsPolicy::Middleware, Rack::Head, Rack::ConditionalGet, Rack::ETag, Rack::TempfileReaper
Application root /rails
Environment production
Database adapter sqlite3
Database schema version 20221231233303
# Run Rails runner on primary server
mrsk app exec -p 'bin/rails runner "puts Rails.application.config.time_zone"'
UTC
```
### 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'
```
### Running details to see state of containers
You can see the state of your servers by running `mrsk details`:
```
Traefik Host: 192.168.0.1
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
6195b2a28c81 traefik "/entrypoint.sh --pr…" 30 minutes ago Up 19 minutes 0.0.0.0:80->80/tcp, :::80->80/tcp traefik
Traefik Host: 192.168.0.2
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
de14a335d152 traefik "/entrypoint.sh --pr…" 30 minutes ago Up 19 minutes 0.0.0.0:80->80/tcp, :::80->80/tcp traefik
App Host: 192.168.0.1
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
badb1aa51db3 registry.digitalocean.com/user/app:6ef8a6a84c525b123c5245345a8483f86d05a123 "/rails/bin/docker-e…" 13 minutes ago Up 13 minutes 3000/tcp chat-6ef8a6a84c525b123c5245345a8483f86d05a123
App Host: 192.168.0.2
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
1d3c91ed1f55 registry.digitalocean.com/user/app:6ef8a6a84c525b123c5245345a8483f86d05a123 "/rails/bin/docker-e…" 13 minutes ago Up 13 minutes 3000/tcp chat-6ef8a6a84c525b123c5245345a8483f86d05a123
```
You can also see just info for app containers with `mrsk app details` or just for Traefik with `mrsk traefik details`.
### Running rollback to fix a bad deploy
If you've discovered a bad deploy, you can quickly rollback by reactivating the old, paused container image. You can see what old containers are available for rollback by running `mrsk app containers`. It'll give you a presentation similar to `mrsk app details`, but include all the old containers as well. Showing something like this:
```
App Host: 192.168.0.1
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
1d3c91ed1f51 registry.digitalocean.com/user/app:6ef8a6a84c525b123c5245345a8483f86d05a123 "/rails/bin/docker-e…" 19 minutes ago Up 19 minutes 3000/tcp chat-6ef8a6a84c525b123c5245345a8483f86d05a123
539f26b28369 registry.digitalocean.com/user/app:e5d9d7c2b898289dfbc5f7f1334140d984eedae4 "/rails/bin/docker-e…" 31 minutes ago Exited (1) 27 minutes ago chat-e5d9d7c2b898289dfbc5f7f1334140d984eedae4
App Host: 192.168.0.2
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
badb1aa51db4 registry.digitalocean.com/user/app:6ef8a6a84c525b123c5245345a8483f86d05a123 "/rails/bin/docker-e…" 19 minutes ago Up 19 minutes 3000/tcp chat-6ef8a6a84c525b123c5245345a8483f86d05a123
6f170d1172ae registry.digitalocean.com/user/app:e5d9d7c2b898289dfbc5f7f1334140d984eedae4 "/rails/bin/docker-e…" 31 minutes ago Exited (1) 27 minutes ago chat-e5d9d7c2b898289dfbc5f7f1334140d984eedae4
```
From the example above, we can see that `e5d9d7c2b898289dfbc5f7f1334140d984eedae4` was the last version, so it's available as a rollback target. We can perform this rollback by running `mrsk rollback e5d9d7c2b898289dfbc5f7f1334140d984eedae4`. That'll stop `6ef8a6a84c525b123c5245345a8483f86d05a123` and then start `e5d9d7c2b898289dfbc5f7f1334140d984eedae4`. Because the old container is still available, this is very quick. Nothing to download from the registry.
Note that by default old containers are pruned after 3 days when you run `mrsk deploy`.
### Running removal to clean up servers
If you wish to remove the entire application, including Traefik, containers, images, and registry session, you can run `mrsk remove`. This will leave the servers clean.
## Stage of development
This is alpha software. Lots of stuff is missing. Here are some of the areas we seek to improve:
- Use of other registries than Docker Hub
- Adapterize commands to work with Podman and other container runners
- Better flow for secrets and ENV
- Possibly switching to a bin/mrsk command rather than raw rake
- Integrate wirmth cloud CI pipelines
This is alpha software. Lots of stuff is missing. Lots of stuff will keep moving around for a while.
## License
Mrsk is released under the [MIT License](https://opensource.org/licenses/MIT).
MRSK is released under the [MIT License](https://opensource.org/licenses/MIT).

16
bin/mrsk Executable file
View File

@@ -0,0 +1,16 @@
#!/usr/bin/env ruby
# Prevent failures from being reported twice.
Thread.report_on_exception = false
require "mrsk"
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,8 +1,9 @@
module Mrsk
end
require "mrsk/version"
require "mrsk/engine"
require "zeitwerk"
require "mrsk/configuration"
require "mrsk/commands"
loader = Zeitwerk::Loader.for_gem
loader.ignore("#{__dir__}/mrsk/sshkit_with_ext.rb")
loader.setup
loader.eager_load # We need all commands loaded.

5
lib/mrsk/cli.rb Normal file
View File

@@ -0,0 +1,5 @@
module Mrsk::Cli
end
# SSHKit uses instance eval, so we need a global const for ergonomics
MRSK = Mrsk::Commander.new

214
lib/mrsk/cli/accessory.rb Normal file
View File

@@ -0,0 +1,214 @@
class Mrsk::Cli::Accessory < Mrsk::Cli::Base
desc "boot [NAME]", "Boot accessory service on host (use NAME=all to boot all accessories)"
def boot(name)
if name == "all"
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
desc "reboot [NAME]", "Reboot accessory on host (stop container, remove container, start new container)"
def reboot(name)
with_accessory(name) do |accessory|
stop(name)
remove_container(name)
boot(name)
end
end
desc "start [NAME]", "Start existing accessory on host"
def start(name)
with_accessory(name) do |accessory|
on(accessory.host) do
execute *MRSK.auditor.record("accessory #{name} start"), verbosity: :debug
execute *accessory.start
end
end
end
desc "stop [NAME]", "Stop accessory on host"
def stop(name)
with_accessory(name) do |accessory|
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
desc "restart [NAME]", "Restart accessory on host"
def restart(name)
with_accessory(name) do
stop(name)
start(name)
end
end
desc "details [NAME]", "Display details about accessory on host (use NAME=all to boot all accessories)"
def details(name)
if name == "all"
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
desc "logs [NAME]", "Show log lines from accessory on host"
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 :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)"
def logs(name)
with_accessory(name) do |accessory|
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
on(accessory.host) do
puts capture_with_info(*accessory.logs(since: since, lines: lines, grep: grep))
end
end
end
end
desc "remove [NAME]", "Remove accessory container and image from host (use NAME=all to boot all accessories)"
def remove(name)
if name == "all"
MRSK.accessory_names.each { |accessory_name| remove(accessory_name) }
else
with_accessory(name) do
stop(name)
remove_container(name)
remove_image(name)
remove_service_directory(name)
end
end
end
desc "remove_container [NAME]", "Remove accessory container from host"
def remove_container(name)
with_accessory(name) do |accessory|
on(accessory.host) do
execute *MRSK.auditor.record("accessory #{name} remove container"), verbosity: :debug
execute *accessory.remove_container
end
end
end
desc "remove_image [NAME]", "Remove accessory image from host"
def remove_image(name)
with_accessory(name) do |accessory|
on(accessory.host) do
execute *MRSK.auditor.record("accessory #{name} remove image"), verbosity: :debug
execute *accessory.remove_image
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

198
lib/mrsk/cli/app.rb Normal file
View File

@@ -0,0 +1,198 @@
class Mrsk::Cli::App < Mrsk::Cli::Base
desc "boot", "Boot app on servers (or reboot app if already running)"
def boot
cli = self
say "Ensure no other version of the app is running...", :magenta
stop
say "Get most recent version available as an image...", :magenta unless options[:version]
using_version(options[:version] || most_recent_version_available) do |version|
say "Start container with version #{version} (or reboot if already running)...", :magenta
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
desc "start", "Start existing app on servers (use --version=<git-hash> to designate specific version)"
def start
on(MRSK.hosts) do
execute *MRSK.auditor.record("app start version #{MRSK.version}"), verbosity: :debug
execute *MRSK.app.start, raise_on_non_zero_exit: false
end
end
desc "stop", "Stop app on servers"
def stop
on(MRSK.hosts) do
execute *MRSK.auditor.record("app stop"), verbosity: :debug
execute *MRSK.app.stop, raise_on_non_zero_exit: false
end
end
desc "details", "Display details about app containers"
def details
on(MRSK.hosts) { |host| puts_by_host host, capture_with_info(*MRSK.app.info) }
end
desc "exec [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(cmd)
case
when options[:interactive] && options[:reuse]
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
when options[:interactive]
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 interactive command with version #{version} via SSH from new container on #{MRSK.primary_host}...", :magenta
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
desc "containers", "List all the app containers currently on servers"
def containers
on(MRSK.hosts) { |host| puts_by_host host, capture_with_info(*MRSK.app.list_containers) }
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 "logs", "Show lines from app on servers"
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 :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)"
def logs
# FIXME: Catch when app containers aren't running
grep = options[:grep]
if options[:follow]
run_locally do
info "Following logs on #{MRSK.primary_host}..."
info MRSK.app.follow_logs(host: MRSK.primary_host, grep: grep)
exec MRSK.app.follow_logs(host: MRSK.primary_host, 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
on(MRSK.hosts) do |host|
begin
puts_by_host host, capture_with_info(*MRSK.app.logs(since: since, lines: lines, grep: grep))
rescue SSHKit::Command::Failed
puts_by_host host, "Nothing found"
end
end
end
end
desc "remove", "Remove app containers and images from servers"
def remove
remove_containers
remove_images
end
desc "remove_container [VERSION]", "Remove app container with given version from servers"
def remove_container(version)
on(MRSK.hosts) do
execute *MRSK.auditor.record("app remove container #{version}"), verbosity: :debug
execute *MRSK.app.remove_container(version: version)
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

67
lib/mrsk/cli/base.rb Normal file
View File

@@ -0,0 +1,67 @@
require "thor"
require "dotenv"
require "mrsk/sshkit_with_ext"
module Mrsk::Cli
class Base < Thor
include SSHKit::DSL
def self.exit_on_failure?() true end
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 :primary, type: :boolean, aliases: "-p", desc: "Run commands only on primary host instead of all"
class_option :hosts, aliases: "-h", desc: "Run commands on these hosts instead of all (separate by comma)"
class_option :roles, aliases: "-r", desc: "Run commands on these roles instead of all (separate by comma)"
class_option :config_file, aliases: "-c", default: "config/deploy.yml", desc: "Path to config file (default: config/deploy.yml)"
class_option :destination, aliases: "-d", desc: "Specify destination to be used for config file (west -> deploy.west.yml)"
def initialize(*)
super
load_envs
initialize_commander(options)
end
private
def load_envs
if destination = options[:destination]
Dotenv.load(".env.#{destination}", ".env")
else
Dotenv.load(".env")
end
end
def initialize_commander(options)
MRSK.tap do |commander|
commander.config_file = Pathname.new(File.expand_path(options[:config_file]))
commander.destination = options[:destination]
commander.version = options[:version]
commander.specific_hosts = options[:hosts]&.split(",")
commander.specific_roles = options[:roles]&.split(",")
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
def print_runtime
started_at = Time.now
yield
ensure
runtime = Time.now - started_at
puts " Finished all in #{sprintf("%.1f seconds", runtime)}"
end
end
end

69
lib/mrsk/cli/build.rb Normal file
View File

@@ -0,0 +1,69 @@
class Mrsk::Cli::Build < Mrsk::Cli::Base
desc "deliver", "Deliver a newly built app image to servers"
def deliver
invoke :push
invoke :pull
end
desc "push", "Build locally and push app image to registry"
def push
cli = self
run_locally do
begin
MRSK.with_verbosity(:debug) { execute *MRSK.builder.push }
rescue SSHKit::Command::Failed => e
if e.message =~ /(no builder)|(no such file or directory)/
error "Missing compatible builder, so creating a new one first"
if cli.create
MRSK.with_verbosity(:debug) { execute *MRSK.builder.push }
end
else
raise
end
end
end
end
desc "pull", "Pull app image from the registry onto servers"
def pull
on(MRSK.hosts) do
execute *MRSK.auditor.record("build pull image #{MRSK.version}"), verbosity: :debug
execute *MRSK.builder.pull
end
end
desc "create", "Create a local build setup"
def create
run_locally do
begin
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
desc "remove", "Remove local build setup"
def remove
run_locally do
debug "Using builder: #{MRSK.builder.name}"
execute *MRSK.builder.remove
end
end
desc "details", "Show the name of the configured builder"
def details
run_locally do
puts "Builder: #{MRSK.builder.name}"
puts capture(*MRSK.builder.info)
end
end
end

148
lib/mrsk/cli/main.rb Normal file
View File

@@ -0,0 +1,148 @@
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"
def deploy
print_runtime do
say "Ensure Docker is installed...", :magenta
invoke "mrsk:cli:server:bootstrap"
say "Log into image registry...", :magenta
invoke "mrsk:cli:registry:login"
say "Build and push app image...", :magenta
invoke "mrsk:cli:build:deliver"
say "Ensure Traefik is running...", :magenta
invoke "mrsk:cli:traefik:boot"
invoke "mrsk:cli:app:boot"
say "Prune old containers and images...", :magenta
invoke "mrsk:cli:prune:all"
end
end
desc "redeploy", "Deploy new version of the app to servers (without bootstrapping servers, starting Traefik, pruning, and registry login)"
def redeploy
print_runtime do
say "Build and push app image...", :magenta
invoke "mrsk:cli:build:deliver"
invoke "mrsk:cli:app:boot"
end
end
desc "rollback [VERSION]", "Rollback the app to VERSION"
def rollback(version)
MRSK.version = version
cli = self
cli.say "Stop current version, then start version #{version}...", :magenta
on(MRSK.hosts) do
execute *MRSK.app.stop, raise_on_non_zero_exit: false
execute *MRSK.app.start
end
end
desc "details", "Display details about Traefik and app containers"
def details
invoke "mrsk:cli:traefik: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
desc "config", "Show combined config"
def config
run_locally do
puts MRSK.config.to_h.to_yaml
end
end
desc "init", "Create config stub in config/deploy.yml and env stub in .env"
option :bundle, type: :boolean, default: false, desc: "Add MRSK to the Gemfile and create a bin/mrsk binstub"
def init
require "fileutils"
if (deploy_file = Pathname.new(File.expand_path("config/deploy.yml"))).exist?
puts "Config file already exists in config/deploy.yml (remove first to create a new one)"
else
FileUtils.mkdir_p deploy_file.dirname
FileUtils.cp_r Pathname.new(File.expand_path("templates/deploy.yml", __dir__)), deploy_file
puts "Created configuration file in config/deploy.yml"
end
unless (deploy_file = Pathname.new(File.expand_path(".env"))).exist?
FileUtils.cp_r Pathname.new(File.expand_path("templates/template.env", __dir__)), deploy_file
puts "Created .env file"
end
if options[:bundle]
if (binstub = Pathname.new(File.expand_path("bin/mrsk"))).exist?
puts "Binstub already exists in bin/mrsk (remove first to create a new one)"
else
puts "Adding MRSK to Gemfile and bundle..."
`bundle add mrsk`
`bundle binstubs mrsk`
puts "Created binstub file in bin/mrsk"
end
end
end
desc "envify", "Create .env by evaluating .env.erb (or .env.staging.erb -> .env.staging when using -d staging)"
def envify
if destination = options[:destination]
File.write(".env.#{destination}", ERB.new(IO.read(Pathname.new(File.expand_path(".env.#{destination}.erb")))).result)
else
File.write(".env", ERB.new(IO.read(Pathname.new(File.expand_path(".env.erb")))).result)
end
end
desc "remove", "Remove Traefik, app, and registry session from servers"
def remove
invoke "mrsk:cli:traefik:remove"
invoke "mrsk:cli:app:remove"
invoke "mrsk:cli:registry:logout"
end
desc "version", "Display the MRSK version"
def version
puts Mrsk::VERSION
end
desc "accessory", "Manage the accessories"
subcommand "accessory", Mrsk::Cli::Accessory
desc "app", "Manage the application"
subcommand "app", Mrsk::Cli::App
desc "build", "Build the application image"
subcommand "build", Mrsk::Cli::Build
desc "prune", "Prune old application images and containers"
subcommand "prune", Mrsk::Cli::Prune
desc "registry", "Login and out of the image registry"
subcommand "registry", Mrsk::Cli::Registry
desc "server", "Bootstrap servers with Docker"
subcommand "server", Mrsk::Cli::Server
desc "traefik", "Manage the Traefik load balancer"
subcommand "traefik", Mrsk::Cli::Traefik
end

23
lib/mrsk/cli/prune.rb Normal file
View File

@@ -0,0 +1,23 @@
class Mrsk::Cli::Prune < Mrsk::Cli::Base
desc "all", "Prune unused images and stopped containers"
def all
invoke :containers
invoke :images
end
desc "images", "Prune unused images older than 30 days"
def images
on(MRSK.hosts) do
execute *MRSK.auditor.record("prune images"), verbosity: :debug
execute *MRSK.prune.images
end
end
desc "containers", "Prune stopped containers for the service older than 3 days"
def containers
on(MRSK.hosts) do
execute *MRSK.auditor.record("prune containers"), verbosity: :debug
execute *MRSK.prune.containers
end
end
end

16
lib/mrsk/cli/registry.rb Normal file
View File

@@ -0,0 +1,16 @@
class Mrsk::Cli::Registry < Mrsk::Cli::Base
desc "login", "Login to the registry locally and remotely"
def login
run_locally { execute *MRSK.registry.login }
on(MRSK.hosts) { execute *MRSK.registry.login }
rescue ArgumentError => e
puts e.message
end
desc "logout", "Logout of the registry remotely"
def logout
on(MRSK.hosts) { execute *MRSK.registry.logout }
rescue ArgumentError => e
puts e.message
end
end

6
lib/mrsk/cli/server.rb Normal file
View File

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

View File

@@ -0,0 +1,17 @@
# Name of your application. Used to uniquely configuring Traefik and app containers.
# Your Dockerfile should set LABEL service=the-same-value to ensure image pruning works.
service: my-app
# Name of the container image.
image: user/my-app
# Deploy to these servers.
servers:
- 192.168.0.1
# Credentials for your image host.
registry:
# Specify the registry server, if you're not using Docker Hub
# server: registry.digitalocean.com / ghcr.io / ...
username: my-user
password: my-password-should-go-somewhere-safe

View File

@@ -0,0 +1,2 @@
MRSK_REGISTRY_PASSWORD=change-this
RAILS_MASTER_KEY=another-env

87
lib/mrsk/cli/traefik.rb Normal file
View File

@@ -0,0 +1,87 @@
class Mrsk::Cli::Traefik < Mrsk::Cli::Base
desc "boot", "Boot Traefik on servers"
def boot
on(MRSK.traefik_hosts) { execute *MRSK.traefik.run, raise_on_non_zero_exit: false }
end
desc "reboot", "Reboot Traefik on servers (stop container, remove container, start new container)"
def reboot
invoke :stop
invoke :remove_container
invoke :boot
end
desc "start", "Start existing Traefik on servers"
def start
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
desc "stop", "Stop Traefik on servers"
def stop
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
desc "restart", "Restart Traefik on servers"
def restart
invoke :stop
invoke :start
end
desc "details", "Display details about Traefik containers from servers"
def details
on(MRSK.traefik_hosts) { |host| puts_by_host host, capture_with_info(*MRSK.traefik.info), type: "Traefik" }
end
desc "logs", "Show log lines from Traefik on servers"
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 :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)"
def logs
grep = options[:grep]
if options[:follow]
run_locally do
info "Following logs on #{MRSK.primary_host}..."
info MRSK.traefik.follow_logs(host: MRSK.primary_host, grep: grep)
exec MRSK.traefik.follow_logs(host: MRSK.primary_host, 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
on(MRSK.traefik_hosts) do |host|
puts_by_host host, capture(*MRSK.traefik.logs(since: since, lines: lines, grep: grep)), type: "Traefik"
end
end
end
desc "remove", "Remove Traefik container and image from servers"
def remove
invoke :stop
invoke :remove_container
invoke :remove_image
end
desc "remove_container", "Remove Traefik container from servers"
def remove_container
on(MRSK.traefik_hosts) do
execute *MRSK.auditor.record("traefik remove container"), verbosity: :debug
execute *MRSK.traefik.remove_container
end
end
desc "remove_container", "Remove Traefik image from servers"
def remove_image
on(MRSK.traefik_hosts) do
execute *MRSK.auditor.record("traefik remove image"), verbosity: :debug
execute *MRSK.traefik.remove_image
end
end
end

103
lib/mrsk/commander.rb Normal file
View File

@@ -0,0 +1,103 @@
require "active_support/core_ext/enumerable"
class Mrsk::Commander
attr_accessor :config_file, :destination, :verbosity, :version
def initialize(config_file: nil, destination: nil, verbosity: :info)
@config_file, @destination, @verbosity = config_file, destination, verbosity
end
def config
@config ||= \
Mrsk::Configuration
.create_from(config_file, destination: destination, version: cascading_version)
.tap { |config| configure_sshkit_with(config) }
end
attr_accessor :specific_hosts
def specific_primary!
self.specific_hosts = [ config.primary_web_host ]
end
def specific_roles=(role_names)
self.specific_hosts = config.roles.select { |r| role_names.include?(r.name) }.flat_map(&:hosts) if role_names.present?
end
def primary_host
specific_hosts&.sole || config.primary_web_host
end
def hosts
specific_hosts || config.all_hosts
end
def traefik_hosts
specific_hosts || config.traefik_hosts
end
def accessory_hosts
specific_hosts || config.accessories.collect(&:host)
end
def accessory_names
config.accessories&.collect(&:name) || []
end
def app
@app ||= Mrsk::Commands::App.new(config)
end
def builder
@builder ||= Mrsk::Commands::Builder.new(config)
end
def traefik
@traefik ||= Mrsk::Commands::Traefik.new(config)
end
def registry
@registry ||= Mrsk::Commands::Registry.new(config)
end
def prune
@prune ||= Mrsk::Commands::Prune.new(config)
end
def accessory(name)
Mrsk::Commands::Accessory.new(config, name: name)
end
def auditor
@auditor ||= Mrsk::Commands::Auditor.new(config)
end
def with_verbosity(level)
old_level = SSHKit.config.output_verbosity
SSHKit.config.output_verbosity = level
yield
ensure
SSHKit.config.output_verbosity = old_level
end
# Test-induced damage!
def reset
@config = @config_file = @destination = @version = nil
@app = @builder = @traefik = @registry = @prune = @auditor = nil
@verbosity = :info
end
private
def cascading_version
version.presence || ENV["VERSION"] || `git rev-parse HEAD`.strip
end
# Lazy setup of SSHKit
def configure_sshkit_with(config)
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.output_verbosity = verbosity
end
end

View File

@@ -1,13 +1,2 @@
module Mrsk::Commands
class Base
attr_accessor :config
def initialize(config)
@config = config
end
end
end
require "mrsk/commands/app"
require "mrsk/commands/traefik"
require "mrsk/commands/registry"

View File

@@ -0,0 +1,109 @@
class Mrsk::Commands::Accessory < Mrsk::Commands::Base
attr_reader :accessory_config
delegate :service_name, :image, :host, :port, :files, :directories, :env_args, :volume_args, :label_args, to: :accessory_config
def initialize(config, name:)
super(config)
@accessory_config = config.accessory(name)
end
def run
docker :run,
"--name", service_name,
"-d",
"--restart", "unless-stopped",
"-p", port,
*env_args,
*volume_args,
*label_args,
image
end
def start
docker :container, :start, service_name
end
def stop
docker :container, :stop, service_name
end
def info
docker :ps, *service_filter
end
def logs(since: nil, lines: nil, grep: nil)
pipe \
docker(:logs, service_name, (" --since #{since}" if since), (" -n #{lines}" if lines), "-t", "2>&1"),
("grep '#{grep}'" if grep)
end
def follow_logs(grep: nil)
run_over_ssh \
pipe \
docker(:logs, service_name, "-t", "-n", "10", "-f", "2>&1"),
(%(grep "#{grep}") if grep)
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)
end
def execute_in_new_container_over_ssh(*command)
run_over_ssh execute_in_new_container(*command, interactive: true)
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
def remove_container
docker :container, :prune, "-f", *service_filter
end
def remove_image
docker :image, :prune, "-a", "-f", *service_filter
end
private
def service_filter
[ "--filter", "label=service=#{service_name}" ]
end
end

View File

@@ -1,22 +1,131 @@
class Mrsk::Commands::App < Mrsk::Commands::Base
def push
# TODO: Run 'docker buildx create --use' when needed
"docker buildx build --push --platform=linux/amd64,linux/arm64 -t #{config.absolute_image} ."
end
def run(role: :web)
role = config.role(role)
def pull
"docker pull #{config.absolute_image}"
docker :run,
"-d",
"--restart unless-stopped",
"--name", service_with_version,
*role.env_args,
*config.volume_args,
*role.label_args,
config.absolute_image,
role.cmd
end
def start
"docker run -d --rm --name #{config.service_with_version} #{config.envs} #{config.labels} #{config.absolute_image}"
docker :start, service_with_version
end
def stop
"docker ps -q --filter label=service=#{config.service} | xargs docker stop"
pipe current_container_id, xargs(docker(:stop))
end
def info
"docker ps --filter label=service=#{config.service}"
docker :ps, *service_filter
end
def logs(since: nil, lines: nil, grep: nil)
pipe \
current_container_id,
"xargs docker logs#{" --since #{since}" if since}#{" -n #{lines}" if lines} 2>&1",
("grep '#{grep}'" if grep)
end
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)
),
host: host
end
def execute_in_existing_container(*command, interactive: false)
docker :exec,
("-it" if interactive),
config.service_with_version,
*command
end
def execute_in_new_container(*command, interactive: false)
docker :run,
("-it" if interactive),
"--rm",
*config.env_args,
*config.volume_args,
config.absolute_image,
*command
end
def execute_in_existing_container_over_ssh(*command, host:)
run_over_ssh execute_in_existing_container(*command, interactive: true), host: host
end
def execute_in_new_container_over_ssh(*command, host:)
run_over_ssh execute_in_new_container(*command, interactive: true), host: host
end
def current_container_id
docker :ps, "-q", *service_filter
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
docker :container, :ls, "-a", *service_filter
end
def remove_container(version:)
pipe \
container_id_for(container_name: service_with_version(version)),
xargs(docker(:container, :rm))
end
def remove_containers
docker :container, :prune, "-f", *service_filter
end
def list_images
docker :image, :ls, config.repository
end
def remove_images
docker :image, :prune, "-a", "-f", *service_filter
end
private
def service_with_version(version = nil)
if version
"#{config.service}-#{version}"
else
config.service_with_version
end
end
def service_filter
[ "--filter", "label=service=#{config.service}" ]
end
end

View File

@@ -0,0 +1,34 @@
require "active_support/core_ext/time/conversions"
class Mrsk::Commands::Auditor < Mrsk::Commands::Base
def record(line)
append \
[ :echo, tagged_line(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 tagged_line(line)
"'#{tags} #{line}'"
end
def tags
"[#{timestamp}] [#{performer}]"
end
def performer
`whoami`.strip
end
def timestamp
Time.now.to_fs(:db)
end
end

46
lib/mrsk/commands/base.rb Normal file
View File

@@ -0,0 +1,46 @@
module Mrsk::Commands
class Base
delegate :redact, to: Mrsk::Utils
attr_accessor :config
def initialize(config)
@config = config
end
def run_over_ssh(*command, host:)
"ssh".tap do |cmd|
cmd << " -J #{config.ssh_proxy.jump_proxies}" if config.ssh_proxy
cmd << " -t #{config.ssh_user}@#{host} '#{command.join(" ")}'"
end
end
private
def combine(*commands, by: "&&")
commands
.compact
.collect { |command| Array(command) + [ by ] }.flatten # Join commands
.tap { |commands| commands.pop } # Remove trailing combiner
end
def chain(*commands)
combine *commands, by: ";"
end
def pipe(*commands)
combine *commands, by: "|"
end
def append(*commands)
combine *commands, by: ">>"
end
def xargs(command)
[ :xargs, command ].flatten
end
def docker(*args)
args.compact.unshift :docker
end
end
end

View File

@@ -0,0 +1,36 @@
class Mrsk::Commands::Builder < Mrsk::Commands::Base
delegate :create, :remove, :push, :pull, :info, to: :target
def name
target.class.to_s.remove("Mrsk::Commands::Builder::").underscore
end
def target
case
when config.builder && config.builder["multiarch"] == false
native
when config.builder && config.builder["local"] && config.builder["remote"]
multiarch_remote
when config.builder && config.builder["remote"]
native_remote
else
multiarch
end
end
def native
@native ||= Mrsk::Commands::Builder::Native.new(config)
end
def native_remote
@native ||= Mrsk::Commands::Builder::Native::Remote.new(config)
end
def multiarch
@multiarch ||= Mrsk::Commands::Builder::Multiarch.new(config)
end
def multiarch_remote
@multiarch_remote ||= Mrsk::Commands::Builder::Multiarch::Remote.new(config)
end
end

View File

@@ -0,0 +1,24 @@
class Mrsk::Commands::Builder::Base < Mrsk::Commands::Base
delegate :argumentize, to: Mrsk::Utils
def pull
docker :pull, config.absolute_image
end
def build_args
argumentize "--build-arg", args, redacted: true
end
def build_secrets
argumentize "--secret", secrets.collect { |secret| [ "id", secret ] }
end
private
def args
(config.builder && config.builder["args"]) || {}
end
def secrets
(config.builder && config.builder["secrets"]) || []
end
end

View File

@@ -0,0 +1,31 @@
class Mrsk::Commands::Builder::Multiarch < Mrsk::Commands::Builder::Base
def create
docker :buildx, :create, "--use", "--name", builder_name
end
def remove
docker :buildx, :rm, builder_name
end
def push
docker :buildx, :build,
"--push",
"--platform", "linux/amd64,linux/arm64",
"--builder", builder_name,
"-t", config.absolute_image,
*build_args,
*build_secrets,
"."
end
def info
combine \
docker(:context, :ls),
docker(:buildx, :ls)
end
private
def builder_name
"mrsk-#{config.service}-multiarch"
end
end

View File

@@ -0,0 +1,59 @@
class Mrsk::Commands::Builder::Multiarch::Remote < Mrsk::Commands::Builder::Multiarch
def create
combine \
create_contexts,
create_local_buildx,
append_remote_buildx
end
def remove
combine \
remove_contexts,
super
end
private
def builder_name
super + "-remote"
end
def builder_name_with_arch(arch)
"#{builder_name}-#{arch}"
end
def create_local_buildx
docker :buildx, :create, "--name", builder_name, builder_name_with_arch(local["arch"]), "--platform", "linux/#{local["arch"]}"
end
def append_remote_buildx
docker :buildx, :create, "--append", "--name", builder_name, builder_name_with_arch(remote["arch"]), "--platform", "linux/#{remote["arch"]}"
end
def create_contexts
combine \
create_context(local["arch"], local["host"]),
create_context(remote["arch"], remote["host"])
end
def create_context(arch, host)
docker :context, :create, builder_name_with_arch(arch), "--description", "'#{builder_name} #{arch} native host'", "--docker", "'host=#{host}'"
end
def remove_contexts
combine \
remove_context(local["arch"]),
remove_context(remote["arch"])
end
def remove_context(arch)
docker :context, :rm, builder_name_with_arch(arch)
end
def local
config.builder["local"]
end
def remote
config.builder["remote"]
end
end

View File

@@ -0,0 +1,19 @@
class Mrsk::Commands::Builder::Native < Mrsk::Commands::Builder::Base
def create
# No-op on native
end
def remove
# No-op on native
end
def push
combine \
docker(:build, "-t", *build_args, *build_secrets, config.absolute_image, "."),
docker(:push, config.absolute_image)
end
def info
# No-op on native
end
end

View File

@@ -0,0 +1,69 @@
class Mrsk::Commands::Builder::Native::Remote < Mrsk::Commands::Builder::Native
def create
chain \
create_context,
create_buildx
end
def remove
chain \
remove_context,
remove_buildx
end
def push
docker :buildx, :build,
"--push",
"--platform", platform,
"--builder", builder_name,
"-t", config.absolute_image,
*build_args,
*build_secrets,
"."
end
def info
chain \
docker(:context, :ls),
docker(:buildx, :ls)
end
private
def arch
config.builder["remote"]["arch"]
end
def host
config.builder["remote"]["host"]
end
def builder_name
"mrsk-#{config.service}-native-remote"
end
def builder_name_with_arch
"#{builder_name}-#{arch}"
end
def platform
"linux/#{arch}"
end
def create_context
docker :context, :create,
builder_name_with_arch, "--description", "'#{builder_name} #{arch} native host'", "--docker", "'host=#{host}'"
end
def remove_context
docker :context, :rm, builder_name_with_arch
end
def create_buildx
docker :buildx, :create, "--name", builder_name, builder_name_with_arch, "--platform", platform
end
def remove_buildx
docker :buildx, :rm, builder_name
end
end

View File

@@ -0,0 +1,16 @@
require "active_support/duration"
require "active_support/core_ext/numeric/time"
class Mrsk::Commands::Prune < Mrsk::Commands::Base
PRUNE_IMAGES_AFTER = 30.days.in_hours.to_i
PRUNE_CONTAINERS_AFTER = 3.days.in_hours.to_i
def images
docker :image, :prune, "-f", "--filter", "until=#{PRUNE_IMAGES_AFTER}h"
end
def containers
docker :image, :prune, "-f", "--filter", "until=#{PRUNE_IMAGES_AFTER}h"
docker :container, :prune, "-f", "--filter", "label=service=#{config.service}", "--filter", "'until=#{PRUNE_CONTAINERS_AFTER}h'"
end
end

View File

@@ -1,5 +1,11 @@
class Mrsk::Commands::Registry < Mrsk::Commands::Base
delegate :registry, to: :config
def login
"docker login #{config.registry["server"]} -u #{config.registry["username"]} -p #{config.registry["password"]}"
docker :login, registry["server"], "-u", redact(registry["username"]), "-p", redact(registry["password"])
end
def logout
docker :logout, registry["server"]
end
end

View File

@@ -1,17 +1,51 @@
class Mrsk::Commands::Traefik < Mrsk::Commands::Base
def run
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",
*cmd_args
end
def start
"docker run --name traefik " +
"--rm -d " +
"-p 80:80 " +
"-v /var/run/docker.sock:/var/run/docker.sock " +
"traefik --providers.docker"
docker :container, :start, "traefik"
end
def stop
"docker container stop traefik"
docker :container, :stop, "traefik"
end
def info
"docker ps --filter name=traefik"
docker :ps, "--filter", "name=traefik"
end
def logs(since: nil, lines: nil, grep: nil)
pipe \
docker(:logs, "traefik", (" --since #{since}" if since), (" -n #{lines}" if lines), "-t", "2>&1"),
("grep '#{grep}'" if grep)
end
def follow_logs(host:, grep: nil)
run_over_ssh pipe(
docker(:logs, "traefik", "-t", "-n", "10", "-f", "2>&1"),
(%(grep "#{grep}") if grep)
).join(" "), host: host
end
def remove_container
docker :container, :prune, "-f", "--filter", "label=org.opencontainers.image.title=Traefik"
end
def remove_image
docker :image, :prune, "-a", "-f", "--filter", "label=org.opencontainers.image.title=Traefik"
end
private
def cmd_args
(config.raw_config.dig(:traefik, "args") || { }).collect { |(key, value)| [ "--#{key}", value ] }.flatten
end
end

View File

@@ -1,78 +1,178 @@
require "active_support/ordered_options"
require "active_support/core_ext/string/inquiry"
require "active_support/core_ext/module/delegation"
require "pathname"
require "erb"
require "net/ssh/proxy/jump"
class Mrsk::Configuration
delegate :service, :image, :env, :registry, :ssh_user, to: :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
def self.load_file(file)
if file.exist?
new YAML.load_file(file).symbolize_keys
else
raise "Configuration file not found in #{file}"
attr_accessor :version
attr_accessor :raw_config
class << self
def create_from(base_config_file, destination: nil, version: "missing")
new(load_config_file(base_config_file).tap do |config|
if destination
config.deep_merge! \
load_config_file destination_config_file(base_config_file, destination)
end
end, version: version)
end
private
def load_config_file(file)
if file.exist?
YAML.load(ERB.new(IO.read(file)).result).symbolize_keys
else
raise "Configuration file not found in #{file}"
end
end
def destination_config_file(base_config_file, destination)
dir, basename = base_config_file.split
dir.join basename.to_s.remove(".yml") + ".#{destination}.yml"
end
end
def initialize(config)
@config = ActiveSupport::InheritableOptions.new(config)
ensure_required_keys_present
def initialize(raw_config, version: "missing", validate: true)
@raw_config = ActiveSupport::InheritableOptions.new(raw_config)
@version = version
valid? if validate
end
def servers
ENV["SERVERS"] || config.servers
def roles
@roles ||= role_names.collect { |role_name| Role.new(role_name, config: self) }
end
def version
@version ||= ENV["VERSION"] || `git rev-parse HEAD`.strip
def role(name)
roles.detect { |r| r.name == name.to_s }
end
def accessories
@accessories ||= raw_config.accessories&.keys&.collect { |name| Mrsk::Configuration::Accessory.new(name, config: self) } || []
end
def accessory(name)
accessories.detect { |a| a.name == name.to_s }
end
def all_hosts
roles.flat_map(&:hosts)
end
def primary_web_host
role(:web).hosts.first
end
def traefik_hosts
roles.select(&:running_traefik?).flat_map(&:hosts)
end
def repository
[ raw_config.registry["server"], image ].compact.join("/")
end
def absolute_image
[ config.registry["server"], image_with_version ].compact.join("/")
end
def image_with_version
"#{image}:#{version}"
"#{repository}:#{version}"
end
def service_with_version
"#{service}-#{version}"
end
def envs
parameterize "-e", \
{ "RAILS_MASTER_KEY" => master_key }.merge(env || {})
def env_args
if raw_config.env.present?
argumentize_env_with_secrets(raw_config.env)
else
[]
end
end
def labels
parameterize "--label", \
"service" => service,
"traefik.http.routers.#{service}.rule" => "'PathPrefix(`/`)'",
"traefik.http.services.#{service}.loadbalancer.healthcheck.path" => "/up",
"traefik.http.services.#{service}.loadbalancer.healthcheck.interval" => "1s",
"traefik.http.middlewares.#{service}.retry.attempts" => "3",
"traefik.http.middlewares.#{service}.retry.initialinterval" => "500ms"
def volume_args
if raw_config.volumes.present?
argumentize "--volume", raw_config.volumes
else
[]
end
end
def ssh_user
if raw_config.ssh.present?
raw_config.ssh["user"] || "root"
else
"root"
end
end
def ssh_proxy
if raw_config.ssh.present? && raw_config.ssh["proxy"]
Net::SSH::Proxy::Jump.new \
raw_config.ssh["proxy"].include?("@") ? raw_config.ssh["proxy"] : "root@#{raw_config.ssh["proxy"]}"
end
end
def ssh_options
{ user: config.ssh_user || "root", auth_methods: [ "publickey" ] }
{ user: ssh_user, proxy: ssh_proxy, auth_methods: [ "publickey" ] }.compact
end
private
attr_accessor :config
def valid?
ensure_required_keys_present && ensure_env_available
end
def to_h
{
roles: role_names,
hosts: all_hosts,
primary_host: primary_web_host,
version: version,
repository: repository,
absolute_image: absolute_image,
service_with_version: service_with_version,
env_args: env_args,
volume_args: volume_args,
ssh_options: ssh_options,
builder: raw_config.builder,
accessories: raw_config.accessories
}.compact
end
private
# Will raise ArgumentError if any required config keys are missing
def ensure_required_keys_present
%i[ service image registry ].each do |key|
raise ArgumentError, "Missing required configuration for #{key}" unless config[key].present?
%i[ service image registry servers ].each do |key|
raise ArgumentError, "Missing required configuration for #{key}" unless raw_config[key].present?
end
%w[ username password ].each do |key|
raise ArgumentError, "Missing required configuration for registry/#{key}" unless config.registry[key].present?
end
if raw_config.registry["username"].blank?
raise ArgumentError, "You must specify a username for the registry in config/deploy.yml"
end
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)"
end
true
end
def parameterize(param, hash)
hash.collect { |k, v| "#{param} #{k}=#{v}" }.join(" ")
# Will raise KeyError if any secret ENVs are missing
def ensure_env_available
env_args
roles.each(&:env_args)
true
end
def master_key
ENV["RAILS_MASTER_KEY"] || File.read(Rails.root.join("config/master.key"))
def role_names
raw_config.servers.is_a?(Array) ? [ "web" ] : raw_config.servers.keys.sort
end
end

View File

@@ -0,0 +1,123 @@
class Mrsk::Configuration::Accessory
delegate :argumentize, :argumentize_env_with_secrets, to: Mrsk::Utils
attr_accessor :name, :specifics
def initialize(name, config:)
@name, @config, @specifics = name.inquiry, config, config.raw_config["accessories"][name]
end
def service_name
"#{config.service}-#{name}"
end
def image
specifics["image"]
end
def host
specifics["host"] || raise(ArgumentError, "Missing host for accessory")
end
def port
if specifics["port"].to_s.include?(":")
specifics["port"]
else
"#{specifics["port"]}:#{specifics["port"]}"
end
end
def labels
default_labels.merge(specifics["labels"] || {})
end
def label_args
argumentize "--label", labels
end
def env
specifics["env"] || {}
end
def env_args
argumentize_env_with_secrets env
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
specific_volumes + remote_files_as_volumes + remote_directories_as_volumes
end
def volume_args
argumentize "--volume", volumes
end
private
attr_accessor :config
def default_labels
{ "service" => service_name }
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

View File

@@ -0,0 +1,107 @@
class Mrsk::Configuration::Role
delegate :argumentize, :argumentize_env_with_secrets, to: Mrsk::Utils
attr_accessor :name
def initialize(name, config:)
@name, @config = name.inquiry, config
end
def hosts
@hosts ||= extract_hosts_from_config
end
def labels
default_labels.merge(traefik_labels).merge(custom_labels)
end
def label_args
argumentize "--label", labels
end
def env
if config.env && config.env["secret"]
merged_env_with_secrets
else
merged_env
end
end
def env_args
argumentize_env_with_secrets env
end
def cmd
specializations["cmd"]
end
def running_traefik?
name.web? || specializations["traefik"]
end
private
attr_accessor :config
def extract_hosts_from_config
if config.servers.is_a?(Array)
config.servers
else
servers = config.servers[name]
servers.is_a?(Array) ? servers : servers["hosts"]
end
end
def default_labels
{ "service" => config.service, "role" => name }
end
def traefik_labels
if running_traefik?
{
"traefik.http.routers.#{config.service}.rule" => "'PathPrefix(`/`)'",
"traefik.http.services.#{config.service}.loadbalancer.healthcheck.path" => "/up",
"traefik.http.services.#{config.service}.loadbalancer.healthcheck.interval" => "1s",
"traefik.http.middlewares.#{config.service}.retry.attempts" => "3",
"traefik.http.middlewares.#{config.service}.retry.initialinterval" => "500ms"
}
else
{}
end
end
def custom_labels
Hash.new.tap do |labels|
labels.merge!(config.labels) if config.labels.present?
labels.merge!(specializations["labels"]) if specializations["labels"].present?
end
end
def specializations
if config.servers.is_a?(Array) || config.servers[name].is_a?(Array)
{ }
else
config.servers[name].except("hosts")
end
end
def specialized_env
specializations["env"] || {}
end
def merged_env
config.env&.merge(specialized_env) || {}
end
# Secrets are stored in an array, which won't merge by default, so have to do it by hand.
def merged_env_with_secrets
merged_env.tap do |new_env|
new_env["secret"] = Array(config.env["secret"]) + Array(specialized_env["secret"])
# If there's no secret/clear split, everything is clear
clear_app_env = config.env["secret"] ? Array(config.env["clear"]) : Array(config.env["clear"] || config.env)
clear_role_env = specialized_env["secret"] ? Array(specialized_env["clear"]) : Array(specialized_env["clear"] || specialized_env)
new_env["clear"] = (clear_app_env + clear_role_env).uniq
end
end
end

View File

@@ -1,4 +0,0 @@
module Mrsk
class Engine < ::Rails::Engine
end
end

View File

@@ -0,0 +1,12 @@
require "sshkit"
require "sshkit/dsl"
class SSHKit::Backend::Abstract
def capture_with_info(*args)
capture(*args, verbosity: Logger::INFO)
end
def puts_by_host(host, output, type: "App")
puts "#{type} Host: #{host}\n#{output}\n\n"
end
end

29
lib/mrsk/utils.rb Normal file
View File

@@ -0,0 +1,29 @@
module Mrsk::Utils
extend self
# Return a list of shell arguments using the same named argument against the passed attributes (hash or array).
def argumentize(argument, attributes, redacted: false)
Array(attributes).flat_map do |k, v|
if v.present?
[ argument, redacted ? redact("#{k}=#{v}") : "#{k}=#{v}" ]
else
[ argument, k ]
end
end
end
# Return a list of shell arguments using the same named argument against the passed attributes,
# but redacts and expands secrets.
def argumentize_env_with_secrets(env)
if (secrets = env["secret"]).present?
argumentize("-e", secrets.to_h { |key| [ key, ENV.fetch(key) ] }, redacted: true) + argumentize("-e", env["clear"])
else
argumentize "-e", env.fetch("clear", env)
end
end
# Copied from SSHKit::Backend::Abstract#redact to be available inside Commands classes
def redact(arg) # Used in execute_command to hide redact() args a user passes in
arg.to_s.extend(SSHKit::Redaction) # to_s due to our inability to extend Integer, etc
end
end

View File

@@ -1,3 +1,3 @@
module Mrsk
VERSION = "0.0.1"
VERSION = "0.6.0"
end

View File

@@ -1,31 +0,0 @@
require_relative "setup"
app = Mrsk::Commands::App.new(MRSK_CONFIG)
namespace :mrsk do
namespace :app do
desc "Build and push app image to servers"
task :push do
run_locally { execute app.push }
on(MRSK_CONFIG.servers) { execute app.pull }
end
desc "Start app on servers"
task :start do
on(MRSK_CONFIG.servers) { execute app.start }
end
desc "Stop app on servers"
task :stop do
on(MRSK_CONFIG.servers) { execute app.stop, raise_on_non_zero_exit: false }
end
desc "Restart app on servers"
task restart: %i[ stop start ]
desc "Display information about app containers"
task :info do
on(MRSK_CONFIG.servers) { |host| puts "Host: #{host}\n" + capture(app.info) + "\n\n" }
end
end
end

View File

@@ -1,12 +0,0 @@
namespace :mrsk do
desc "Push the latest version of the app, ensure Traefik is running, then restart app"
task deploy: [ "app:push", "traefik:start", "app:restart" ]
desc "Display information about Traefik and app containers"
task info: [ "traefik:info", "app:info" ]
desc "Create config stub"
task :init do
Rails.root.join("config/deploy.yml")
end
end

View File

@@ -1,13 +0,0 @@
require_relative "setup"
registry = Mrsk::Commands::Registry.new(MRSK_CONFIG)
namespace :mrsk do
namespace :registry do
desc "Login to the registry locally and remotely"
task :login do
run_locally { execute registry.login }
on(MRSK_CONFIG.servers) { execute registry.login }
end
end
end

View File

@@ -1,8 +0,0 @@
require "sshkit"
require "sshkit/dsl"
include SSHKit::DSL
MRSK_CONFIG = Mrsk::Configuration.load_file(Rails.root.join("config/deploy.yml"))
SSHKit::Backend::Netssh.configure { |ssh| ssh.ssh_options = MRSK_CONFIG.ssh_options }

View File

@@ -1,25 +0,0 @@
require_relative "setup"
traefik = Mrsk::Commands::Traefik.new(MRSK_CONFIG)
namespace :mrsk do
namespace :traefik do
desc "Start Traefik"
task :start do
on(MRSK_CONFIG.servers) { execute traefik.start, raise_on_non_zero_exit: false }
end
desc "Stop Traefik"
task :stop do
on(MRSK_CONFIG.servers) { execute traefik.stop, raise_on_non_zero_exit: false }
end
desc "Restart Traefik"
task restart: %i[ stop start ]
desc "Display information about Traefik containers"
task :info do
on(MRSK_CONFIG.servers) { |host| puts "Host: #{host}\n" + capture(traefik.info) + "\n\n" }
end
end
end

View File

@@ -6,11 +6,15 @@ Gem::Specification.new do |spec|
spec.authors = [ "David Heinemeier Hansson" ]
spec.email = "dhh@hey.com"
spec.homepage = "https://github.com/rails/mrsk"
spec.summary = "Deploy Docker containers with zero downtime to any host."
spec.summary = "Deploy Rails apps in containers to servers running Docker with zero downtime."
spec.license = "MIT"
spec.files = Dir["lib/**/*", "MIT-LICENSE", "README.md"]
spec.executables = %w[ mrsk ]
spec.add_dependency "railties", ">= 7.0.0"
spec.add_dependency "activesupport", ">= 7.0"
spec.add_dependency "sshkit", "~> 1.21"
spec.add_dependency "thor", "~> 1.2"
spec.add_dependency "dotenv", "~> 2.8"
spec.add_dependency "zeitwerk", "~> 2.5"
end

View File

@@ -0,0 +1,38 @@
require_relative "cli_test_case"
class CliAccessoryTest < CliTestCase
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
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")
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

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

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

@@ -0,0 +1,24 @@
require "test_helper"
require "active_support/testing/stream"
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

8
test/cli/main_test.rb Normal file
View File

@@ -0,0 +1,8 @@
require_relative "cli_test_case"
class CliMainTest < CliTestCase
test "version" do
version = stdouted { Mrsk::Cli::Main.new.version }
assert_equal Mrsk::VERSION, version
end
end

35
test/commander_test.rb Normal file
View File

@@ -0,0 +1,35 @@
require "test_helper"
class CommanderTest < ActiveSupport::TestCase
setup do
@mrsk = Mrsk::Commander.new config_file: Pathname.new(File.expand_path("fixtures/deploy_with_roles.yml", __dir__))
end
test "lazy configuration" do
assert_equal Mrsk::Configuration, @mrsk.config.class
end
test "overwriting hosts" do
assert_equal [ "1.1.1.1", "1.1.1.2", "1.1.1.3", "1.1.1.4" ], @mrsk.hosts
@mrsk.specific_hosts = [ "1.2.3.4", "1.2.3.5" ]
assert_equal [ "1.2.3.4", "1.2.3.5" ], @mrsk.hosts
end
test "overwriting hosts with roles" do
assert_equal [ "1.1.1.1", "1.1.1.2", "1.1.1.3", "1.1.1.4" ], @mrsk.hosts
@mrsk.specific_roles = [ "workers", "web" ]
assert_equal [ "1.1.1.1", "1.1.1.2", "1.1.1.3", "1.1.1.4" ], @mrsk.hosts
@mrsk.specific_roles = [ "workers" ]
assert_equal [ "1.1.1.3", "1.1.1.4" ], @mrsk.hosts
end
test "overwriting hosts with primary" do
assert_equal [ "1.1.1.1", "1.1.1.2", "1.1.1.3", "1.1.1.4" ], @mrsk.hosts
@mrsk.specific_primary!
assert_equal [ "1.1.1.1" ], @mrsk.hosts
end
end

View File

@@ -0,0 +1,115 @@
require "test_helper"
class CommandsAccessoryTest < ActiveSupport::TestCase
setup do
@config = {
service: "app", image: "dhh/app", registry: { "username" => "dhh", "password" => "secret" },
servers: [ "1.1.1.1" ],
accessories: {
"mysql" => {
"image" => "mysql:8.0",
"host" => "1.1.1.5",
"port" => "3306",
"env" => {
"clear" => {
"MYSQL_ROOT_HOST" => "%"
},
"secret" => [
"MYSQL_ROOT_PASSWORD"
]
}
},
"redis" => {
"image" => "redis:latest",
"host" => "1.1.1.6",
"port" => "6379:6379",
"labels" => {
"cache" => true
},
"env" => {
"SOMETHING" => "else"
},
"volumes" => [
"/var/lib/redis:/data"
]
}
}
}
@config = Mrsk::Configuration.new(@config)
@mysql = Mrsk::Commands::Accessory.new(@config, name: :mysql)
@redis = Mrsk::Commands::Accessory.new(@config, name: :redis)
ENV["MYSQL_ROOT_PASSWORD"] = "secret123"
end
teardown do
ENV.delete("MYSQL_ROOT_PASSWORD")
end
test "run" do
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
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
end
test "start" do
assert_equal [:docker, :container, :start, "app-mysql"], @mysql.start
end
test "stop" do
assert_equal [:docker, :container, :stop, "app-mysql"], @mysql.stop
end
test "info" do
assert_equal [:docker, :ps, "--filter", "label=service=app-mysql"], @mysql.info
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.join(" ") }) 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.join(" ") }) 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
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")
end
test "follow logs" do
assert_equal "ssh -t root@1.1.1.5 'docker logs app-mysql -t -n 10 -f 2>&1'", @mysql.follow_logs
end
test "remove container" do
assert_equal [:docker, :container, :prune, "-f", "--filter", "label=service=app-mysql"], @mysql.remove_container
end
test "remove image" do
assert_equal [:docker, :image, :prune, "-a", "-f", "--filter", "label=service=app-mysql"], @mysql.remove_image
end
end

161
test/commands/app_test.rb Normal file
View File

@@ -0,0 +1,161 @@
require "test_helper"
class CommandsAppTest < ActiveSupport::TestCase
setup do
ENV["RAILS_MASTER_KEY"] = "456"
@config = { service: "app", image: "dhh/app", registry: { "username" => "dhh", "password" => "secret" }, servers: [ "1.1.1.1" ], env: { "secret" => [ "RAILS_MASTER_KEY" ] } }
@app = Mrsk::Commands::App.new Mrsk::Configuration.new(@config).tap { |c| c.version = "999" }
end
teardown do
ENV.delete("RAILS_MASTER_KEY")
end
test "run" do
assert_equal \
"docker run -d --restart unless-stopped --name app-999 -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:999",
@app.run.join(" ")
end
test "run with volumes" do
@config[:volumes] = ["/local/path:/container/path" ]
assert_equal \
"docker run -d --restart unless-stopped --name app-999 -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:999",
@app.run.join(" ")
end
test "start" do
assert_equal \
"docker start app-999",
@app.start.join(" ")
end
test "stop" do
assert_equal \
"docker ps -q --filter label=service=app | xargs docker stop",
@app.stop.join(" ")
end
test "info" do
assert_equal \
"docker ps --filter label=service=app",
@app.info.join(" ")
end
test "logs" do
assert_equal \
"docker ps -q --filter label=service=app | xargs docker logs 2>&1",
@app.logs.join(" ")
assert_equal \
"docker ps -q --filter label=service=app | xargs docker logs --since 5m 2>&1",
@app.logs(since: "5m").join(" ")
assert_equal \
"docker ps -q --filter label=service=app | xargs docker logs -n 100 2>&1",
@app.logs(lines: "100").join(" ")
assert_equal \
"docker ps -q --filter label=service=app | xargs docker logs --since 5m -n 100 2>&1",
@app.logs(since: "5m", lines: "100").join(" ")
assert_equal \
"docker ps -q --filter label=service=app | xargs docker logs 2>&1 | grep 'my-id'",
@app.logs(grep: "my-id").join(" ")
assert_equal \
"docker ps -q --filter label=service=app | xargs docker logs --since 5m 2>&1 | grep 'my-id'",
@app.logs(since: "5m", grep: "my-id").join(" ")
end
test "follow logs" do
@app.stub(:run_over_ssh, ->(cmd, host:) { cmd.join(" ") }) do
assert_equal \
"docker ps -q --filter label=service=app | xargs docker logs -t -n 10 -f 2>&1",
@app.follow_logs(host: "app-1")
assert_equal \
"docker ps -q --filter label=service=app | xargs docker logs -t -n 10 -f 2>&1 | grep \"Completed\"",
@app.follow_logs(host: "app-1", grep: "Completed")
end
end
test "execute in new container" do
assert_equal \
"docker run --rm -e RAILS_MASTER_KEY=456 dhh/app:999 bin/rails db:setup",
@app.execute_in_new_container("bin/rails", "db:setup").join(" ")
end
test "execute in existing container" do
assert_equal \
"docker exec app-999 bin/rails db:setup",
@app.execute_in_existing_container("bin/rails", "db:setup").join(" ")
end
test "execute in new container over ssh" do
@app.stub(:run_over_ssh, ->(cmd, host:) { cmd.join(" ") }) do
assert_match %r|docker run -it --rm -e RAILS_MASTER_KEY=456 dhh/app:999 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.join(" ") }) do
assert_match %r|docker exec -it app-999 bin/rails c|,
@app.execute_in_existing_container_over_ssh("bin/rails", "c", host: "app-1")
end
end
test "run over ssh" do
assert_equal "ssh -t root@1.1.1.1 'ls'", @app.run_over_ssh("ls", host: "1.1.1.1")
end
test "run over ssh with custom user" do
@app = Mrsk::Commands::App.new Mrsk::Configuration.new(@config.tap { |c| c[:ssh] = { "user" => "app" } })
assert_equal "ssh -t app@1.1.1.1 'ls'", @app.run_over_ssh("ls", host: "1.1.1.1")
end
test "run over ssh with proxy" do
@app = Mrsk::Commands::App.new Mrsk::Configuration.new(@config.tap { |c| c[:ssh] = { "proxy" => "2.2.2.2" } })
assert_equal "ssh -J root@2.2.2.2 -t root@1.1.1.1 'ls'", @app.run_over_ssh("ls", host: "1.1.1.1")
end
test "run over ssh with proxy user" do
@app = Mrsk::Commands::App.new Mrsk::Configuration.new(@config.tap { |c| c[:ssh] = { "proxy" => "app@2.2.2.2" } })
assert_equal "ssh -J app@2.2.2.2 -t root@1.1.1.1 'ls'", @app.run_over_ssh("ls", host: "1.1.1.1")
end
test "run over ssh with custom user with proxy" do
@app = Mrsk::Commands::App.new Mrsk::Configuration.new(@config.tap { |c| c[:ssh] = { "user" => "app", "proxy" => "2.2.2.2" } })
assert_equal "ssh -J root@2.2.2.2 -t app@1.1.1.1 'ls'", @app.run_over_ssh("ls", host: "1.1.1.1")
end
test "current_container_id" do
assert_equal \
"docker ps -q --filter label=service=app",
@app.current_container_id.join(" ")
end
test "container_id_for" do
assert_equal \
"docker container ls -a -f name=app-999 -q",
@app.container_id_for(container_name: "app-999").join(" ")
end
test "current_running_version" do
assert_equal \
"docker ps --filter label=service=app --format \"{{.Names}}\" | sed 's/-/\\n/g' | tail -n 1",
@app.current_running_version.join(" ")
end
test "most_recent_version_from_available_images" do
assert_equal \
"docker image ls --format \"{{.Tag}}\" dhh/app | head -n 1",
@app.most_recent_version_from_available_images.join(" ")
end
end

View File

@@ -0,0 +1,61 @@
require "test_helper"
class CommandsBuilderTest < ActiveSupport::TestCase
setup do
@config = { service: "app", image: "dhh/app", registry: { "username" => "dhh", "password" => "secret" }, servers: [ "1.1.1.1" ] }
end
test "target multiarch by default" do
builder = new_builder_command
assert_equal "multiarch", builder.name
assert_equal [:docker, :buildx, :build, "--push", "--platform", "linux/amd64,linux/arm64", "--builder", "mrsk-app-multiarch", "-t", "dhh/app:123", "."], builder.push
end
test "target native when multiarch is off" do
builder = new_builder_command(builder: { "multiarch" => false })
assert_equal "native", builder.name
assert_equal [:docker, :build, "-t", "dhh/app:123", ".", "&&", :docker, :push, "dhh/app:123"], builder.push
end
test "target multiarch remote when local and remote is set" do
builder = new_builder_command(builder: { "local" => { }, "remote" => { } })
assert_equal "multiarch/remote", builder.name
assert_equal [:docker, :buildx, :build, "--push", "--platform", "linux/amd64,linux/arm64", "--builder", "mrsk-app-multiarch-remote", "-t", "dhh/app:123", "."], builder.push
end
test "target native remote when only remote is set" do
builder = new_builder_command(builder: { "remote" => { "arch" => "amd64" } })
assert_equal "native/remote", builder.name
assert_equal [:docker, :buildx, :build, "--push", "--platform", "linux/amd64", "--builder", "mrsk-app-native-remote", "-t", "dhh/app:123", "."], builder.push
end
test "build args" do
builder = new_builder_command(builder: { "args" => { "a" => 1, "b" => 2 } })
assert_equal [ "--build-arg", "a=1", "--build-arg", "b=2" ], builder.target.build_args
end
test "build secrets" do
builder = new_builder_command(builder: { "secrets" => ["token_a", "token_b"] })
assert_equal [ "--secret", "id=token_a", "--secret", "id=token_b" ], builder.target.build_secrets
end
test "native push with build args" do
builder = new_builder_command(builder: { "multiarch" => false, "args" => { "a" => 1, "b" => 2 } })
assert_equal [ :docker, :build, "-t", "--build-arg", "a=1", "--build-arg", "b=2", "dhh/app:123", ".", "&&", :docker, :push, "dhh/app:123" ], builder.push
end
test "multiarch push with build args" do
builder = new_builder_command(builder: { "args" => { "a" => 1, "b" => 2 } })
assert_equal [ :docker, :buildx, :build, "--push", "--platform", "linux/amd64,linux/arm64", "--builder", "mrsk-app-multiarch", "-t", "dhh/app:123", "--build-arg", "a=1", "--build-arg", "b=2", "." ], builder.push
end
test "native push with with build secrets" do
builder = new_builder_command(builder: { "multiarch" => false, "secrets" => [ "a", "b" ] })
assert_equal [ :docker, :build, "-t", "--secret", "id=a", "--secret", "id=b", "dhh/app:123", ".", "&&", :docker, :push, "dhh/app:123" ], builder.push
end
private
def new_builder_command(additional_config = {})
Mrsk::Commands::Builder.new(Mrsk::Configuration.new(@config.merge(additional_config), version: "123"))
end
end

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

@@ -0,0 +1,23 @@
require "test_helper"
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,76 @@
require "test_helper"
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

@@ -0,0 +1,107 @@
require "test_helper"
class ConfigurationAccessoryTest < ActiveSupport::TestCase
setup do
@deploy = {
service: "app", image: "dhh/app", registry: { "username" => "dhh", "password" => "secret" },
servers: [ "1.1.1.1", "1.1.1.2" ],
env: { "REDIS_URL" => "redis://x/y" },
accessories: {
"mysql" => {
"image" => "mysql:8.0",
"host" => "1.1.1.5",
"port" => "3306",
"env" => {
"clear" => {
"MYSQL_ROOT_HOST" => "%"
},
"secret" => [
"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" => {
"image" => "redis:latest",
"host" => "1.1.1.6",
"port" => "6379:6379",
"labels" => {
"cache" => true
},
"env" => {
"SOMETHING" => "else"
},
"volumes" => [
"/var/lib/redis:/data"
]
}
}
}
@config = Mrsk::Configuration.new(@deploy)
end
test "service name" do
assert_equal "app-mysql", @config.accessory(:mysql).service_name
assert_equal "app-redis", @config.accessory(:redis).service_name
end
test "port" do
assert_equal "3306:3306", @config.accessory(:mysql).port
assert_equal "6379:6379", @config.accessory(:redis).port
end
test "host" do
assert_equal "1.1.1.5", @config.accessory(:mysql).host
assert_equal "1.1.1.6", @config.accessory(:redis).host
end
test "missing host" do
@deploy[:accessories]["mysql"]["host"] = nil
@config = Mrsk::Configuration.new(@deploy)
assert_raises(ArgumentError) do
@config.accessory(:mysql).host
end
end
test "label args" do
assert_equal ["--label", "service=app-mysql"], @config.accessory(:mysql).label_args
assert_equal ["--label", "service=app-redis", "--label", "cache=true"], @config.accessory(:redis).label_args
end
test "env args with secret" do
ENV["MYSQL_ROOT_PASSWORD"] = "secret123"
assert_equal ["-e", "MYSQL_ROOT_PASSWORD=secret123", "-e", "MYSQL_ROOT_HOST=%"], @config.accessory(:mysql).env_args
assert @config.accessory(:mysql).env_args[1].is_a?(SSHKit::Redaction)
ensure
ENV["MYSQL_ROOT_PASSWORD"] = nil
end
test "env args without secret" do
assert_equal ["-e", "SOMETHING=else"], @config.accessory(:redis).env_args
end
test "volume args" do
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
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

View File

@@ -0,0 +1,140 @@
require "test_helper"
class ConfigurationRoleTest < ActiveSupport::TestCase
setup do
@deploy = {
service: "app", image: "dhh/app", registry: { "username" => "dhh", "password" => "secret" },
servers: [ "1.1.1.1", "1.1.1.2" ],
env: { "REDIS_URL" => "redis://x/y" }
}
@config = Mrsk::Configuration.new(@deploy)
@deploy_with_roles = @deploy.dup.merge({
servers: {
"web" => [ "1.1.1.1", "1.1.1.2" ],
"workers" => {
"hosts" => [ "1.1.1.3", "1.1.1.4" ],
"cmd" => "bin/jobs",
"env" => {
"REDIS_URL" => "redis://a/b",
"WEB_CONCURRENCY" => 4
}
}
}
})
@config_with_roles = Mrsk::Configuration.new(@deploy_with_roles)
end
test "hosts" do
assert_equal [ "1.1.1.1", "1.1.1.2" ], @config.role(:web).hosts
assert_equal [ "1.1.1.3", "1.1.1.4" ], @config_with_roles.role(:workers).hosts
end
test "cmd" do
assert_nil @config.role(:web).cmd
assert_equal "bin/jobs", @config_with_roles.role(:workers).cmd
end
test "label args" do
assert_equal [ "--label", "service=app", "--label", "role=workers" ], @config_with_roles.role(:workers).label_args
end
test "special label args for web" do
assert_equal [ "--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"], @config.role(:web).label_args
end
test "custom labels" do
@deploy[:labels] = { "my.custom.label" => "50" }
assert_equal "50", @config.role(:web).labels["my.custom.label"]
end
test "custom labels via role specialization" do
@deploy_with_roles[:labels] = { "my.custom.label" => "50" }
@deploy_with_roles[:servers]["workers"]["labels"] = { "my.custom.label" => "70" }
assert_equal "70", @config_with_roles.role(:workers).labels["my.custom.label"]
end
test "overwriting default traefik label" do
@deploy[:labels] = { "traefik.http.routers.app.rule" => "'Host(`example.com`) || (Host(`example.org`) && Path(`/traefik`))'" }
assert_equal "'Host(`example.com`) || (Host(`example.org`) && Path(`/traefik`))'", @config.role(:web).labels["traefik.http.routers.app.rule"]
end
test "default traefik label on non-web role" do
config = Mrsk::Configuration.new(@deploy_with_roles.tap { |c|
c[:servers]["beta"] = { "traefik" => "true", "hosts" => [ "1.1.1.5" ] }
})
assert_equal [ "--label", "service=app", "--label", "role=beta", "--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" ], config.role(:beta).label_args
end
test "env overwritten by role" do
assert_equal "redis://a/b", @config_with_roles.role(:workers).env["REDIS_URL"]
assert_equal ["-e", "REDIS_URL=redis://a/b", "-e", "WEB_CONCURRENCY=4"], @config_with_roles.role(:workers).env_args
end
test "env secret overwritten by role" do
@deploy_with_roles[:env] = {
"clear" => {
"REDIS_URL" => "redis://a/b"
},
"secret" => [
"REDIS_PASSWORD"
]
}
@deploy_with_roles[:servers]["workers"]["env"] = {
"clear" => {
"REDIS_URL" => "redis://a/b",
"WEB_CONCURRENCY" => 4
},
"secret" => [
"DB_PASSWORD"
]
}
ENV["REDIS_PASSWORD"] = "secret456"
ENV["DB_PASSWORD"] = "secret123"
assert_equal ["-e", "REDIS_PASSWORD=secret456", "-e", "DB_PASSWORD=secret123", "-e", "REDIS_URL=redis://a/b", "-e", "WEB_CONCURRENCY=4"], @config_with_roles.role(:workers).env_args
ensure
ENV["REDIS_PASSWORD"] = nil
ENV["DB_PASSWORD"] = nil
end
test "env secrets only in role" do
@deploy_with_roles[:servers]["workers"]["env"] = {
"clear" => {
"REDIS_URL" => "redis://a/b",
"WEB_CONCURRENCY" => 4
},
"secret" => [
"DB_PASSWORD"
]
}
ENV["DB_PASSWORD"] = "secret123"
assert_equal ["-e", "DB_PASSWORD=secret123", "-e", "REDIS_URL=redis://a/b", "-e", "WEB_CONCURRENCY=4"], @config_with_roles.role(:workers).env_args
ensure
ENV["DB_PASSWORD"] = nil
end
test "env secrets only at top level" do
@deploy_with_roles[:env] = {
"clear" => {
"REDIS_URL" => "redis://a/b"
},
"secret" => [
"REDIS_PASSWORD"
]
}
ENV["REDIS_PASSWORD"] = "secret456"
assert_equal ["-e", "REDIS_PASSWORD=secret456", "-e", "REDIS_URL=redis://a/b", "-e", "WEB_CONCURRENCY=4"], @config_with_roles.role(:workers).env_args
ensure
ENV["REDIS_PASSWORD"] = nil
end
end

View File

@@ -1,29 +1,186 @@
require "test_helper"
require "mrsk/configuration"
ENV["VERSION"] = "123"
class ConfigurationTest < ActiveSupport::TestCase
setup do
@config = { service: "app", image: "dhh/app", registry: { "username" => "dhh", "password" => "secret" } }
ENV["RAILS_MASTER_KEY"] = "456"
@deploy = {
service: "app", image: "dhh/app",
registry: { "username" => "dhh", "password" => "secret" },
env: { "REDIS_URL" => "redis://x/y" },
servers: [ "1.1.1.1", "1.1.1.2" ],
volumes: ["/local/path:/container/path"]
}
@config = Mrsk::Configuration.new(@deploy)
@deploy_with_roles = @deploy.dup.merge({
servers: { "web" => [ "1.1.1.1", "1.1.1.2" ], "workers" => { "hosts" => [ "1.1.1.3", "1.1.1.4" ] } } })
@config_with_roles = Mrsk::Configuration.new(@deploy_with_roles)
end
teardown do
ENV["RAILS_MASTER_KEY"] = nil
end
test "ensure valid keys" do
assert_raise(ArgumentError) do
Mrsk::Configuration.new(@config.tap { _1.delete(:service) })
Mrsk::Configuration.new(@config.tap { _1.delete(:image) })
Mrsk::Configuration.new(@config.tap { _1.delete(:registry) })
Mrsk::Configuration.new(@deploy.tap { _1.delete(:service) })
Mrsk::Configuration.new(@deploy.tap { _1.delete(:image) })
Mrsk::Configuration.new(@deploy.tap { _1.delete(:registry) })
Mrsk::Configuration.new(@config.tap { _1[:registry].delete("username") })
Mrsk::Configuration.new(@config.tap { _1[:registry].delete("password") })
Mrsk::Configuration.new(@deploy.tap { _1[:registry].delete("username") })
Mrsk::Configuration.new(@deploy.tap { _1[:registry].delete("password") })
end
end
test "absolute image" do
configuration = Mrsk::Configuration.new(@config)
assert_equal "dhh/app:123", configuration.absolute_image
test "roles" do
assert_equal %w[ web ], @config.roles.collect(&:name)
assert_equal %w[ web workers ], @config_with_roles.roles.collect(&:name)
end
configuration = Mrsk::Configuration.new(@config.tap { |c| c[:registry].merge!({ "server" => "ghcr.io" }) })
assert_equal "ghcr.io/dhh/app:123", configuration.absolute_image
test "role" do
assert_equal "web", @config.role(:web).name
assert_equal "workers", @config_with_roles.role(:workers).name
assert_nil @config.role(:missing)
end
test "all hosts" do
assert_equal [ "1.1.1.1", "1.1.1.2"], @config.all_hosts
assert_equal [ "1.1.1.1", "1.1.1.2", "1.1.1.3", "1.1.1.4" ], @config_with_roles.all_hosts
end
test "primary web host" do
assert_equal "1.1.1.1", @config.primary_web_host
assert_equal "1.1.1.1", @config_with_roles.primary_web_host
end
test "traefik hosts" do
assert_equal [ "1.1.1.1", "1.1.1.2" ], @config_with_roles.traefik_hosts
@deploy_with_roles[:servers]["workers"]["traefik"] = true
config = Mrsk::Configuration.new(@deploy_with_roles)
assert_equal [ "1.1.1.1", "1.1.1.2", "1.1.1.3", "1.1.1.4" ], config.traefik_hosts
end
test "version" do
assert_equal "missing", @config.version
assert_equal "123", Mrsk::Configuration.new(@deploy, version: "123").version
end
test "repository" do
assert_equal "dhh/app", @config.repository
config = Mrsk::Configuration.new(@deploy.tap { |c| c[:registry].merge!({ "server" => "ghcr.io" }) })
assert_equal "ghcr.io/dhh/app", config.repository
end
test "absolute image" do
assert_equal "dhh/app:missing", @config.absolute_image
config = Mrsk::Configuration.new(@deploy.tap { |c| c[:registry].merge!({ "server" => "ghcr.io" }) })
assert_equal "ghcr.io/dhh/app:missing", config.absolute_image
end
test "service with version" do
assert_equal "app-missing", @config.service_with_version
end
test "env args" do
assert_equal [ "-e", "REDIS_URL=redis://x/y" ], @config.env_args
end
test "env args with clear and secrets" do
ENV["PASSWORD"] = "secret123"
config = Mrsk::Configuration.new(@deploy.tap { |c| c.merge!({
env: { "clear" => { "PORT" => "3000" }, "secret" => [ "PASSWORD" ] }
}) })
assert_equal [ "-e", "PASSWORD=secret123", "-e", "PORT=3000" ], config.env_args
assert config.env_args[1].is_a?(SSHKit::Redaction)
ensure
ENV["PASSWORD"] = nil
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
ENV["PASSWORD"] = "secret123"
config = Mrsk::Configuration.new(@deploy.tap { |c| c.merge!({
env: { "secret" => [ "PASSWORD" ] }
}) })
assert_equal [ "-e", "PASSWORD=secret123" ], config.env_args
assert config.env_args[1].is_a?(SSHKit::Redaction)
ensure
ENV["PASSWORD"] = nil
end
test "env args with missing secret" do
assert_raises(KeyError) do
config = Mrsk::Configuration.new(@deploy.tap { |c| c.merge!({
env: { "secret" => [ "PASSWORD" ] }
}) })
end
end
test "valid config" do
assert @config.valid?
end
test "ssh options" do
assert_equal "root", @config.ssh_options[:user]
config = Mrsk::Configuration.new(@deploy.tap { |c| c.merge!(ssh: { "user" => "app" }) })
assert_equal "app", @config.ssh_options[:user]
end
test "ssh options with proxy host" do
config = Mrsk::Configuration.new(@deploy.tap { |c| c.merge!(ssh: { "proxy" => "1.2.3.4" }) })
assert_equal "root@1.2.3.4", @config.ssh_options[:proxy].jump_proxies
end
test "ssh options with proxy host and user" do
config = Mrsk::Configuration.new(@deploy.tap { |c| c.merge!(ssh: { "proxy" => "app@1.2.3.4" }) })
assert_equal "app@1.2.3.4", @config.ssh_options[:proxy].jump_proxies
end
test "volume_args" do
assert_equal ["--volume", "/local/path:/container/path"], @config.volume_args
end
test "erb evaluation of yml config" do
config = Mrsk::Configuration.create_from Pathname.new(File.expand_path("fixtures/deploy.erb.yml", __dir__))
assert_equal "my-user", config.registry["username"]
end
test "destination yml config merge" do
dest_config_file = Pathname.new(File.expand_path("fixtures/deploy_for_dest.yml", __dir__))
config = Mrsk::Configuration.create_from dest_config_file, destination: "world"
assert_equal "1.1.1.1", config.all_hosts.first
config = Mrsk::Configuration.create_from dest_config_file, destination: "mars"
assert_equal "1.1.1.3", config.all_hosts.first
end
test "destination yml config file missing" do
dest_config_file = Pathname.new(File.expand_path("fixtures/deploy_for_dest.yml", __dir__))
assert_raises(RuntimeError) do
config = Mrsk::Configuration.create_from dest_config_file, destination: "missing"
end
end
test "to_h" do
assert_equal({ :roles=>["web"], :hosts=>["1.1.1.1", "1.1.1.2"], :primary_host=>"1.1.1.1", :version=>"missing", :repository=>"dhh/app", :absolute_image=>"dhh/app:missing", :service_with_version=>"app-missing", :env_args=>["-e", "REDIS_URL=redis://x/y"], :ssh_options=>{:user=>"root", :auth_methods=>["publickey"]}, :volume_args=>["--volume", "/local/path:/container/path"] }, @config.to_h)
end
end

11
test/fixtures/deploy.erb.yml vendored Normal file
View File

@@ -0,0 +1,11 @@
service: app
image: dhh/app
servers:
- 1.1.1.1
- 1.1.1.2
env:
REDIS_URL: redis://x/y
registry:
server: registry.digitalocean.com
username: <%= "my-user" %>
password: <%= "my-password" %>

View File

@@ -0,0 +1,5 @@
servers:
- 1.1.1.3
- 1.1.1.4
env:
REDIS_URL: redis://a/b

View File

@@ -0,0 +1,5 @@
servers:
- 1.1.1.1
- 1.1.1.2
env:
REDIS_URL: redis://x/y

6
test/fixtures/deploy_for_dest.yml vendored Normal file
View File

@@ -0,0 +1,6 @@
service: app
image: dhh/app
registry:
server: registry.digitalocean.com
username: <%= "my-user" %>
password: <%= "my-password" %>

View File

@@ -0,0 +1,29 @@
service: app
image: dhh/app
servers:
- 1.1.1.1
- 1.1.1.2
registry:
username: user
password: pw
accessories:
mysql:
image: mysql:5.7
host: 1.1.1.3
port: 3306
env:
clear:
MYSQL_ROOT_HOST: '%'
secret:
- MYSQL_ROOT_PASSWORD
files:
- test/fixtures/files/my.cnf:/etc/mysql/my.cnf
directories:
- data:/var/lib/mysql
redis:
image: redis:latest
host: 1.1.1.4
port: 6379
directories:
- data:/data

15
test/fixtures/deploy_with_roles.yml vendored Normal file
View File

@@ -0,0 +1,15 @@
service: app
image: dhh/app
servers:
web:
- 1.1.1.1
- 1.1.1.2
workers:
- 1.1.1.3
- 1.1.1.4
env:
REDIS_URL: redis://x/y
registry:
server: registry.digitalocean.com
username: user
password: pw

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

@@ -1,8 +1,15 @@
require "bundler/setup"
require "active_support/test_case"
require "active_support/testing/autorun"
require "debug"
require "mocha/minitest" # using #stubs that can alter returns
require "minitest/autorun" # using #stub that take args
require "sshkit"
require "mrsk"
ActiveSupport::LogSubscriber.logger = ActiveSupport::Logger.new(STDOUT) if ENV["VERBOSE"]
SSHKit.config.backend = SSHKit::Backend::Printer
class ActiveSupport::TestCase
end