Compare commits

...

468 Commits

Author SHA1 Message Date
David Heinemeier Hansson
c47de8246b Bump version for 0.8.1 2023-02-20 18:20:41 +01:00
David Heinemeier Hansson
1fccaf60b2 Cleanup escaping logic 2023-02-20 18:20:08 +01:00
David Heinemeier Hansson
9b02a7668d Merge branch 'main' into pr/53
* main:
  Bump version for 0.8.0
  Remove images of the same name before pulling a new one
  Changed to a timeout
  Better language
  Switch to ruby-based retry
2023-02-20 18:14:47 +01:00
David Heinemeier Hansson
f6ea287e66 Bump version for 0.8.0 2023-02-20 18:06:56 +01:00
David Heinemeier Hansson
42b343436d Remove images of the same name before pulling a new one
Or you'll end up with untagged dupes.
2023-02-20 18:06:16 +01:00
David Heinemeier Hansson
9d6ccf9889 Changed to a timeout 2023-02-20 17:59:41 +01:00
David Heinemeier Hansson
c4cc9e690b Better language 2023-02-20 17:44:55 +01:00
David Heinemeier Hansson
1ccf679ca9 Switch to ruby-based retry
Retry connection errors with backoff
2023-02-20 17:42:55 +01:00
Paul Gabriel
f81ba12aa5 fix(escape): Escape double quotes and all other characters reliably 2023-02-20 16:49:47 +01:00
Paul Gabriel
25e8b91569 fix(escape-cli-args): Always use quotes to escape CLI arguments 2023-02-20 15:02:34 +01:00
Paul Gabriel
21c6a1f1ba chore(rebase): Rebase main 2023-02-20 10:27:51 +01:00
David Heinemeier Hansson
5898fdd8f4 Expand arguments to be more self-explanatory in logs 2023-02-19 18:11:06 +01:00
David Heinemeier Hansson
5299826146 Alphabetical order 2023-02-19 17:43:56 +01:00
David Heinemeier Hansson
28be8dc0f0 Encourage registry password from ENV 2023-02-19 17:42:30 +01:00
David Heinemeier Hansson
2ed3ccc53e More readable tests 2023-02-19 17:40:41 +01:00
David Heinemeier Hansson
11c726858d Point to where secrets are from 2023-02-19 17:34:49 +01:00
David Heinemeier Hansson
8706fae2b5 Reveal all options in default config 2023-02-19 17:34:06 +01:00
David Heinemeier Hansson
67d6c3acfe Think we can drop this
Now that we rescue at the top level
2023-02-19 17:33:54 +01:00
David Heinemeier Hansson
a5fd4c76ba No need for invocation 2023-02-19 17:22:03 +01:00
David Heinemeier Hansson
f3a5845501 Remember this 2023-02-19 17:16:14 +01:00
David Heinemeier Hansson
5356f31e2e Remove also removes accessories but requires confirmation 2023-02-19 17:16:14 +01:00
David Heinemeier Hansson
67cb89b9b9 Remove requires confirmation 2023-02-19 17:16:06 +01:00
David Heinemeier Hansson
745b09051e Test app remove 2023-02-19 17:15:57 +01:00
David Heinemeier Hansson
0fa70f4688 Stop app before removing it 2023-02-19 17:15:57 +01:00
David Heinemeier Hansson
6bc2def677 No need for invoke
No double action possible
2023-02-19 17:15:57 +01:00
David Heinemeier Hansson
42bc691758 CLI doc updates
Match word

Language

Suggest what accessories are

There are also accessories

Default already shown

Better example

Warn about secrets being shown

Now also accessories

Wording

Clarifications

Clarify how to see options

General option for all

Options important here too

Hide subcommands

Implied

Simpler as just version

Be concise

Missing word

Wordsmith

Simpler and uniform words are better

Clarify what exactly we're manipulating

Wordsmithing

Implicit

Simpler language

Hide subcommands

Clarify its container management

Just one per server

Simpler
2023-02-19 17:15:44 +01:00
David Heinemeier Hansson
e5c4cb0344 Retry healthcheck for up to 10 seconds (in case container wasnt ready) 2023-02-19 15:34:36 +01:00
David Heinemeier Hansson
a0d71f3fe4 Protect against missing current version 2023-02-19 09:48:35 +01:00
David Heinemeier Hansson
389ce2f701 Only output if there's a failure 2023-02-19 09:36:04 +01:00
David Heinemeier Hansson
8e918b1906 Output logs when healthcheck fails 2023-02-19 09:33:49 +01:00
David Heinemeier Hansson
e37e5f7d09 Bump version for 0.7.2 2023-02-18 18:23:28 +01:00
David Heinemeier Hansson
7f1191bf59 Change broadcast cmd to just take an argument instead of STDIN
Simpler
2023-02-18 18:22:46 +01:00
David Heinemeier Hansson
0c03216fdf Bump version for 0.7.1 2023-02-18 16:33:28 +01:00
David Heinemeier Hansson
1973f55c58 Don't include recorded_at with broadcast line
Receiving end will already add that
2023-02-18 16:33:12 +01:00
David Heinemeier Hansson
0a51cd0899 Update for healthcheck config 2023-02-18 16:28:31 +01:00
David Heinemeier Hansson
4b0a8728f1 Bump version for 0.7.0 2023-02-18 16:27:08 +01:00
David Heinemeier Hansson
3075f8daf1 Include healthcheck in config 2023-02-18 16:26:23 +01:00
David Heinemeier Hansson
9985834bd6 Use number 2023-02-18 16:26:17 +01:00
David Heinemeier Hansson
94b4461c76 Merge pull request #52 from mrsked/health-check-with-deploy
Add healthcheck before deploy
2023-02-18 16:24:41 +01:00
David Heinemeier Hansson
7afa9e0815 Mention healthcheck as part of steps instead 2023-02-18 16:23:46 +01:00
David Heinemeier Hansson
933ece35ab Add healthcheck before deploy 2023-02-18 16:22:08 +01:00
David Heinemeier Hansson
2f80b300f0 Test rolling back to a good version too 2023-02-18 14:55:11 +01:00
David Heinemeier Hansson
2e06bf59a4 Protect against rolling back to a bad version 2023-02-18 14:33:47 +01:00
David Heinemeier Hansson
854795c2b6 Wording 2023-02-18 12:10:42 +01:00
David Heinemeier Hansson
4fe7fb705a Use same sentence style as broadcasts for audit log lines 2023-02-18 12:00:15 +01:00
David Heinemeier Hansson
270e0d0e2c Merge pull request #50 from pagbrl/labels-traefik-docs
docs(traefik-labels): Improve docs for traefik labels formatting
2023-02-18 11:42:43 +01:00
David Heinemeier Hansson
6ddc9cf017 Merge pull request #51 from mrsked/audit-broadcasts
Add audit broadcasts
2023-02-18 11:41:19 +01:00
David Heinemeier Hansson
2dcd76b2de Merge branch 'main' into audit-broadcasts
* main:
  Remove unnecessary audit recordings
2023-02-18 11:38:34 +01:00
David Heinemeier Hansson
a6eabd0b67 Remove unnecessary audit recordings 2023-02-18 11:36:52 +01:00
David Heinemeier Hansson
fb9357b5ba Add audit broadcasts 2023-02-18 11:36:30 +01:00
Paul Gabriel
d484cfcc31 docs(traefik-labels): Improve docs for traefik labels formatting 2023-02-18 00:25:30 +01:00
David Heinemeier Hansson
5c93642f2a Prepare for custom pruning 2023-02-15 20:34:08 +01:00
David Heinemeier Hansson
8ff206ba7e Highlight 2023-02-15 18:08:46 +01:00
David Heinemeier Hansson
e36a5e111c Make a note about the /up requirement 2023-02-15 18:08:26 +01:00
David Heinemeier Hansson
72522001e5 Merge pull request #46 from fschueller/fix-prune-desc
Adjust CLI description for prune command to mention 7 days
2023-02-15 14:09:06 +01:00
David Heinemeier Hansson
50c4bb83cb Bump version for 0.6.4 2023-02-15 13:48:10 +01:00
David Heinemeier Hansson
b2875ad056 More readable tests 2023-02-15 13:47:16 +01:00
David Heinemeier Hansson
8ec94f105c Tag images with service label so we can prune exclusively 2023-02-15 13:41:03 +01:00
David Heinemeier Hansson
90f4212a68 Stray copypasta 2023-02-15 13:39:53 +01:00
David Heinemeier Hansson
648894f9a9 No need for quoting 2023-02-15 13:32:59 +01:00
David Heinemeier Hansson
dc68639dfa Prune all unused images matching time filter 2023-02-15 13:32:50 +01:00
David Heinemeier Hansson
244cf8b3b7 Add prune command test 2023-02-15 13:30:31 +01:00
David Heinemeier Hansson
f25f506d77 Don't use abbreviations when we don't have to 2023-02-15 13:26:57 +01:00
David Heinemeier Hansson
c29a177a7a DRY the use of build options into one call 2023-02-15 13:23:14 +01:00
Farah Schüller
03328a998c Adjust CLI description for prune command to mention 7 days 2023-02-14 17:05:36 +01:00
David Heinemeier Hansson
ec5fad5bea Describe the vision 2023-02-11 14:30:23 +01:00
David Heinemeier Hansson
c671acf68f Bump version for 0.6.3 2023-02-11 13:10:47 +01:00
David Heinemeier Hansson
4f2cb5e184 Shorter 2023-02-11 13:00:22 +01:00
David Heinemeier Hansson
63a065237a Ensure .env file is only accessible to user 2023-02-11 12:56:57 +01:00
David Heinemeier Hansson
0f4e1888d9 Just delete the full cache directory, it isnt needed 2023-02-10 14:35:11 +01:00
David Heinemeier Hansson
d4d3308c34 Need to use args 2023-02-09 21:50:57 +01:00
David Heinemeier Hansson
b9c6d2966b Bump version for 0.6.2 2023-02-09 19:57:39 +01:00
David Heinemeier Hansson
f371cda8d8 Stick with json logger for filebeat compatibility but cap at 10mb 2023-02-09 19:56:17 +01:00
David Heinemeier Hansson
9eaf0f3b8f Lower default prune target for images to 7 days. Its just a local convenience cache. Dont risk filling up the disk on very active development. 2023-02-09 18:07:52 +01:00
David Heinemeier Hansson
a80289d046 Use local log driver for everything
Auto rotation, max is 100mb
2023-02-09 17:02:15 +01:00
David Heinemeier Hansson
aae45afb1b Easier to read tests 2023-02-09 17:01:35 +01:00
David Heinemeier Hansson
f4157c95c4 Easier to read tests 2023-02-09 16:55:09 +01:00
David Heinemeier Hansson
bb5176673b Deal with lazy-setting of configuration 2023-02-08 14:24:16 +01:00
David Heinemeier Hansson
e9cb5b64b3 Remove Fly as an example of k8s 2023-02-08 14:14:52 +01:00
David Heinemeier Hansson
0433619518 Tag new builds with latest 2023-02-08 14:08:36 +01:00
David Heinemeier Hansson
110bf44a3b Recommend single layer 2023-02-08 10:27:27 +01:00
David Heinemeier Hansson
fbdf39a733 Code highlighting 2023-02-08 08:37:33 +01:00
David Heinemeier Hansson
f99ff47f75 Make sure folks dont leak GITHUB_TOKENs into the image when using git dependencies 2023-02-08 08:35:30 +01:00
David Heinemeier Hansson
bb18189b01 Bump version for 0.6.1 2023-02-07 15:05:58 +01:00
David Heinemeier Hansson
18bdb33de2 Fix issue with removing containers triggering twice, then ensure app stop runs closer to app run on each host 2023-02-07 15:05:58 +01:00
David Heinemeier Hansson
1ec016ecad Add a brief note about Docker Swarm
A deeper comparison would be nice at some point.
2023-02-07 13:58:26 +01:00
David Heinemeier Hansson
bd61e04088 Merge pull request #38 from tbuehlmann/native-builder-image-tag-position
Move image tag to proper position
2023-02-06 09:22:57 +01:00
David Heinemeier Hansson
0da2a6408b Merge pull request #39 from adammiribyan/outside-git
Commit hash as version but not in git
2023-02-06 09:22:25 +01:00
David Heinemeier Hansson
9697a9a6e0 Merge pull request #40 from adammiribyan/gemspec
Match README
2023-02-06 09:21:57 +01:00
Adam Miribyan
32d52b024c Match README
Update gemspec description to match what's in README
2023-02-05 23:09:08 +01:00
Adam
2fe01f13df Commit hash version but not in git
Fixes #11
2023-02-05 20:31:14 +01:00
Tobias Bühlmann
554a3558ab Move image tag to proper position 2023-02-05 18:39:52 +01:00
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
75 changed files with 4206 additions and 222 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,10 @@
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"
gem "ed25519"
gem "bcrypt_pbkdf"

View File

@@ -1,9 +1,12 @@
PATH
remote: .
specs:
mrsk (0.0.1)
railties (>= 7.0.0)
mrsk (0.8.1)
activesupport (>= 7.0)
dotenv (~> 2.8)
sshkit (~> 1.21)
thor (~> 1.2)
zeitwerk (~> 2.5)
GEM
remote: https://rubygems.org/
@@ -26,22 +29,36 @@ GEM
i18n (>= 1.6, < 2)
minitest (>= 5.1)
tzinfo (~> 2.0)
bcrypt_pbkdf (1.1.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)
ed25519 (1.3.0)
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 +77,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 +94,16 @@ PLATFORMS
arm64-darwin-22
x86_64-darwin-20
x86_64-darwin-21
x86_64-darwin-22
x86_64-linux
DEPENDENCIES
byebug
bcrypt_pbkdf
debug
ed25519
mocha
mrsk!
railties
BUNDLED WITH
2.2.33
2.4.3

512
README.md
View File

@@ -1,55 +1,505 @@
# 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. It was built for Rails applications, but works with any type of web app that can be bundled with Docker.
## 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:
- 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. Ensure your app responds with `200 OK` to `GET /up`.
9. Stop any containers running a previous versions of the app.
10. Start a new container with the version of the app that matches the current git version hash.
11. 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.
## Vision
In the past decade+, there's been an explosion in commercial offerings that make deploying web apps easier. Heroku kicked it off with an incredible offering that stayed ahead of the competition seemingly forever. These days we have excellent alternatives like Fly.io and Render. And hosted Kubernetes is making things easier too on AWS, GCP, Digital Ocean, and elsewhere. But these are all offerings that have you renting computers in the cloud at a premium. If you want to run on our own hardware, or even just have a clear migration path to do so, you need to carefully consider how locked in you get to these commercial platforms. Preferably before the bills swallow your business whole!
MRSK seeks to bring the advance in ergonomics pioneered by these commercial offerings to deploying web apps anywhere. Whether that's low-cost cloud options without the managed-service markup from the likes of Digital Ocean, Hetzner, OVH, etc, or it's your own colocated metal. To MRSK, it's all the same. Feed the config file a list of IP addresses with vanilla Ubuntu servers that have seen no prep beyond an added SSH key, and you'll be running in literally minutes.
This structure also gives you enormous portability. You can have your web app deployed on several clouds at ease like this. Or you can buy the baseline with your own hardware, then deploy to a cloud before a big seasonal spike to get more capacity. When you're not locked into a single provider from a tooling perspective, there's a lot of compelling options available.
Ultimately, MRSK is meant to compress the complexity of going to production using open source tooling that isn't tied to any commercial offering. Not to zero, though. You're probably still better off with a fully managed service if basic Linux or Docker is still difficult, but from an early stage when those concepts are familiar.
## Why not just run Capistrano, Kubernetes or Docker Swarm?
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, either transparently [like Render](https://thenewstack.io/render-cloud-deployment-with-less-engineering/) or explicitly on AWS/GCP, 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.
Docker Swarm is much simpler than Kubernetes, but it's still built on the same declarative model that uses state reconciliation. MRSK is intentionally designed to around imperative commands, like Capistrano.
## 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 escaped backticks are needed to ensure the rule is passed in correctly and not treated as command substitution by Bash!
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:
```dockerfile
# Copy Gemfiles
COPY Gemfile Gemfile.lock ./
# Install dependencies, including private repositories via access token (then remove bundle cache with exposed GITHUB_TOKEN)
RUN --mount=type=secret,id=GITHUB_TOKEN \
BUNDLE_GITHUB__COM=x-access-token:$(cat /run/secrets/GITHUB_TOKEN) \
bundle install && \
rm -rf /usr/local/bundle/cache
```
### Using command arguments for Traefik
You can customize the traefik command line:
```yaml
traefik:
args:
accesslog: true
accesslog.format: json
```
This will start the traefik container with `--accesslog=true accesslog.format=json`.
### 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`.
### Using audit broadcasts
If you'd like to broadcast audits of deploys, rollbacks, etc to a chatroom or elsewhere, you can configure the `audit_broadcast_cmd` setting with the path to a bin file that will be passed the audit line as the first argument:
```yaml
audit_broadcast_cmd:
bin/audit_broadcast
```
The broadcast command could look something like:
```bash
#!/usr/bin/env bash
curl -q -d content="[My App] ${1}" https://3.basecamp.com/XXXXX/integrations/XXXXX/buckets/XXXXX/chats/XXXXX/lines
```
That'll post a line like follows to a preconfigured chatbot in Basecamp:
```
[My App] [dhh] Rolled back to version d264c4e92470ad1bd18590f04466787262f605de
```
### Using custom healthcheck path or port
MRSK defaults to checking the health of your application again `/up` on port 3000. You can tailor both with the `healthcheck` setting:
```yaml
healthcheck:
path: /healthz
port: 4000
```
This will ensure your application is configured with a traefik label for the healthcheck against `/healthz` and that the pre-deploy healthcheck that MRSK performs is done against the same path on port 4000.
## 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 show 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

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

@@ -0,0 +1,216 @@
class Mrsk::Cli::Accessory < Mrsk::Cli::Base
desc "boot [NAME]", "Boot new 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("Booted #{name} accessory"), verbosity: :debug
execute *accessory.run
end
audit_broadcast "Booted accessory #{name}"
end
end
end
desc "upload [NAME]", "Upload accessory files to host", hide: true
def upload(name)
with_accessory(name) do |accessory|
on(accessory.host) do
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", hide: true
def directories(name)
with_accessory(name) do |accessory|
on(accessory.host) do
accessory.directories.keys.each do |host_path|
execute *accessory.make_directory(host_path)
end
end
end
end
desc "reboot [NAME]", "Reboot existing 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 container on host"
def start(name)
with_accessory(name) do |accessory|
on(accessory.host) do
execute *MRSK.auditor.record("Started #{name} accessory"), verbosity: :debug
execute *accessory.start
end
end
end
desc "stop [NAME]", "Stop existing accessory container on host"
def stop(name)
with_accessory(name) do |accessory|
on(accessory.host) do
execute *MRSK.auditor.record("Stopped #{name} accessory"), verbosity: :debug
execute *accessory.stop, raise_on_non_zero_exit: false
end
end
end
desc "restart [NAME]", "Restart existing accessory container on host"
def restart(name)
with_accessory(name) do
stop(name)
start(name)
end
end
desc "details [NAME]", "Show details about accessory on host (use NAME=all to show 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 (use --help to show options)"
option :interactive, aliases: "-i", type: :boolean, default: false, desc: "Execute command over ssh for an interactive shell (use for console/bash)"
option :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("Executed cmd '#{cmd}' on #{name} accessory"), 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("Executed cmd '#{cmd}' on #{name} accessory"), 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 (use --help to show options)"
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 remove all accessories)"
option :confirmed, aliases: "-y", type: :boolean, default: false, desc: "Proceed without confirmation question"
def remove(name)
if name == "all"
if options[:confirmed] || ask("This will remove all containers and images for all accessories. Are you sure?", limited_to: %w( y N ), default: "N") == "y"
MRSK.accessory_names.each { |accessory_name| remove(accessory_name) }
end
else
if options[:confirmed] || ask("This will remove all containers and images for #{name}. Are you sure?", limited_to: %w( y N ), default: "N") == "y"
with_accessory(name) do
stop(name)
remove_container(name)
remove_image(name)
remove_service_directory(name)
end
end
end
end
desc "remove_container [NAME]", "Remove accessory container from host", hide: true
def remove_container(name)
with_accessory(name) do |accessory|
on(accessory.host) do
execute *MRSK.auditor.record("Remove #{name} accessory container"), verbosity: :debug
execute *accessory.remove_container
end
end
end
desc "remove_image [NAME]", "Remove accessory image from host", hide: true
def remove_image(name)
with_accessory(name) do |accessory|
on(accessory.host) do
execute *MRSK.auditor.record("Removed #{name} accessory 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", hide: true
def remove_service_directory(name)
with_accessory(name) do |accessory|
on(accessory.host) do
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

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

@@ -0,0 +1,202 @@
class Mrsk::Cli::App < Mrsk::Cli::Base
desc "boot", "Boot app on servers (or reboot app if already running)"
def boot
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("Booted app version #{version}"), verbosity: :debug
begin
execute *MRSK.app.stop, raise_on_non_zero_exit: false
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}"
execute *MRSK.auditor.record("Rebooted app version #{version}"), verbosity: :debug
execute *MRSK.app.remove_container(version: version)
execute *MRSK.app.run(role: role.name)
else
raise
end
end
end
end
end
end
desc "start", "Start existing app container on servers"
def start
on(MRSK.hosts) do
execute *MRSK.auditor.record("Started app version #{MRSK.version}"), verbosity: :debug
execute *MRSK.app.start, raise_on_non_zero_exit: false
end
end
desc "stop", "Stop app container on servers"
def stop
on(MRSK.hosts) do
execute *MRSK.auditor.record("Stopped app"), verbosity: :debug
execute *MRSK.app.stop, raise_on_non_zero_exit: false
end
end
# FIXME: Drop in favor of just containers?
desc "details", "Show 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 (use --help to show options)"
option :interactive, aliases: "-i", type: :boolean, default: false, desc: "Execute command over ssh for an interactive shell (use for console/bash)"
option :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("Executed cmd '#{cmd}' on app 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("Executed cmd '#{cmd}' on app version #{version}"), verbosity: :debug
puts_by_host host, capture_with_info(*MRSK.app.execute_in_new_container(cmd))
end
end
end
end
desc "containers", "Show app containers on servers"
def containers
on(MRSK.hosts) { |host| puts_by_host host, capture_with_info(*MRSK.app.list_containers) }
end
desc "images", "Show app images on servers"
def images
on(MRSK.hosts) { |host| puts_by_host host, capture_with_info(*MRSK.app.list_images) }
end
desc "logs", "Show log lines from app on servers (use --help to show options)"
option :since, aliases: "-s", desc: "Show lines since timestamp (e.g. 2013-01-02T13:23:37Z) or relative (e.g. 42m for 42 minutes)"
option :lines, type: :numeric, aliases: "-n", desc: "Number of lines to show from each server"
option :grep, aliases: "-g", desc: "Show lines with grep match only (use this to fetch specific requests by id)"
option :follow, aliases: "-f", desc: "Follow log 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
stop
remove_containers
remove_images
end
desc "remove_container [VERSION]", "Remove app container with given version from servers", hide: true
def remove_container(version)
on(MRSK.hosts) do
execute *MRSK.auditor.record("Removed app container with version #{version}"), verbosity: :debug
execute *MRSK.app.remove_container(version: version)
end
end
desc "remove_containers", "Remove all app containers from servers", hide: true
def remove_containers
on(MRSK.hosts) do
execute *MRSK.auditor.record("Removed all app containers"), verbosity: :debug
execute *MRSK.app.remove_containers
end
end
desc "remove_images", "Remove all app images from servers", hide: true
def remove_images
on(MRSK.hosts) do
execute *MRSK.auditor.record("Removed all app images"), verbosity: :debug
execute *MRSK.app.remove_images
end
end
desc "version", "Show app version currently running on servers"
def 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 }
if version == "<none>"
raise "Most recent image available was not tagged with a version (returned <none>)"
else
version.presence
end
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

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

@@ -0,0 +1,72 @@
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"
class_option :destination, aliases: "-d", desc: "Specify destination to be used for config file (staging -> deploy.staging.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
return Time.now - started_at
ensure
runtime = Time.now - started_at
puts " Finished all in #{sprintf("%.1f seconds", runtime)}"
end
def audit_broadcast(line)
run_locally { execute *MRSK.auditor.broadcast(line), verbosity: :debug }
end
end
end

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

@@ -0,0 +1,70 @@
class Mrsk::Cli::Build < Mrsk::Cli::Base
desc "deliver", "Build app and push app image to registry then pull image on servers"
def deliver
push
pull
end
desc "push", "Build 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 registry onto servers"
def pull
on(MRSK.hosts) do
execute *MRSK.auditor.record("Pulled image with version #{MRSK.version}"), verbosity: :debug
execute *MRSK.builder.clean, raise_on_non_zero_exit: false
execute *MRSK.builder.pull
end
end
desc "create", "Create a 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 build setup"
def remove
run_locally do
debug "Using builder: #{MRSK.builder.name}"
execute *MRSK.builder.remove
end
end
desc "details", "Show build setup"
def details
run_locally do
puts "Builder: #{MRSK.builder.name}"
puts capture(*MRSK.builder.info)
end
end
end

View File

@@ -0,0 +1,48 @@
class Mrsk::Cli::Healthcheck < Mrsk::Cli::Base
MAX_ATTEMPTS = 5
default_command :perform
desc "perform", "Health check current app version"
def perform
on(MRSK.primary_host) do
begin
execute *MRSK.healthcheck.run
target = "Health check against #{MRSK.config.healthcheck["path"]}"
attempt = 1
begin
status = capture_with_info(*MRSK.healthcheck.curl)
if status == "200"
info "#{target} succeeded with 200 OK!"
else
raise "#{target} failed with status #{status}"
end
rescue SSHKit::Command::Failed
if attempt <= MAX_ATTEMPTS
info "#{target} failed to respond, retrying in #{attempt}s..."
sleep attempt
attempt += 1
retry
else
raise
end
end
rescue SSHKit::Command::Failed => e
error capture_with_info(*MRSK.healthcheck.logs)
if e.message =~ /curl/
raise SSHKit::Command::Failed, "#{target} failed to return 200 OK!"
else
raise
end
ensure
execute *MRSK.healthcheck.stop, raise_on_non_zero_exit: false
execute *MRSK.healthcheck.remove, raise_on_non_zero_exit: false
end
end
end
end

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

@@ -0,0 +1,187 @@
class Mrsk::Cli::Main < Mrsk::Cli::Base
desc "setup", "Setup all accessories and deploy 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 app to servers"
def deploy
runtime = 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"
say "Ensure app can pass healthcheck...", :magenta
invoke "mrsk:cli:healthcheck:perform"
invoke "mrsk:cli:app:boot"
say "Prune old containers and images...", :magenta
invoke "mrsk:cli:prune:all"
end
audit_broadcast "Deployed app in #{runtime.to_i} seconds"
end
desc "redeploy", "Deploy app to servers without bootstrapping servers, starting Traefik, pruning, and registry login"
def redeploy
runtime = print_runtime do
say "Build and push app image...", :magenta
invoke "mrsk:cli:build:deliver"
say "Ensure app can pass healthcheck...", :magenta
invoke "mrsk:cli:healthcheck:perform"
invoke "mrsk:cli:app:boot"
end
audit_broadcast "Redeployed app in #{runtime.to_i} seconds"
end
desc "rollback [VERSION]", "Rollback app to VERSION"
def rollback(version)
MRSK.version = version
if container_name_available?(MRSK.config.service_with_version)
say "Stop current version, then start version #{version}...", :magenta
on(MRSK.hosts) do |host|
execute *MRSK.app.stop, raise_on_non_zero_exit: false
execute *MRSK.app.start
end
audit_broadcast "Rolled back app to version #{version}"
else
say "The app version '#{version}' is not available as a container (use 'mrsk app containers' for available versions)", :red
end
end
desc "details", "Show details about all 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 (including secrets!)"
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]
env_template_path = ".env.#{destination}.erb"
env_path = ".env.#{destination}"
else
env_template_path = ".env.erb"
env_path = ".env"
end
File.write(env_path, ERB.new(File.read(env_template_path)).result, perm: 0600)
end
desc "remove", "Remove Traefik, app, accessories, and registry session from servers"
option :confirmed, aliases: "-y", type: :boolean, default: false, desc: "Proceed without confirmation question"
def remove
if options[:confirmed] || ask(remove_confirmation_question, limited_to: %w( y N ), default: "N") == "y"
invoke "mrsk:cli:traefik:remove", [], options.without(:confirmed)
invoke "mrsk:cli:app:remove", [], options.without(:confirmed)
invoke "mrsk:cli:accessory:remove", [ "all" ]
invoke "mrsk:cli:registry:logout", [], options.without(:confirmed)
end
end
desc "version", "Show MRSK version"
def version
puts Mrsk::VERSION
end
desc "accessory", "Manage accessories (db/redis/search)"
subcommand "accessory", Mrsk::Cli::Accessory
desc "app", "Manage application"
subcommand "app", Mrsk::Cli::App
desc "build", "Build application image"
subcommand "build", Mrsk::Cli::Build
desc "healthcheck", "Healthcheck application"
subcommand "healthcheck", Mrsk::Cli::Healthcheck
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 Traefik load balancer"
subcommand "traefik", Mrsk::Cli::Traefik
private
def container_name_available?(container_name, host: MRSK.primary_host)
container_names = nil
on(host) { container_names = capture_with_info(*MRSK.app.list_container_names).split("\n") }
Array(container_names).include?(container_name)
end
def remove_confirmation_question
"This will remove all containers and images. " +
(MRSK.config.accessories.any? ? "Including #{MRSK.config.accessories.collect(&:name).to_sentence}. " : "") +
"Are you sure?"
end
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
containers
images
end
desc "images", "Prune unused images older than 7 days"
def images
on(MRSK.hosts) do
execute *MRSK.auditor.record("Pruned images"), verbosity: :debug
execute *MRSK.prune.images
end
end
desc "containers", "Prune stopped containers older than 3 days"
def containers
on(MRSK.hosts) do
execute *MRSK.auditor.record("Pruned containers"), verbosity: :debug
execute *MRSK.prune.containers
end
end
end

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

@@ -0,0 +1,18 @@
class Mrsk::Cli::Registry < Mrsk::Cli::Base
desc "login", "Log in to registry locally and remotely"
def login
run_locally { execute *MRSK.registry.login }
on(MRSK.hosts) { execute *MRSK.registry.login }
# FIXME: This rescue needed?
rescue ArgumentError => e
puts e.message
end
desc "logout", "Log out of registry remotely"
def logout
on(MRSK.hosts) { execute *MRSK.registry.logout }
# FIXME: This rescue needed?
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 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,76 @@
# Name of your application. Used to uniquely configure containers.
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:
- MRSK_REGISTRY_PASSWORD
# Inject ENV variables into containers (secrets come from .env).
# env:
# clear:
# DB_HOST: 192.168.0.2
# secret:
# - RAILS_MASTER_KEY
# Call a broadcast command on deploys.
# audit_broadcast_cmd:
# bin/broadcast_to_bc
# Use a different ssh user than root
# ssh:
# user: app
# Configure builder setup.
# builder:
# args:
# RUBY_VERSION: 3.2.0
# secrets:
# - GITHUB_TOKEN
# remote:
# arch: amd64
# host: ssh://app@192.168.0.1
# Use accessory services (secrets come from .env).
# accessories:
# db:
# image: mysql:8.0
# host: 192.168.0.2
# port: 3306
# env:
# clear:
# MYSQL_ROOT_HOST: '%'
# secret:
# - MYSQL_ROOT_PASSWORD
# files:
# - config/mysql/production.cnf:/etc/mysql/my.cnf
# - db/production.sql.erb:/docker-entrypoint-initdb.d/setup.sql
# directories:
# - data:/var/lib/mysql
# redis:
# image: redis:7.0
# host: 192.168.0.2
# port: 6379
# directories:
# - data:/data
# Configure custom arguments for Traefik
# traefik:
# args:
# accesslog: true
# accesslog.format: json
# Configure a custom healthcheck (default is /up on port 3000)
# healthcheck:
# path: /healthz
# port: 4000

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
stop
remove_container
boot
end
desc "start", "Start existing Traefik container on servers"
def start
on(MRSK.traefik_hosts) do
execute *MRSK.auditor.record("Started traefik"), verbosity: :debug
execute *MRSK.traefik.start, raise_on_non_zero_exit: false
end
end
desc "stop", "Stop existing Traefik container on servers"
def stop
on(MRSK.traefik_hosts) do
execute *MRSK.auditor.record("Stopped traefik"), verbosity: :debug
execute *MRSK.traefik.stop, raise_on_non_zero_exit: false
end
end
desc "restart", "Restart existing Traefik container on servers"
def restart
stop
start
end
desc "details", "Show details about Traefik container 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
stop
remove_container
remove_image
end
desc "remove_container", "Remove Traefik container from servers", hide: true
def remove_container
on(MRSK.traefik_hosts) do
execute *MRSK.auditor.record("Removed traefik container"), verbosity: :debug
execute *MRSK.traefik.remove_container
end
end
desc "remove_container", "Remove Traefik image from servers", hide: true
def remove_image
on(MRSK.traefik_hosts) do
execute *MRSK.auditor.record("Removed traefik image"), verbosity: :debug
execute *MRSK.traefik.remove_image
end
end
end

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

@@ -0,0 +1,119 @@
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 accessory(name)
Mrsk::Commands::Accessory.new(config, name: name)
end
def auditor
@auditor ||= Mrsk::Commands::Auditor.new(config)
end
def builder
@builder ||= Mrsk::Commands::Builder.new(config)
end
def healthcheck
@healthcheck ||= Mrsk::Commands::Healthcheck.new(config)
end
def prune
@prune ||= Mrsk::Commands::Prune.new(config)
end
def registry
@registry ||= Mrsk::Commands::Registry.new(config)
end
def traefik
@traefik ||= Mrsk::Commands::Traefik.new(config)
end
def with_verbosity(level)
old_level = self.verbosity
self.verbosity = level
SSHKit.config.output_verbosity = level
yield
ensure
self.verbosity = old_level
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"] || current_commit_hash
end
def current_commit_hash
if system("git rev-parse")
`git rev-parse HEAD`.strip
else
raise "Can't use commit hash as version, no git repository found in #{Dir.pwd}"
end
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,110 @@
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,
"--detach",
"--restart", "unless-stopped",
"--log-opt", "max-size=#{MAX_LOG_SIZE}",
"--publish", 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), (" --tail #{lines}" if lines), "--timestamps", "2>&1"),
("grep '#{grep}'" if grep)
end
def follow_logs(grep: nil)
run_over_ssh \
pipe \
docker(:logs, service_name, "--timestamps", "--tail", "10", "--follow", "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, "--force", *service_filter
end
def remove_image
docker :image, :prune, "--all", "--force", *service_filter
end
private
def service_filter
[ "--filter", "label=service=#{service_name}" ]
end
end

View File

@@ -1,22 +1,138 @@
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,
"--detach",
"--restart unless-stopped",
"--log-opt", "max-size=#{MAX_LOG_SIZE}",
"--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}#{" --tail #{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 --timestamps --tail 10 --follow 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, "--quiet", *service_filter
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 all_versions_from_available_containers
pipe \
docker(:image, :ls, "--format", '"{{.Tag}}"', config.repository),
"head -n 1"
end
def list_containers
docker :container, :ls, "--all", *service_filter
end
def list_container_names
[ *list_containers, "--format", "'{{ .Names }}'" ]
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, "--force", *service_filter
end
def list_images
docker :image, :ls, config.repository
end
def remove_images
docker :image, :prune, "--all", "--force", *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,42 @@
require "active_support/core_ext/time/conversions"
class Mrsk::Commands::Auditor < Mrsk::Commands::Base
# Runs remotely
def record(line)
append \
[ :echo, tagged_record_line(line) ],
audit_log_file
end
# Runs locally
def broadcast(line)
if broadcast_cmd = config.audit_broadcast_cmd
[ broadcast_cmd, tagged_broadcast_line(line) ]
end
end
def reveal
[ :tail, "-n", 50, audit_log_file ]
end
private
def audit_log_file
"mrsk-#{config.service}-audit.log"
end
def tagged_record_line(line)
"'#{recorded_at_tag} #{performer_tag} #{line}'"
end
def tagged_broadcast_line(line)
"'#{performer_tag} #{line}'"
end
def performer_tag
"[#{`whoami`.strip}]"
end
def recorded_at_tag
"[#{Time.now.to_fs(:db)}]"
end
end

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

@@ -0,0 +1,52 @@
module Mrsk::Commands
class Base
delegate :redact, to: Mrsk::Utils
MAX_LOG_SIZE = "10m"
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
def container_id_for(container_name:)
docker :container, :ls, "--all", "--filter", "name=#{container_name}", "--quiet"
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, :clean, :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,40 @@
class Mrsk::Commands::Builder::Base < Mrsk::Commands::Base
delegate :argumentize, to: Mrsk::Utils
def clean
docker :image, :rm, "--force", config.absolute_image
end
def pull
docker :pull, config.absolute_image
end
def build_options
[ *build_tags, *build_labels, *build_args, *build_secrets ]
end
private
def build_tags
[ "-t", config.absolute_image, "-t", config.latest_image ]
end
def build_labels
argumentize "--label", { service: config.service }
end
def build_args
argumentize "--build-arg", args, redacted: true
end
def build_secrets
argumentize "--secret", secrets.collect { |secret| [ "id", secret ] }
end
def args
(config.builder && config.builder["args"]) || {}
end
def secrets
(config.builder && config.builder["secrets"]) || []
end
end

View File

@@ -0,0 +1,29 @@
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,
*build_options,
"."
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, *build_options, "."),
docker(:push, config.absolute_image)
end
def info
# No-op on native
end
end

View File

@@ -0,0 +1,67 @@
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,
*build_options,
"."
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,50 @@
class Mrsk::Commands::Healthcheck < Mrsk::Commands::Base
EXPOSED_PORT = 3999
def run
web = config.role(:web)
docker :run,
"--detach",
"--name", container_name_with_version,
"--publish", "#{EXPOSED_PORT}:#{config.healthcheck["port"]}",
"--label", "service=#{container_name}",
*web.env_args,
*config.volume_args,
config.absolute_image,
web.cmd
end
def curl
[ :curl, "--silent", "--output", "/dev/null", "--write-out", "'%{http_code}'", "--max-time", "2", health_url ]
end
def logs
pipe container_id, xargs(docker(:logs, "--tail", 50, "2>&1"))
end
def stop
pipe container_id, xargs(docker(:stop))
end
def remove
pipe container_id, xargs(docker(:container, :rm))
end
private
def container_name
"healthcheck-#{config.service}"
end
def container_name_with_version
"healthcheck-#{config.service_with_version}"
end
def container_id
container_id_for(container_name: container_name)
end
def health_url
"http://localhost:#{EXPOSED_PORT}#{config.healthcheck["path"]}"
end
end

View File

@@ -0,0 +1,12 @@
require "active_support/duration"
require "active_support/core_ext/numeric/time"
class Mrsk::Commands::Prune < Mrsk::Commands::Base
def images(until_hours: 7.days.in_hours.to_i)
docker :image, :prune, "--all", "--force", "--filter", "label=service=#{config.service}", "--filter", "until=#{until_hours}h"
end
def containers(until_hours: 3.days.in_hours.to_i)
docker :container, :prune, "--force", "--filter", "label=service=#{config.service}", "--filter", "until=#{until_hours}h"
end
end

View File

@@ -1,5 +1,20 @@
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(lookup_password)
end
def logout
docker :logout, registry["server"]
end
private
def lookup_password
if registry["password"].is_a?(Array)
ENV.fetch(registry["password"].first).dup
else
registry["password"]
end
end
end

View File

@@ -1,17 +1,52 @@
class Mrsk::Commands::Traefik < Mrsk::Commands::Base
def run
docker :run, "--name traefik",
"--detach",
"--restart", "unless-stopped",
"--log-opt", "max-size=#{MAX_LOG_SIZE}",
"--publish", "80:80",
"--volume", "/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), (" --tail #{lines}" if lines), "--timestamps", "2>&1"),
("grep '#{grep}'" if grep)
end
def follow_logs(host:, grep: nil)
run_over_ssh pipe(
docker(:logs, "traefik", "--timestamps", "--tail", "10", "--follow", "2>&1"),
(%(grep "#{grep}") if grep)
).join(" "), host: host
end
def remove_container
docker :container, :prune, "--force", "--filter", "label=org.opencontainers.image.title=Traefik"
end
def remove_image
docker :image, :prune, "--all", "--force", "--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,193 @@
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("/")
"#{repository}:#{version}"
end
def image_with_version
"#{image}:#{version}"
def latest_image
"#{repository}:latest"
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 audit_broadcast_cmd
raw_config.audit_broadcast_cmd
end
def healthcheck
{ "path" => "/up", "port" => 3000 }.merge(raw_config.healthcheck || {})
end
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,
healthcheck: healthcheck
}.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" => config.healthcheck["path"],
"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

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

@@ -0,0 +1,30 @@
module Mrsk::Utils
extend self
# Return a list of escaped 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 |key, value|
if value.present?
escaped_pair = [ key, value.to_s.dump.gsub(/`/, '\\\\`') ].join("=")
[ argument, redacted ? redact(escaped_pair) : escaped_pair ]
else
[ argument, key ]
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.8.1"
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 web 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,46 @@
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 --detach --restart unless-stopped --log-opt max-size=10m --publish 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
test "remove with confirmation" do
run_command("remove", "mysql", "-y").tap do |output|
assert_match /docker container stop app-mysql/, output
assert_match /docker image prune --all --force --filter label=service=app-mysql/, output
assert_match /rm -rf app-mysql/, output
end
end
private
def run_command(*command)
stdouted { Mrsk::Cli::Accessory.start([*command, "-c", "test/fixtures/deploy_with_accessories.yml"]) }
end
end

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

@@ -0,0 +1,75 @@
require_relative "cli_test_case"
class CliAppTest < CliTestCase
test "boot" do
assert_match /Running docker run --detach --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 --quiet --filter label=service=app \| xargs docker stop/, output # Stop what's running
assert_match /docker container ls --all --filter name=app-999 --quiet \| 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 --quiet --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" do
run_command("remove").tap do |output|
assert_match /docker ps --quiet --filter label=service=app | xargs docker stop/, output
assert_match /docker container prune --force --filter label=service=app/, output
assert_match /docker image prune --all --force --filter label=service=app/, output
end
end
test "remove_container" do
run_command("remove_container", "1234567").tap do |output|
assert_match /docker container ls --all --filter name=app-1234567 --quiet \| 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

15
test/cli/build_test.rb Normal file
View File

@@ -0,0 +1,15 @@
require_relative "cli_test_case"
class CliBuildTest < CliTestCase
test "pull" do
run_command("pull").tap do |output|
assert_match /docker image rm --force dhh\/app:999/, output
assert_match /docker pull dhh\/app:999/, output
end
end
private
def run_command(*command)
stdouted { Mrsk::Cli::Build.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

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

@@ -0,0 +1,56 @@
require_relative "cli_test_case"
class CliMainTest < CliTestCase
test "version" do
version = stdouted { Mrsk::Cli::Main.new.version }
assert_equal Mrsk::VERSION, version
end
test "rollback bad version" do
run_command("details") # Preheat MRSK const
run_command("rollback", "nonsense").tap do |output|
assert_match /docker container ls --all --filter label=service=app --format '{{ .Names }}'/, output
assert_match /The app version 'nonsense' is not available as a container/, output
end
end
test "rollback good version" do
Mrsk::Cli::Main.any_instance.stubs(:container_name_available?).returns(true)
run_command("rollback", "123").tap do |output|
assert_match /Stop current version, then start version 123/, output
assert_match /docker ps -q --filter label=service=app | xargs docker stop/, output
assert_match /docker start app-123/, output
end
end
test "remove with confirmation" do
run_command("remove", "-y").tap do |output|
assert_match /docker container stop traefik/, output
assert_match /docker container prune --force --filter label=org.opencontainers.image.title=Traefik/, output
assert_match /docker image prune --all --force --filter label=org.opencontainers.image.title=Traefik/, output
assert_match /docker ps --quiet --filter label=service=app | xargs docker stop/, output
assert_match /docker container prune --force --filter label=service=app/, output
assert_match /docker image prune --all --force --filter label=service=app/, output
assert_match /docker container stop app-mysql/, output
assert_match /docker container prune --force --filter label=service=app-mysql/, output
assert_match /docker image prune --all --force --filter label=service=app-mysql/, output
assert_match /rm -rf app-mysql/, output
assert_match /docker container stop app-redis/, output
assert_match /docker container prune --force --filter label=service=app-redis/, output
assert_match /docker image prune --all --force --filter label=service=app-redis/, output
assert_match /rm -rf app-redis/, output
assert_match /docker logout/, output
end
end
private
def run_command(*command)
stdouted { Mrsk::Cli::Main.start([*command, "-c", "test/fixtures/deploy_with_accessories.yml"]) }
end
end

45
test/commander_test.rb Normal file
View File

@@ -0,0 +1,45 @@
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 "commit hash as version" do
assert_equal `git rev-parse HEAD`.strip, @mrsk.config.version
end
test "commit hash as version but not in git" do
@mrsk.expects(:system).with("git rev-parse").returns(nil)
error = assert_raises(RuntimeError) { @mrsk.config }
assert_match /no git repository found/, error.message
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,134 @@
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 --detach --restart unless-stopped --log-opt max-size=10m --publish 3306:3306 -e MYSQL_ROOT_PASSWORD=\"secret123\" -e MYSQL_ROOT_HOST=\"%\" --label service=\"app-mysql\" mysql:8.0",
@mysql.run.join(" ")
assert_equal \
"docker run --name app-redis --detach --restart unless-stopped --log-opt max-size=10m --publish 6379:6379 -e SOMETHING=\"else\" --volume /var/lib/redis:/data --label service=\"app-redis\" --label cache=\"true\" redis:latest",
@redis.run.join(" ")
end
test "start" do
assert_equal \
"docker container start app-mysql",
@mysql.start.join(" ")
end
test "stop" do
assert_equal \
"docker container stop app-mysql",
@mysql.stop.join(" ")
end
test "info" do
assert_equal \
"docker ps --filter label=service=app-mysql",
@mysql.info.join(" ")
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").join(" ")
end
test "execute in existing container" do
assert_equal \
"docker exec app-mysql mysql -u root",
@mysql.execute_in_existing_container("mysql", "-u", "root").join(" ")
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 --timestamps 2>&1",
@mysql.logs.join(" ")
assert_equal \
"docker logs app-mysql --since 5m --tail 100 --timestamps 2>&1 | grep 'thing'",
@mysql.logs(since: "5m", lines: 100, grep: "thing").join(" ")
end
test "follow logs" do
assert_equal \
"ssh -t root@1.1.1.5 'docker logs app-mysql --timestamps --tail 10 --follow 2>&1'",
@mysql.follow_logs
end
test "remove container" do
assert_equal \
"docker container prune --force --filter label=service=app-mysql",
@mysql.remove_container.join(" ")
end
test "remove image" do
assert_equal \
"docker image prune --all --force --filter label=service=app-mysql",
@mysql.remove_image.join(" ")
end
end

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

@@ -0,0 +1,169 @@
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 --detach --restart unless-stopped --log-opt max-size=10m --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 --detach --restart unless-stopped --log-opt max-size=10m --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 "run with custom healthcheck path" do
@config[:healthcheck] = { "path" => "/healthz" }
assert_equal \
"docker run --detach --restart unless-stopped --log-opt max-size=10m --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=\"/healthz\" --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 --quiet --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 --quiet --filter label=service=app | xargs docker logs 2>&1",
@app.logs.join(" ")
assert_equal \
"docker ps --quiet --filter label=service=app | xargs docker logs --since 5m 2>&1",
@app.logs(since: "5m").join(" ")
assert_equal \
"docker ps --quiet --filter label=service=app | xargs docker logs --tail 100 2>&1",
@app.logs(lines: "100").join(" ")
assert_equal \
"docker ps --quiet --filter label=service=app | xargs docker logs --since 5m --tail 100 2>&1",
@app.logs(since: "5m", lines: "100").join(" ")
assert_equal \
"docker ps --quiet --filter label=service=app | xargs docker logs 2>&1 | grep 'my-id'",
@app.logs(grep: "my-id").join(" ")
assert_equal \
"docker ps --quiet --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 --quiet --filter label=service=app | xargs docker logs --timestamps --tail 10 --follow 2>&1",
@app.follow_logs(host: "app-1")
assert_equal \
"docker ps --quiet --filter label=service=app | xargs docker logs --timestamps --tail 10 --follow 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 --quiet --filter label=service=app",
@app.current_container_id.join(" ")
end
test "container_id_for" do
assert_equal \
"docker container ls --all --filter name=app-999 --quiet",
@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,27 @@
require "test_helper"
class CommandsAuditorTest < ActiveSupport::TestCase
setup do
@config = {
service: "app", image: "dhh/app", registry: { "username" => "dhh", "password" => "secret" }, servers: [ "1.1.1.1" ],
audit_broadcast_cmd: "bin/audit_broadcast"
}
end
test "record" do
assert_match \
/echo '.* app removed container' >> mrsk-app-audit.log/,
new_command.record("app removed container").join(" ")
end
test "broadcast" do
assert_match \
/bin\/audit_broadcast '\[.*\] app removed container'/,
new_command.broadcast("app removed container").join(" ")
end
private
def new_command
Mrsk::Commands::Auditor.new(Mrsk::Configuration.new(@config, version: "123"))
end
end

View File

@@ -0,0 +1,79 @@
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 -t dhh/app:latest --label service=\"app\" .",
builder.push.join(" ")
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 -t dhh/app:latest --label service=\"app\" . && docker push dhh/app:123",
builder.push.join(" ")
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 -t dhh/app:latest --label service=\"app\" .",
builder.push.join(" ")
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 -t dhh/app:latest --label service=\"app\" .",
builder.push.join(" ")
end
test "build args" do
builder = new_builder_command(builder: { "args" => { "a" => 1, "b" => 2 } })
assert_equal \
"-t dhh/app:123 -t dhh/app:latest --label service=\"app\" --build-arg a=\"1\" --build-arg b=\"2\"",
builder.target.build_options.join(" ")
end
test "build secrets" do
builder = new_builder_command(builder: { "secrets" => ["token_a", "token_b"] })
assert_equal \
"-t dhh/app:123 -t dhh/app:latest --label service=\"app\" --secret id=\"token_a\" --secret id=\"token_b\"",
builder.target.build_options.join(" ")
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 dhh/app:123 -t dhh/app:latest --label service=\"app\" --build-arg a=\"1\" --build-arg b=\"2\" . && docker push dhh/app:123",
builder.push.join(" ")
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 -t dhh/app:latest --label service=\"app\" --build-arg a=\"1\" --build-arg b=\"2\" .",
builder.push.join(" ")
end
test "native push with with build secrets" do
builder = new_builder_command(builder: { "multiarch" => false, "secrets" => [ "a", "b" ] })
assert_equal \
"docker build -t dhh/app:123 -t dhh/app:latest --label service=\"app\" --secret id=\"a\" --secret id=\"b\" . && docker push dhh/app:123",
builder.push.join(" ")
end
private
def new_builder_command(additional_config = {})
Mrsk::Commands::Builder.new(Mrsk::Configuration.new(@config.merge(additional_config), version: "123"))
end
end

View File

@@ -0,0 +1,55 @@
require "test_helper"
class CommandsHealthcheckTest < 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 --detach --name healthcheck-app-123 --publish 3999:3000 --label service=healthcheck-app dhh/app:123",
new_command.run.join(" ")
end
test "run with custom port" do
@config[:healthcheck] = { "port" => 3001 }
assert_equal \
"docker run --detach --name healthcheck-app-123 --publish 3999:3001 --label service=healthcheck-app dhh/app:123",
new_command.run.join(" ")
end
test "curl" do
assert_equal \
"curl --silent --output /dev/null --write-out '%{http_code}' --max-time 2 http://localhost:3999/up",
new_command.curl.join(" ")
end
test "curl with custom path" do
@config[:healthcheck] = { "path" => "/healthz" }
assert_equal \
"curl --silent --output /dev/null --write-out '%{http_code}' --max-time 2 http://localhost:3999/healthz",
new_command.curl.join(" ")
end
test "stop" do
assert_equal \
"docker container ls --all --filter name=healthcheck-app --quiet | xargs docker stop",
new_command.stop.join(" ")
end
test "remove" do
assert_equal \
"docker container ls --all --filter name=healthcheck-app --quiet | xargs docker container rm",
new_command.remove.join(" ")
end
private
def new_command
Mrsk::Commands::Healthcheck.new(Mrsk::Configuration.new(@config, version: "123"))
end
end

View File

@@ -0,0 +1,27 @@
require "test_helper"
class CommandsPruneTest < 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 "images" do
assert_equal \
"docker image prune --all --force --filter label=service=app --filter until=168h",
new_command.images.join(" ")
end
test "containers" do
assert_equal \
"docker container prune --force --filter label=service=app --filter until=72h",
new_command.containers.join(" ")
end
private
def new_command
Mrsk::Commands::Prune.new(Mrsk::Configuration.new(@config, version: "123"))
end
end

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

@@ -0,0 +1,38 @@
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.join(" ")
end
test "registry login with ENV password" do
ENV["MRSK_REGISTRY_PASSWORD"] = "more-secret"
@config[:registry]["password"] = [ "MRSK_REGISTRY_PASSWORD" ]
assert_equal \
"docker login hub.docker.com -u dhh -p more-secret",
@registry.login.join(" ")
ensure
ENV.delete("MRSK_REGISTRY_PASSWORD")
end
test "registry logout" do
assert_equal \
"docker logout hub.docker.com",
@registry.logout.join(" ")
end
end

View File

@@ -0,0 +1,87 @@
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 --detach --restart unless-stopped --log-opt max-size=10m --publish 80:80 --volume /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.join(" ")
end
test "traefik start" do
assert_equal \
"docker container start traefik",
new_command.start.join(" ")
end
test "traefik stop" do
assert_equal \
"docker container stop traefik",
new_command.stop.join(" ")
end
test "traefik info" do
assert_equal \
"docker ps --filter name=traefik",
new_command.info.join(" ")
end
test "traefik logs" do
assert_equal \
"docker logs traefik --timestamps 2>&1",
new_command.logs.join(" ")
end
test "traefik logs since 2h" do
assert_equal \
"docker logs traefik --since 2h --timestamps 2>&1",
new_command.logs(since: '2h').join(" ")
end
test "traefik logs last 10 lines" do
assert_equal \
"docker logs traefik --tail 10 --timestamps 2>&1",
new_command.logs(lines: 10).join(" ")
end
test "traefik logs with grep hello!" do
assert_equal \
"docker logs traefik --timestamps 2>&1 | grep 'hello!'",
new_command.logs(grep: 'hello!').join(" ")
end
test "traefik remove container" do
assert_equal \
"docker container prune --force --filter label=org.opencontainers.image.title=Traefik",
new_command.remove_container.join(" ")
end
test "traefik remove image" do
assert_equal \
"docker image prune --all --force --filter label=org.opencontainers.image.title=Traefik",
new_command.remove_image.join(" ")
end
test "traefik follow logs" do
assert_equal \
"ssh -t root@1.1.1.1 'docker logs traefik --timestamps --tail 10 --follow 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 --timestamps --tail 10 --follow 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"] = "secret&\"123"
assert_equal ["-e", "REDIS_PASSWORD=\"secret456\"", "-e", "DB_PASSWORD=\"secret&\\\"123\"", "-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"], :healthcheck=>{"path"=>"/up", "port"=>3000 }}, @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