hamal 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.rubocop.yml +157 -0
- data/README.md +344 -0
- data/Rakefile +8 -0
- data/exe/hamal +5 -0
- data/lib/hamal.rb +280 -0
- metadata +62 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: 56130bfb0f299aed7a21f84abc68ffb3a980c31cbe136158198456d9be7483c1
|
4
|
+
data.tar.gz: baca257ab1ed431884d57d4d909eb11a8a47dbacdd06a0095c2999594cf29e18
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 1434a5cfab400a3ed32271cf516ce83e2663dce98470e6ed48b08d5d6875ceeabb7c03b2d4527a06c6aa4b93530f0e5b1ea7670fe42d81531d896a9676644857
|
7
|
+
data.tar.gz: e430a46b3a1dacd99fcd191c71095977ac889752b2de0c4fdb16ec22284d925016a0a9abd44e4bfa11d69204993d1b3ced27818073da4e37a9b8a7938a6be194
|
data/.rubocop.yml
ADDED
@@ -0,0 +1,157 @@
|
|
1
|
+
AllCops:
|
2
|
+
SuggestExtensions: false
|
3
|
+
NewCops: enable
|
4
|
+
|
5
|
+
# I can set other things than accessors.
|
6
|
+
Naming/AccessorMethodName:
|
7
|
+
Enabled: false
|
8
|
+
|
9
|
+
# Don't complain on missing documentation for every class.
|
10
|
+
Style/Documentation:
|
11
|
+
Enabled: false
|
12
|
+
|
13
|
+
# No frozen string literals
|
14
|
+
Style/FrozenStringLiteralComment:
|
15
|
+
EnforcedStyle: never
|
16
|
+
|
17
|
+
# Let me write them typographic (–) dashes in comments.
|
18
|
+
Style/AsciiComments:
|
19
|
+
Enabled: false
|
20
|
+
|
21
|
+
# Don't enforce Kernel#lambda.
|
22
|
+
Style/Lambda:
|
23
|
+
Enabled: false
|
24
|
+
|
25
|
+
# Since module_function is a visibility modifier, you can't have private
|
26
|
+
# singleton methods. E.g. in some cases, we _do_ need to use extend self.
|
27
|
+
Style/ModuleFunction:
|
28
|
+
Enabled: false
|
29
|
+
|
30
|
+
# I like them and am gonna use them in multiline blocks. A nice way to enable
|
31
|
+
# their usage by disabling their cop.
|
32
|
+
Style/NumberedParameters:
|
33
|
+
Enabled: false
|
34
|
+
|
35
|
+
# Let me use and/or precedence in conditions, please!
|
36
|
+
Style/AndOr:
|
37
|
+
Enabled: false
|
38
|
+
|
39
|
+
# I think this results in uglier code, depending on the situation.
|
40
|
+
#
|
41
|
+
# Example:
|
42
|
+
#
|
43
|
+
# if old_password or password
|
44
|
+
# change_password(old_password, password)
|
45
|
+
# end
|
46
|
+
#
|
47
|
+
# Versus:
|
48
|
+
#
|
49
|
+
# change_password(old_password, password) if old_password or password
|
50
|
+
#
|
51
|
+
# It depends, but if I have a complex condition or a longer line, I prefer the
|
52
|
+
# more explicit if condition.
|
53
|
+
Style/IfUnlessModifier:
|
54
|
+
Enabled: false
|
55
|
+
|
56
|
+
# Recently, I tend to prefer the named boolean operators. Yeah, they do have
|
57
|
+
# different precedence, but still.
|
58
|
+
Style/Not:
|
59
|
+
Enabled: false
|
60
|
+
|
61
|
+
# I don't think this results in better code. We're flatting it out, while it is
|
62
|
+
# really nested. Why hide that?
|
63
|
+
Style/GuardClause:
|
64
|
+
Enabled: false
|
65
|
+
|
66
|
+
Style/StabbyLambdaParentheses:
|
67
|
+
Enabled: false
|
68
|
+
|
69
|
+
# Some of our admin code is generated by administrate. I don't wanna change
|
70
|
+
# this autogenerated code.
|
71
|
+
Style/TrailingCommaInHashLiteral:
|
72
|
+
Enabled: false
|
73
|
+
|
74
|
+
# Some of our admin code is generated by administrate. I don't wanna change
|
75
|
+
# this autogenerated code.
|
76
|
+
Style/TrailingCommaInArguments:
|
77
|
+
Enabled: false
|
78
|
+
|
79
|
+
# Some of our admin code is generated by administrate. I don't wanna change
|
80
|
+
# this autogenerated code.
|
81
|
+
Style/SymbolArray:
|
82
|
+
Enabled: false
|
83
|
+
|
84
|
+
# Prefer double quotes because at this time I like them quite better.
|
85
|
+
Style/StringLiterals:
|
86
|
+
EnforcedStyle: double_quotes
|
87
|
+
|
88
|
+
Style/AccessModifierDeclarations:
|
89
|
+
Enabled: false
|
90
|
+
|
91
|
+
Style/ClassAndModuleChildren:
|
92
|
+
Enabled: false
|
93
|
+
|
94
|
+
# Dogfood the no-parens love.
|
95
|
+
Style/MethodCallWithArgsParentheses:
|
96
|
+
Enabled: true
|
97
|
+
EnforcedStyle: omit_parentheses
|
98
|
+
AllowParenthesesInMultilineCall: true
|
99
|
+
AllowParenthesesInChaining: true
|
100
|
+
AllowParenthesesInCamelCaseMethod: true
|
101
|
+
|
102
|
+
Style/MutableConstant:
|
103
|
+
Enabled: false
|
104
|
+
|
105
|
+
# I like the value omission hash syntax.
|
106
|
+
Style/HashSyntax:
|
107
|
+
EnforcedShorthandSyntax: always
|
108
|
+
|
109
|
+
# This is an application, not a library. We don't need to go that far.
|
110
|
+
Style/DocumentDynamicEvalDefinition:
|
111
|
+
Enabled: false
|
112
|
+
|
113
|
+
# I do that a lot. Think it's okay.
|
114
|
+
Lint/AssignmentInCondition:
|
115
|
+
Enabled: false
|
116
|
+
|
117
|
+
# Let's not enforce arbitrary metrics.
|
118
|
+
Metrics/MethodLength:
|
119
|
+
Enabled: false
|
120
|
+
|
121
|
+
Metrics/ClassLength:
|
122
|
+
Enabled: false
|
123
|
+
|
124
|
+
Metrics/BlockLength:
|
125
|
+
Enabled: false
|
126
|
+
|
127
|
+
Metrics/AbcSize:
|
128
|
+
Enabled: false
|
129
|
+
|
130
|
+
Metrics/ParameterLists:
|
131
|
+
Enabled: false
|
132
|
+
|
133
|
+
Metrics/PerceivedComplexity:
|
134
|
+
Enabled: false
|
135
|
+
|
136
|
+
Metrics/CyclomaticComplexity:
|
137
|
+
Enabled: false
|
138
|
+
|
139
|
+
# Sometimes life leaves you no choice. True story.
|
140
|
+
Lint/SuppressedException:
|
141
|
+
Enabled: false
|
142
|
+
|
143
|
+
Layout/DefEndAlignment:
|
144
|
+
EnforcedStyleAlignWith: start_of_line
|
145
|
+
|
146
|
+
# I think it's safe to ignore the 80 chars limit.
|
147
|
+
Layout/LineLength:
|
148
|
+
Enabled: false
|
149
|
+
|
150
|
+
Layout/LineContinuationLeadingSpace:
|
151
|
+
Enabled: false
|
152
|
+
|
153
|
+
Layout/MultilineMethodCallIndentation:
|
154
|
+
EnforcedStyle: indented
|
155
|
+
|
156
|
+
Layout/SpaceInLambdaLiteral:
|
157
|
+
Enabled: false
|
data/README.md
ADDED
@@ -0,0 +1,344 @@
|
|
1
|
+
Hamal is a simple deploy tool for self-hosted Rails application. Learn how it
|
2
|
+
works, how to configure it, and how to provision new servers.
|
3
|
+
|
4
|
+
Not to be confused with Kamal. 😉
|
5
|
+
|
6
|
+
**PLACEHOLDERS**: Some commands and configuration snippets described in this
|
7
|
+
configuration are app-specific, i.e. their exact contents will vary from app to
|
8
|
+
app. In order to make this documentation generic, the `#{app_name}` and
|
9
|
+
`#{app_domain}` placeholders are used in such places. Replace the placeholders
|
10
|
+
with the respective values in `config/deploy.yml` before executing the commands
|
11
|
+
/ copying the configuration.
|
12
|
+
|
13
|
+
# Overview
|
14
|
+
|
15
|
+
`hamal` implements a simple deploy process for a self-hosted app on a server
|
16
|
+
that you administer. It:
|
17
|
+
|
18
|
+
1. Connects to the server via SSH. All subsequent stages happen on the server.
|
19
|
+
2. Fetches the code that will be deployed from GitHub.
|
20
|
+
3. Builds a Docker image containing the app's code.
|
21
|
+
4. Uses that image to run the app in a container.
|
22
|
+
5. Configures nginx to expose the app container to the Internet.
|
23
|
+
|
24
|
+
## Prerequisites
|
25
|
+
|
26
|
+
To deploy the app to a server, you will need:
|
27
|
+
|
28
|
+
- A bare-bones Ubuntu 22.04 server.
|
29
|
+
- SSH access as `root` to that server.
|
30
|
+
|
31
|
+
# Provision
|
32
|
+
|
33
|
+
When you want to deploy the app on a new server, prepare it for service first
|
34
|
+
by following the steps in this section.
|
35
|
+
|
36
|
+
## System settings (on the server)
|
37
|
+
|
38
|
+
### Update packages
|
39
|
+
|
40
|
+
Install latest updates. You'd likely want to do this periodically.
|
41
|
+
|
42
|
+
```
|
43
|
+
apt update
|
44
|
+
apt upgrade
|
45
|
+
```
|
46
|
+
|
47
|
+
Note: If provisioning an ARM64 Hetzner server, make sure the mirrors in
|
48
|
+
`/etc/apt/sources.list` are using `http://mirror.hetzner.com/ubuntu-ports/packages/`
|
49
|
+
URLs instead of `http://mirror.hetzner.com/ubuntu/packages/`
|
50
|
+
(see https://status.hetzner.com/incident/43b5f083-cb30-4c01-b904-b611206eb172).
|
51
|
+
|
52
|
+
### Tighten SSH config
|
53
|
+
|
54
|
+
In `/etc/ssh/sshd_config`:
|
55
|
+
|
56
|
+
- Set `PasswordAuthentication` to `no`
|
57
|
+
- Comment out the `Subsystem sftp` line
|
58
|
+
|
59
|
+
### Restart for changes to take effect
|
60
|
+
|
61
|
+
```
|
62
|
+
reboot
|
63
|
+
```
|
64
|
+
|
65
|
+
## Docker
|
66
|
+
|
67
|
+
Follow the [official docs](https://docs.docker.com/engine/install/ubuntu/).
|
68
|
+
The following should just work:
|
69
|
+
|
70
|
+
```
|
71
|
+
curl -fsSL https://get.docker.com | sh
|
72
|
+
```
|
73
|
+
|
74
|
+
## nginx
|
75
|
+
|
76
|
+
### Install
|
77
|
+
|
78
|
+
Follow the [official docs](https://docs.nginx.com/nginx/admin-guide/installing-nginx/installing-nginx-open-source/#installing-prebuilt-ubuntu-packages).
|
79
|
+
In short:
|
80
|
+
|
81
|
+
```
|
82
|
+
apt install curl gnupg2 ca-certificates lsb-release ubuntu-keyring
|
83
|
+
curl https://nginx.org/keys/nginx_signing.key | gpg --dearmor | tee /usr/share/keyrings/nginx-archive-keyring.gpg >/dev/null
|
84
|
+
# Verify keyring (see official docs for that)
|
85
|
+
echo "deb [signed-by=/usr/share/keyrings/nginx-archive-keyring.gpg] http://nginx.org/packages/ubuntu `lsb_release -cs` nginx" | tee /etc/apt/sources.list.d/nginx.list
|
86
|
+
echo -e "Package: *\nPin: origin nginx.org\nPin: release o=nginx\nPin-Priority: 900\n" | tee /etc/apt/preferences.d/99nginx
|
87
|
+
apt update
|
88
|
+
apt install nginx
|
89
|
+
systemctl start nginx
|
90
|
+
```
|
91
|
+
|
92
|
+
### Configure (part 1, before we have an SSL certificate)
|
93
|
+
|
94
|
+
We need this first incomplete part of the nginx configuration so that we can
|
95
|
+
issue an SSL certificate.
|
96
|
+
|
97
|
+
Create the directory that will serve static content for the SSL verification
|
98
|
+
process:
|
99
|
+
|
100
|
+
```
|
101
|
+
mkdir /usr/share/nginx/cert_validations
|
102
|
+
```
|
103
|
+
|
104
|
+
Replace the contents of `/etc/nginx/nginx.conf` with the following:
|
105
|
+
|
106
|
+
```
|
107
|
+
user nginx;
|
108
|
+
worker_processes auto;
|
109
|
+
|
110
|
+
error_log /var/log/nginx/error.log notice;
|
111
|
+
pid /var/run/nginx.pid;
|
112
|
+
|
113
|
+
events {
|
114
|
+
worker_connections 1024;
|
115
|
+
}
|
116
|
+
|
117
|
+
http {
|
118
|
+
include /etc/nginx/mime.types;
|
119
|
+
default_type application/octet-stream;
|
120
|
+
|
121
|
+
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
|
122
|
+
'$status $body_bytes_sent "$http_referer" '
|
123
|
+
'"$http_user_agent" "$http_x_forwarded_for"';
|
124
|
+
access_log /var/log/nginx/access.log main;
|
125
|
+
|
126
|
+
sendfile on;
|
127
|
+
keepalive_timeout 65;
|
128
|
+
|
129
|
+
ssl_session_cache shared:SSL:10m;
|
130
|
+
ssl_session_timeout 10m;
|
131
|
+
|
132
|
+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
133
|
+
proxy_set_header X-Forwarded-Proto $scheme;
|
134
|
+
proxy_set_header Host $http_host;
|
135
|
+
|
136
|
+
server {
|
137
|
+
listen 80;
|
138
|
+
|
139
|
+
location /.well-known/acme-challenge/ {
|
140
|
+
root /usr/share/nginx/cert_validations;
|
141
|
+
}
|
142
|
+
}
|
143
|
+
}
|
144
|
+
```
|
145
|
+
|
146
|
+
Apply the changes:
|
147
|
+
|
148
|
+
```
|
149
|
+
nginx -s reload
|
150
|
+
```
|
151
|
+
|
152
|
+
### Issue SSL certificate
|
153
|
+
|
154
|
+
Install certbot:
|
155
|
+
|
156
|
+
```
|
157
|
+
apt install snapd
|
158
|
+
snap install core
|
159
|
+
snap refresh core
|
160
|
+
snap install --classic certbot
|
161
|
+
```
|
162
|
+
|
163
|
+
Issue a certificate:
|
164
|
+
|
165
|
+
```
|
166
|
+
certbot certonly -m genadi@hey.com --webroot -w /usr/share/nginx/cert_validations -d #{app_domain}
|
167
|
+
```
|
168
|
+
|
169
|
+
Let's Encrypt certificates are only valid for 90 days and need to be renewed
|
170
|
+
regularly. There's no need to manually create a cron, though, the certbot snap
|
171
|
+
installation has already taken care of this by registering a
|
172
|
+
`snap.certbot.renew.timer` systemd timer (check `systemctl list-timers`).
|
173
|
+
|
174
|
+
Test that the renewal process is properly set up:
|
175
|
+
|
176
|
+
```
|
177
|
+
certbot renew --dry-run
|
178
|
+
```
|
179
|
+
|
180
|
+
You should see a success message for the certificate we just issued.
|
181
|
+
|
182
|
+
### Configure (part 2, after we have an SSL certificate)
|
183
|
+
|
184
|
+
Create `/etc/nginx/#{app_name}.conf.template` with the following contents:
|
185
|
+
|
186
|
+
```
|
187
|
+
server {
|
188
|
+
listen 443 ssl;
|
189
|
+
server_name #{app_domain};
|
190
|
+
|
191
|
+
ssl_certificate /etc/letsencrypt/live/#{app_domain}/fullchain.pem;
|
192
|
+
ssl_certificate_key /etc/letsencrypt/live/#{app_domain}/privkey.pem;
|
193
|
+
|
194
|
+
location / {
|
195
|
+
proxy_pass http://localhost:$ACTIVE_RAILS_PORT;
|
196
|
+
}
|
197
|
+
}
|
198
|
+
```
|
199
|
+
|
200
|
+
Create a temporary dummy `#{app_name}.conf` file. This will get overwritten by
|
201
|
+
the actual deploy process, but we need it for now to bootstrap nginx with a
|
202
|
+
valid config:
|
203
|
+
|
204
|
+
```
|
205
|
+
ACTIVE_RAILS_PORT=80 envsubst < /etc/nginx/#{app_name}.conf.template > /etc/nginx/#{app_name}.conf
|
206
|
+
```
|
207
|
+
|
208
|
+
Add the following include line at the end of the `http` block in `/etc/nginx/nginx.conf`:
|
209
|
+
|
210
|
+
```
|
211
|
+
http {
|
212
|
+
...
|
213
|
+
include /etc/nginx/#{app_name}.conf;
|
214
|
+
}
|
215
|
+
```
|
216
|
+
|
217
|
+
Apply the changes:
|
218
|
+
|
219
|
+
```
|
220
|
+
nginx -s reload
|
221
|
+
```
|
222
|
+
|
223
|
+
## Deploy user and directories
|
224
|
+
|
225
|
+
- Create app user (with the same UID as the user created in the Dockerfile)
|
226
|
+
|
227
|
+
```
|
228
|
+
useradd rails --uid 1001 --create-home --shell /bin/bash
|
229
|
+
```
|
230
|
+
|
231
|
+
- Create directories
|
232
|
+
|
233
|
+
```
|
234
|
+
mkdir -p /var/lib/#{app_name}/db
|
235
|
+
mkdir -p /var/lib/#{app_name}/storage
|
236
|
+
mkdir -p /var/lib/#{app_name}/src
|
237
|
+
chown rails:rails /var/lib/#{app_name}/db /var/lib/#{app_name}/storage
|
238
|
+
```
|
239
|
+
|
240
|
+
## Secrets
|
241
|
+
|
242
|
+
Store `RAILS_MASTER_KEY` on the server:
|
243
|
+
|
244
|
+
```
|
245
|
+
echo RAILS_MASTER_KEY=<actual_secret> > /var/lib/#{app_name}/env_file
|
246
|
+
```
|
247
|
+
|
248
|
+
## Database
|
249
|
+
|
250
|
+
If this is an existing app, restore its database to `/var/lib/#{app_name}/db`.
|
251
|
+
Make sure its owner user and group are `rails:rails`.
|
252
|
+
|
253
|
+
If this is a new app, create its database by running `bin/rails db:create` in
|
254
|
+
out of its images. You will likely have to do this at a later point, when you
|
255
|
+
do have such an image. Examine `bin/hamal` to determine what arguments to
|
256
|
+
`docker run` are needed, e.g. to set ENV variables and mount host directories.
|
257
|
+
The final commands you're looking for will look something like this:
|
258
|
+
|
259
|
+
```
|
260
|
+
docker run --rm <args inferred from bin/hamal> --entrypoint '/rails/bin/rails' <app_image> -- db:create
|
261
|
+
docker run --rm <args inferred from bin/hamal> --entrypoint '/rails/bin/rails' <app_image> -- db:schema:load
|
262
|
+
```
|
263
|
+
|
264
|
+
## GitHub
|
265
|
+
|
266
|
+
Create and add a deploy key to grant the server read-only access to this
|
267
|
+
repository. Follow the [official docs](https://docs.github.com/en/authentication/connecting-to-github-with-ssh/managing-deploy-keys#deploy-keys).
|
268
|
+
In short:
|
269
|
+
|
270
|
+
1. Create a new SSH key for the root user on the server
|
271
|
+
|
272
|
+
```
|
273
|
+
ssh-keygen -t ed25519 -C "Hetzner_<serverIP>" -f ~/.ssh/github_deploy
|
274
|
+
```
|
275
|
+
|
276
|
+
Leave the passphrase empty.
|
277
|
+
|
278
|
+
2. Add the key to GitHub
|
279
|
+
|
280
|
+
Open repository in GitHub, in "Settings" -> "Deploy keys" press "Add deploy
|
281
|
+
key". Enter a title (e.g. the `Hetzner_<serverIP>` comment), the public key
|
282
|
+
you just created (i.e. the contents of `.ssh/github_deploy.pub`), and press
|
283
|
+
"Add key".
|
284
|
+
|
285
|
+
3. Configure SSH on the server to use this key when connecting to GitHub
|
286
|
+
|
287
|
+
Create `~/.ssh/config` with the following contents:
|
288
|
+
|
289
|
+
```
|
290
|
+
Host github.com
|
291
|
+
IdentityFile ~/.ssh/github_deploy
|
292
|
+
```
|
293
|
+
|
294
|
+
# Configuration
|
295
|
+
|
296
|
+
The deploy script expects certain configuration in `config/deploy.yml`:
|
297
|
+
|
298
|
+
- `github_repo`: The repo where the app's source is located, in the form
|
299
|
+
`<username>/<repo_name>`.
|
300
|
+
- `app_name`: Used as part of directory and Docker image names, so must be a
|
301
|
+
valid identifier: only letters, numbers, and underscores.
|
302
|
+
- `app_domain`: The hostname that this app will be accessible at.
|
303
|
+
- `server`: The IP address of a provisioned server.
|
304
|
+
- `local_ports`: An array of at least two ports that will be used by the run
|
305
|
+
the app locally on the server. These ports will not be exposed to the
|
306
|
+
Internet. If you're using the server to host multiple apps using this
|
307
|
+
script, make sure that all apps are configured with unique ports so that
|
308
|
+
they do not conflict with each other.
|
309
|
+
|
310
|
+
# Usage
|
311
|
+
|
312
|
+
## Installation
|
313
|
+
|
314
|
+
Install the `hamal` gem globally or put it in your app's `Gemfile`:
|
315
|
+
|
316
|
+
```ruby
|
317
|
+
gem "hamal"
|
318
|
+
```
|
319
|
+
|
320
|
+
## Deploy
|
321
|
+
|
322
|
+
Pass the commit you want deployed to `hamal deploy`:
|
323
|
+
|
324
|
+
```
|
325
|
+
hamal deploy b04c0b567
|
326
|
+
```
|
327
|
+
|
328
|
+
Omitting the commit will deploy the latest commit on the current branch. The
|
329
|
+
commit must have been pushed to the git repo. The deploy script does not deploy
|
330
|
+
local commits.
|
331
|
+
|
332
|
+
## --help
|
333
|
+
|
334
|
+
For more commands, run `hamal --help`:
|
335
|
+
|
336
|
+
```
|
337
|
+
Usage: bin/hamal [command]
|
338
|
+
|
339
|
+
Commands:
|
340
|
+
deploy - Deploy the app to the server
|
341
|
+
console - Run Rails console in the deployed container
|
342
|
+
logs - Follow logs of the deployed container
|
343
|
+
sudo - SSH into the server as administrator
|
344
|
+
```
|
data/Rakefile
ADDED
data/exe/hamal
ADDED
data/lib/hamal.rb
ADDED
@@ -0,0 +1,280 @@
|
|
1
|
+
require "English"
|
2
|
+
require "json"
|
3
|
+
require "yaml"
|
4
|
+
|
5
|
+
module Hamal
|
6
|
+
VERSION = "0.1.0"
|
7
|
+
|
8
|
+
module Config
|
9
|
+
def config_file = "config/deploy.yml"
|
10
|
+
def deployed_revision = ARGV.first.then { _1 unless _1.to_s.start_with? "-" } || `git rev-parse HEAD`.strip
|
11
|
+
def deployed_image = "#{app_name}:#{deployed_revision}"
|
12
|
+
def deploy_config = @deploy_config ||= YAML.safe_load_file(config_file)
|
13
|
+
def deploy_env = "production"
|
14
|
+
def app_name = deploy_config.fetch "app_name"
|
15
|
+
def app_repo = deploy_config.fetch "github_repo"
|
16
|
+
def app_local_ports = deploy_config.fetch("local_ports").map(&:to_s)
|
17
|
+
def server = deploy_config.fetch "server"
|
18
|
+
def project_root = "/var/lib/#{app_name}"
|
19
|
+
end
|
20
|
+
|
21
|
+
module Helpers
|
22
|
+
include Config
|
23
|
+
|
24
|
+
def on_server(user: :root, dir: nil, &) = RemoteExecutor.new(user, dir).instance_exec(&)
|
25
|
+
|
26
|
+
def log(message)
|
27
|
+
bold = "\e[1m"
|
28
|
+
green = "\e[32m"
|
29
|
+
clear = "\e[0m"
|
30
|
+
|
31
|
+
message = "#{bold}#{green}#{message}#{clear}" if $stdout.tty?
|
32
|
+
puts message
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
class RemoteExecutor
|
37
|
+
include Helpers
|
38
|
+
|
39
|
+
ExecResult = Struct.new :output, :exit_code do
|
40
|
+
def success? = exit_code.zero?
|
41
|
+
end
|
42
|
+
|
43
|
+
def initialize(remote_user, remote_dir)
|
44
|
+
@remote_user = remote_user
|
45
|
+
@remote_dir = remote_dir
|
46
|
+
|
47
|
+
raise "Invalid remote user #{@remote_user}" unless [:root, :rails].include? @remote_user
|
48
|
+
end
|
49
|
+
|
50
|
+
def sh(command, interactive: false, abort_on_error: false)
|
51
|
+
dir_override = "cd #{@remote_dir};" if @remote_dir
|
52
|
+
user_override = "runuser -u rails" if @remote_user == :rails
|
53
|
+
ssh "#{dir_override} #{user_override} #{command}", interactive:, abort_on_error:
|
54
|
+
end
|
55
|
+
|
56
|
+
def sh!(command, interactive: false) = sh command, interactive:, abort_on_error: true
|
57
|
+
|
58
|
+
def ssh(remote_command, abort_on_error:, interactive: false)
|
59
|
+
remote_command = remote_command.gsub "'", %q('"'"')
|
60
|
+
|
61
|
+
output =
|
62
|
+
if interactive
|
63
|
+
spawn "ssh -tt root@#{server} '#{remote_command}'", out: $stdout, err: $stderr, in: $stdin
|
64
|
+
Process.wait
|
65
|
+
nil
|
66
|
+
else
|
67
|
+
`ssh root@#{server} '#{remote_command}'`.strip
|
68
|
+
end
|
69
|
+
|
70
|
+
abort "Failed to execute `#{remote_command}` on `#{server}`" if abort_on_error && !$CHILD_STATUS.success?
|
71
|
+
|
72
|
+
ExecResult.new output:, exit_code: $CHILD_STATUS.exitstatus
|
73
|
+
end
|
74
|
+
end
|
75
|
+
|
76
|
+
module Stages
|
77
|
+
include Helpers
|
78
|
+
|
79
|
+
def build_new_image
|
80
|
+
image_exists = on_server { sh "docker image inspect #{deployed_image}" }.success?
|
81
|
+
if image_exists
|
82
|
+
log "Using existing image #{deployed_image} for deploy"
|
83
|
+
return
|
84
|
+
end
|
85
|
+
|
86
|
+
log "Building new image #{deployed_image} for deploy"
|
87
|
+
|
88
|
+
source_dir = "#{project_root}/src/#{deployed_revision}"
|
89
|
+
|
90
|
+
on_server do
|
91
|
+
log " Checking out source at revision #{deployed_revision}..."
|
92
|
+
sh! "rm -rf #{source_dir}"
|
93
|
+
sh! "git clone git@github.com:#{app_repo}.git #{source_dir}"
|
94
|
+
end
|
95
|
+
on_server dir: source_dir do
|
96
|
+
sh! "git checkout #{deployed_revision}"
|
97
|
+
|
98
|
+
log " Building image..."
|
99
|
+
sh! "docker build -t #{deployed_image} ."
|
100
|
+
|
101
|
+
log " Cleaning up source dir..."
|
102
|
+
sh! "rm -rf #{source_dir}"
|
103
|
+
end
|
104
|
+
end
|
105
|
+
|
106
|
+
def run_deploy_tasks
|
107
|
+
log "Running migrations"
|
108
|
+
|
109
|
+
on_server do
|
110
|
+
sh! "docker run --rm " \
|
111
|
+
"--label app=#{app_name} " \
|
112
|
+
"--env-file #{project_root}/env_file " \
|
113
|
+
"-e GIT_REVISION=#{deployed_revision} " \
|
114
|
+
"-v #{project_root}/db:/rails/db/#{deploy_env} " \
|
115
|
+
"-v #{project_root}/storage:/rails/storage " \
|
116
|
+
"--entrypoint '/rails/bin/rails' " \
|
117
|
+
"#{deployed_image} " \
|
118
|
+
"-- db:migrate"
|
119
|
+
end
|
120
|
+
end
|
121
|
+
|
122
|
+
def start_new_container
|
123
|
+
log "Starting container for new version"
|
124
|
+
|
125
|
+
# Determine which ports are currently bound and which are free for the new container
|
126
|
+
running_containers = on_server { sh! "docker ps -q --filter label=app=#{app_name}" }.output.split
|
127
|
+
bound_ports =
|
128
|
+
running_containers.map do |container|
|
129
|
+
port_settings = on_server { sh! "docker inspect --format '{{json .NetworkSettings.Ports}}' #{container}" }.output
|
130
|
+
port_settings = JSON.parse port_settings
|
131
|
+
(port_settings["3000/tcp"] || []).map { _1["HostPort"] }.compact
|
132
|
+
end.flatten
|
133
|
+
|
134
|
+
available_port = (app_local_ports - bound_ports).first
|
135
|
+
abort "No TCP port available" unless available_port
|
136
|
+
|
137
|
+
log " Using port #{available_port} for new container"
|
138
|
+
on_server do
|
139
|
+
sh! "docker run -d --rm " \
|
140
|
+
"--label app=#{app_name} " \
|
141
|
+
"--env-file #{project_root}/env_file " \
|
142
|
+
"-e GIT_REVISION=#{deployed_revision} " \
|
143
|
+
"-v #{project_root}/db:/rails/db/#{deploy_env} " \
|
144
|
+
"-v #{project_root}/storage:/rails/storage " \
|
145
|
+
"-p 127.0.0.1:#{available_port}:3000 " \
|
146
|
+
"#{deployed_image}"
|
147
|
+
end
|
148
|
+
|
149
|
+
[available_port, running_containers]
|
150
|
+
end
|
151
|
+
|
152
|
+
def switch_traffic(new_container_port)
|
153
|
+
log "Switching traffic to new version"
|
154
|
+
|
155
|
+
log " Waiting for new version to become ready"
|
156
|
+
health_checks = 1
|
157
|
+
loop do
|
158
|
+
new_container_ready = on_server { sh "curl -fs http://localhost:#{new_container_port}/healthz" }.success?
|
159
|
+
break if new_container_ready
|
160
|
+
|
161
|
+
abort "New container failed to start within 30 seconds, investigate!" if health_checks > 30
|
162
|
+
|
163
|
+
health_checks += 1
|
164
|
+
sleep 1
|
165
|
+
end
|
166
|
+
|
167
|
+
log " Redirecting nginx to new version"
|
168
|
+
on_server do
|
169
|
+
sh! "ACTIVE_RAILS_PORT=#{new_container_port} envsubst < /etc/nginx/#{app_name}.conf.template > /etc/nginx/#{app_name}.conf"
|
170
|
+
sh! "nginx -s reload"
|
171
|
+
end
|
172
|
+
end
|
173
|
+
|
174
|
+
def stop_old_container(old_containers)
|
175
|
+
log "Stopping old container"
|
176
|
+
|
177
|
+
if old_containers.empty?
|
178
|
+
log " (none found)"
|
179
|
+
return
|
180
|
+
end
|
181
|
+
|
182
|
+
on_server do
|
183
|
+
sh! "docker kill -s SIGTERM #{old_containers.join ' '}"
|
184
|
+
end
|
185
|
+
end
|
186
|
+
|
187
|
+
def clean_up
|
188
|
+
log "Cleaning up"
|
189
|
+
|
190
|
+
log " Removing unused docker objects"
|
191
|
+
on_server do
|
192
|
+
sh! 'docker system prune --all --force --filter "until=24h"'
|
193
|
+
end
|
194
|
+
end
|
195
|
+
end
|
196
|
+
|
197
|
+
module Commands
|
198
|
+
extend self
|
199
|
+
|
200
|
+
include Stages
|
201
|
+
|
202
|
+
def execute
|
203
|
+
abort "Configure server in deploy config file" unless server
|
204
|
+
|
205
|
+
case ARGV.shift
|
206
|
+
when "deploy"
|
207
|
+
deploy_command
|
208
|
+
when "console"
|
209
|
+
console_command
|
210
|
+
when "logs"
|
211
|
+
logs_command
|
212
|
+
when "sudo"
|
213
|
+
sudo_command
|
214
|
+
else
|
215
|
+
help_command
|
216
|
+
end
|
217
|
+
end
|
218
|
+
|
219
|
+
private
|
220
|
+
|
221
|
+
def deploy_command
|
222
|
+
build_new_image
|
223
|
+
run_deploy_tasks
|
224
|
+
new_container_port, old_containers = start_new_container
|
225
|
+
switch_traffic new_container_port
|
226
|
+
stop_old_container old_containers
|
227
|
+
clean_up
|
228
|
+
end
|
229
|
+
|
230
|
+
def console_command
|
231
|
+
image_exists = on_server { sh "docker image inspect #{deployed_image}" }.success?
|
232
|
+
unless image_exists
|
233
|
+
log "Cannot find #{deployed_image} for inspecting"
|
234
|
+
return
|
235
|
+
end
|
236
|
+
|
237
|
+
log "Running Rails console"
|
238
|
+
|
239
|
+
on_server do
|
240
|
+
sh! "docker run --rm -it " \
|
241
|
+
"--label app=#{app_name} " \
|
242
|
+
"--env-file #{project_root}/env_file " \
|
243
|
+
"-e GIT_REVISION=#{deployed_revision} " \
|
244
|
+
"-v #{project_root}/db:/rails/db/#{deploy_env} " \
|
245
|
+
"-v #{project_root}/storage:/rails/storage " \
|
246
|
+
"--entrypoint '/rails/bin/rails' " \
|
247
|
+
"#{deployed_image} " \
|
248
|
+
"console", interactive: true
|
249
|
+
end
|
250
|
+
end
|
251
|
+
|
252
|
+
def logs_command
|
253
|
+
# Determine which ports are currently bound and which are free for the new container
|
254
|
+
running_container, *other_containers = on_server { sh! "docker ps -q --filter label=app=#{app_name}" }.output.split
|
255
|
+
abort "Multiple containers found, cannot follow logs: #{other_containers.inspect}" unless other_containers.empty?
|
256
|
+
|
257
|
+
log "Following container #{running_container} logs"
|
258
|
+
|
259
|
+
on_server do
|
260
|
+
sh "docker logs -f #{running_container}", interactive: true
|
261
|
+
end
|
262
|
+
end
|
263
|
+
|
264
|
+
def sudo_command
|
265
|
+
system "ssh root@#{server}", exception: true
|
266
|
+
end
|
267
|
+
|
268
|
+
def help_command
|
269
|
+
puts <<~HELP
|
270
|
+
Usage: bin/hamal [command]
|
271
|
+
|
272
|
+
Commands:
|
273
|
+
deploy - Deploy the app to the server
|
274
|
+
console - Run Rails console in the deployed container
|
275
|
+
logs - Follow logs of the deployed container
|
276
|
+
sudo - SSH into the server as administrator
|
277
|
+
HELP
|
278
|
+
end
|
279
|
+
end
|
280
|
+
end
|
metadata
ADDED
@@ -0,0 +1,62 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: hamal
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.1.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Genadi Samokovarov
|
8
|
+
bindir: exe
|
9
|
+
cert_chain: []
|
10
|
+
date: 2025-01-02 00:00:00.000000000 Z
|
11
|
+
dependencies:
|
12
|
+
- !ruby/object:Gem::Dependency
|
13
|
+
name: json
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
15
|
+
requirements:
|
16
|
+
- - ">="
|
17
|
+
- !ruby/object:Gem::Version
|
18
|
+
version: '0'
|
19
|
+
type: :runtime
|
20
|
+
prerelease: false
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
22
|
+
requirements:
|
23
|
+
- - ">="
|
24
|
+
- !ruby/object:Gem::Version
|
25
|
+
version: '0'
|
26
|
+
email:
|
27
|
+
- gsamokovarov@gmail.com
|
28
|
+
executables:
|
29
|
+
- hamal
|
30
|
+
extensions: []
|
31
|
+
extra_rdoc_files: []
|
32
|
+
files:
|
33
|
+
- ".rubocop.yml"
|
34
|
+
- README.md
|
35
|
+
- Rakefile
|
36
|
+
- exe/hamal
|
37
|
+
- lib/hamal.rb
|
38
|
+
homepage: https://github.com/gsamokovarov/hamal
|
39
|
+
licenses: []
|
40
|
+
metadata:
|
41
|
+
homepage_uri: https://github.com/gsamokovarov/hamal
|
42
|
+
source_code_uri: https://github.com/gsamokovarov/hamal
|
43
|
+
changelog_uri: https://github.com/gsamokovarov/hamal/releases
|
44
|
+
rubygems_mfa_required: 'true'
|
45
|
+
rdoc_options: []
|
46
|
+
require_paths:
|
47
|
+
- lib
|
48
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
49
|
+
requirements:
|
50
|
+
- - ">="
|
51
|
+
- !ruby/object:Gem::Version
|
52
|
+
version: 3.1.0
|
53
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
54
|
+
requirements:
|
55
|
+
- - ">="
|
56
|
+
- !ruby/object:Gem::Version
|
57
|
+
version: '0'
|
58
|
+
requirements: []
|
59
|
+
rubygems_version: 3.6.2
|
60
|
+
specification_version: 4
|
61
|
+
summary: Hamal is a simple deploy tool for self-hosted Rails application
|
62
|
+
test_files: []
|