hamal 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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: []
|