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.
Files changed (7) hide show
  1. checksums.yaml +7 -0
  2. data/.rubocop.yml +157 -0
  3. data/README.md +344 -0
  4. data/Rakefile +8 -0
  5. data/exe/hamal +5 -0
  6. data/lib/hamal.rb +280 -0
  7. 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
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rubocop/rake_task"
5
+
6
+ RuboCop::RakeTask.new
7
+
8
+ task default: :rubocop
data/exe/hamal ADDED
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "hamal"
4
+
5
+ Hamal::Commands.execute
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: []