hamal 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
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: []