brut 0.20.2 → 0.21.0.pre.1

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.
@@ -161,6 +161,11 @@ class Brut::CLI::Apps::New::App < Brut::CLI::Commands::BaseCommand
161
161
  project_root: base.project_root,
162
162
  templates_dir:
163
163
  )
164
+ when "docker-deploy"
165
+ segments << Brut::CLI::Apps::New::Segments::DockerDeploy.new(
166
+ project_root: base.project_root,
167
+ templates_dir:
168
+ )
164
169
  when "demo"
165
170
  # handled above
166
171
  else
@@ -311,6 +316,11 @@ class Brut::CLI::Apps::New::App < Brut::CLI::Commands::BaseCommand
311
316
  project_root:,
312
317
  templates_dir:
313
318
  )
319
+ elsif segment_name == "docker-deploy"
320
+ Brut::CLI::Apps::New::Segments::DockerDeploy.new(
321
+ project_root:,
322
+ templates_dir:
323
+ )
314
324
  end
315
325
  if !segment
316
326
  error "'#{segment_name}' is not a segment. Allowed values: sidekiq, heroku"
@@ -0,0 +1,30 @@
1
+ class Brut::CLI::Apps::New::Segments::DockerDeploy < Brut::CLI::Apps::New::Base
2
+ def self.friendly_name = "Generic Docker-based Deployment"
3
+ def self.segment_name = "docker-deploy"
4
+
5
+ def initialize(project_root:, templates_dir:)
6
+ @project_root = project_root
7
+ @templates_dir = templates_dir / "segments" / "DockerDeploy"
8
+ end
9
+
10
+ def add!
11
+ operations = copy_files(@templates_dir, @project_root) +
12
+ other_operations
13
+
14
+ operations.each do |operation|
15
+ operation.call
16
+ end
17
+ end
18
+
19
+ def <=>(other)
20
+ if self.class == other.class
21
+ 0
22
+ elsif other.class == Brut::CLI::Apps::New::Segments::Sidekiq
23
+ # If both docker and sidekiq segments are activated, we want to do heroku first,
24
+ # since Sidekiq will need to modify it.
25
+ -1
26
+ else
27
+ 1
28
+ end
29
+ end
30
+ end
@@ -47,6 +47,10 @@ class Brut::CLI::Apps::New::Segments::Sidekiq < Brut::CLI::Apps::New::Base
47
47
  # If both herkou and sidekiq segments are activated, we want to do heroku first,
48
48
  # since Sidekiq will need to modify it.
49
49
  1
50
+ elsif other.class == Brut::CLI::Apps::New::Segments::DockerDeploy
51
+ # If both herkou and sidekiq segments are activated, we want to do heroku first,
52
+ # since Sidekiq will need to modify it.
53
+ 1
50
54
  else
51
55
  -1
52
56
  end
@@ -146,16 +150,12 @@ SIDEKIQ_BASIC_AUTH_PASSWORD=password
146
150
  code: "@sidekiq_segment.boot!"
147
151
  ),
148
152
  Brut::CLI::Apps::New::Ops::InsertCodeInMethod.new(
149
- file: project_root / "deploy" / "docker_config.rb",
150
- class_name: "HerokuConfig",
151
- method_name: "additional_images",
153
+ file: project_root / "deploy" / "deploy_config.rb",
154
+ class_name: "AppDeployConfig",
155
+ method_name: "additional_processes",
152
156
  ignore_if_file_not_found: true,
153
157
  code: %{
154
- {
155
- "sidekiq" => {
156
- cmd: "bin/run sidekiq",
157
- }
158
- }
158
+ process_description("worker", [ "bundle", "exec", "bin/run sidekiq" ])
159
159
  },
160
160
  where: :end
161
161
  ),
@@ -4,5 +4,6 @@ module Brut::CLI::Apps::New
4
4
  autoload :Demo, "brut/cli/apps/new/segments/demo"
5
5
  autoload :Sidekiq, "brut/cli/apps/new/segments/sidekiq"
6
6
  autoload :Heroku, "brut/cli/apps/new/segments/heroku"
7
+ autoload :DockerDeploy, "brut/cli/apps/new/segments/docker_deploy"
7
8
  end
8
9
  end
@@ -155,13 +155,16 @@ private
155
155
  end
156
156
  end
157
157
 
158
- # Convienience methods to defer to `Brut::CLI::Commands::ExecutionContext#stdout`'s `puts`.
158
+ # Convienience method to defer to `Brut::CLI::Commands::ExecutionContext#stdout`'s `puts`.
159
159
  # @!visibility public
160
160
  def puts(*args)
161
161
  if !options.quiet?
162
162
  self.execution_context.stdout.puts(*args)
163
163
  end
164
164
  end
165
+
166
+ # Convienience method to defer to `Brut::CLI::Commands::ExecutionContext#stdout`'s `print`.
167
+ # @!visibility public
165
168
  def print(*args)
166
169
  if !options.quiet?
167
170
  self.execution_context.stdout.print(*args)
@@ -190,7 +193,8 @@ private
190
193
 
191
194
 
192
195
  # Runs whatever logic this command exists to execute. This is the method you must implement, however `#execute` is the public
193
- # API and what you should call if you want to programmatically execute a command.
196
+ # API and what you should call if you want to programmatically execute a command. In other words, **do not
197
+ # call this method in a test, call `execute` instead**.
194
198
  #
195
199
  # The default implementation will generate an error, which is suitable for an app or namespace command that require a subcommand.
196
200
  #
@@ -47,12 +47,13 @@ class Brut::Framework::App
47
47
  # Note that Brut will record the exception via OpenTelemetry so you should not do this in your handlers. It
48
48
  # would be preferable to instead record an event if you want to have observability from your error handlers.
49
49
  #
50
- # @param [Class|Integer|Range<Integer>] condition if given this specifies the conditions under which the given
50
+ # @param [Class|Integer|Range<Integer>|Symbol] condition if given this specifies the conditions under which the given
51
51
  # block will handle the error. If omitted, this block will handle any error that doesn't have a more
52
52
  # specific handler configured. Meaning of values:
53
53
  # * A class - this is an exception class that, if caught, triggers the handler
54
54
  # * An integer - this is an HTTP status code that, if returned, triggers the handler
55
55
  # * A range of integers - this is a range of HTTP status codes that, if returned, triggers the handler
56
+ # * If the symbol is `:catch_all`, this will be used to handle any error not handled by a more specific handler.
56
57
  # @yield [Exception] the block is given two named parameters: `exception:` and `http_status_code:`. Your block
57
58
  # can declare both, either, or none. Any that are declared will be given values. At least one
58
59
  # will be non-`nil`, however are encouraged to code defensively inside this block.
@@ -75,7 +75,10 @@ class Brut::Framework::Config
75
75
  Object,
76
76
  "Handle to the database",
77
77
  ) do |database_url|
78
- Sequel.connect(database_url)
78
+ Sequel.connect(
79
+ database_url,
80
+ test: true, #XXX
81
+ )
79
82
  end
80
83
 
81
84
  c.store_ensured_path(
@@ -438,6 +441,15 @@ class Brut::Framework::Config
438
441
  end
439
442
  end
440
443
 
444
+ c.store(
445
+ "webhook_url_path_prefix",
446
+ String,
447
+ "The root url path under which Webhooks are placed.",
448
+ allow_app_override: true
449
+ ) do
450
+ "/webhooks"
451
+ end
452
+
441
453
  end
442
454
  end
443
455
  end
@@ -232,10 +232,7 @@ class Brut::Framework::MCP
232
232
  [
233
233
  {
234
234
  allow_if: ->(env) {
235
- brut_owned_path = env["brut.owned_path"]
236
- app_allowed = Brut.container.csrf_protector.allowed?(env)
237
-
238
- brut_owned_path || app_allowed
235
+ Brut.container.csrf_protector.allowed?(env)
239
236
  },
240
237
  message: message,
241
238
  },
@@ -1,12 +1,16 @@
1
- # Base for custom logic around CSRF protection. Brut configures `Rack::Protection::AuthenticityToken` for all requests, and
2
- # this happens early in the request. The idea is that no real POST should be missing a CSRF token. That said, there are times
3
- # when it must be skipped, such as for webhooks. In that case, you can extend this class and configure it via
1
+ # Stores logic around what POST requests should require CSRF protection.
2
+ # Brut ideally wants *all* POST requests to require CSRF protection, however
3
+ # sometimes this is not convienient, notably webhooks. This class includes
4
+ # that logic.
5
+ #
6
+ # You may specify your own implementation via
4
7
  # `Brut.container.override("csrf_protector", YourCustomCsrfProtector.new)` in your `App` class' initializer.
5
8
  #
6
9
  # @example
7
10
  # class CsrfProtector < Brut::FrontEnd::CsrfProtector
8
11
  # def allowed?(env)
9
- # !!env["PATH_INFO"].to_s.match?(/^\/webhooks\//)
12
+ # super(env) ||
13
+ # !!env["PATH_INFO"].to_s.match?(/^\/api\//)
10
14
  # end
11
15
  # end
12
16
  # # Then, in app.rb
@@ -23,8 +27,8 @@
23
27
  #
24
28
  class Brut::FrontEnd::CsrfProtector
25
29
 
26
- # Return true if the request should be allowed without a CSRF token. This implementation returns false.
30
+ # Return true if the request should be allowed without a CSRF token. This implementation allows webhooks and paths that Brut owns explicitly
27
31
  def allowed?(env)
28
- false
32
+ env["brut.webhook"] || env["brut.owned_path"]
29
33
  end
30
34
  end
@@ -75,7 +75,7 @@ class Brut::FrontEnd::Handlers::InstrumentationHandler < Brut::FrontEnd::Handler
75
75
 
76
76
  if span.nil? || traceparent.nil?
77
77
  SemanticLogger[self.class].info "Missing traceparent or span: #{@http_tracestate}, #{@http_traceparent}"
78
- return http_status(400)
78
+ return http_status(200)
79
79
  end
80
80
 
81
81
  carrier = traceparent.as_carrier
@@ -9,6 +9,8 @@ class Brut::FrontEnd::Middlewares::AnnotateBrutOwnedPaths < Brut::FrontEnd::Midd
9
9
  if env["PATH_INFO"] =~ /^\/__brut\//
10
10
  Brut.container.instrumentation.add_attributes(prefix: "brut", owned_path: true)
11
11
  env["brut.owned_path"] = true
12
+ elsif env["PATH_INFO"] =~ /^#{Regexp.escape(Brut.container.webhook_url_path_prefix)}\//
13
+ env["brut.webhook"] = true
12
14
  end
13
15
  @app.call(env)
14
16
  end
@@ -97,6 +97,28 @@ class Brut::FrontEnd::Routing
97
97
  route
98
98
  end
99
99
 
100
+ def register_webhook(path, method:)
101
+ path = path.gsub(/^\//,"")
102
+ full_path = "#{Brut.container.webhook_url_path_prefix}/#{path}"
103
+
104
+ if full_path =~ /^#{Regexp.escape(Brut.container.webhook_url_path_prefix)}#{Regexp.escape(Brut.container.webhook_url_path_prefix)}/
105
+ raise ArgumentError, "webhook '#{path}' should not start with #{Brut.container.webhook_url_path_prefix} - Brut will add that for you"
106
+ end
107
+
108
+ route = begin
109
+ Route.new(method, full_path)
110
+ rescue Brut::Framework::Errors::NoClassForPath => ex
111
+ if Brut.container.project_env.development?
112
+ MissingPath.new(method,full_path,ex)
113
+ else
114
+ raise ex
115
+ end
116
+ end
117
+ @routes << route
118
+ add_routing_method(route)
119
+ route
120
+ end
121
+
100
122
  def route(handler_class)
101
123
  route = @routes.detect { |route|
102
124
  handler_class_match = route.handler_class.name == handler_class.name
@@ -89,6 +89,11 @@ class Brut::Instrumentation::OpenTelemetry
89
89
  )
90
90
  end
91
91
 
92
+ # Returns a trace ID for the current request/span. This is useful if you
93
+ # need to connect the current OTel request/trace with another system, such
94
+ # as error reporting.
95
+ def current_trace_id = OpenTelemetry::Trace.current_span.context.hex_trace_id
96
+
92
97
  private
93
98
 
94
99
  class NormalizedAttributes
@@ -126,7 +126,7 @@ module Brut::SinatraHelpers
126
126
  #
127
127
  def form(path)
128
128
  route = Brut.container.routing.register_form(path)
129
- self.define_handled_route(route, type: :form)
129
+ self.define_handled_route(route)
130
130
  end
131
131
 
132
132
  # Declare a form action that has no associated form elements. This is used when you need to use a button to submit to the
@@ -137,7 +137,7 @@ module Brut::SinatraHelpers
137
137
  # to make sure there is no form defined.
138
138
  def action(path)
139
139
  route = Brut.container.routing.register_handler_only(path)
140
- self.define_handled_route(route, type: :action)
140
+ self.define_handled_route(route)
141
141
  end
142
142
 
143
143
  # When you need to respond to a given path/method, but it's not a page nor a form. For example, webhooks often
@@ -146,12 +146,24 @@ module Brut::SinatraHelpers
146
146
  # This will locate a handler class based on the same naming convention as for forms.
147
147
  def path(path, method:)
148
148
  route = Brut.container.routing.register_path(path, method: Brut::FrontEnd::HttpMethod.new(method))
149
- self.define_handled_route(route,type: :generic)
149
+ self.define_handled_route(route)
150
+ end
151
+
152
+ # Define a webhook, where another server will post data to your app.
153
+ # This method services two purposes beyond using `path` or `action`:
154
+ #
155
+ # 1. It nests `relative_path` under `/webhooks` as a convention for
156
+ # where all webhooks should go.
157
+ # 2. That convention will be used to skip CSRF protection for a POST
158
+ # made to this endpoint, since you would not want that for a webhook.
159
+ def webhook(relative_path, method: :post)
160
+ route = Brut.container.routing.register_webhook(relative_path, method: Brut::FrontEnd::HttpMethod.new(method))
161
+ self.define_handled_route(route)
150
162
  end
151
163
 
152
164
  private
153
165
 
154
- def define_handled_route(original_brut_route,type:)
166
+ def define_handled_route(original_brut_route)
155
167
 
156
168
  method = original_brut_route.http_method.to_s.upcase
157
169
  path = original_brut_route.path_template
@@ -7,22 +7,62 @@ module Brut::SpecSupport::CLICommandSupport
7
7
  # A subclass of {Brut::CLI::Executor} that remembers the commands it was asked to execute, instead of
8
8
  # actually executing them. This allows inspection later to see if expected commands had been run.
9
9
  #
10
- # This is used by the `have_executed` matcher.
10
+ # This is used by the `have_executed` matcher. It also is useful when yielding a block to
11
+ # `test_execution_context` so that you can configure command line executions to raise errors or
12
+ # provide specific output.
11
13
  class CapturingExecutor < Brut::CLI::Executor
12
14
  attr_reader :commands_executed
15
+ def initialize(...)
16
+ super
17
+ @on_commands = {}
18
+ end
13
19
  def system!(*args)
14
20
  @commands_executed ||= []
15
- if args.length == 1
16
- @commands_executed << args[0]
17
- else
18
- @commands_executed << args
21
+ command = if args.length == 1
22
+ args[0]
23
+ else
24
+ args
25
+ end
26
+ @commands_executed << command
27
+ if @on_commands[command]
28
+ if @on_commands[command][:raise_error]
29
+ raise Brut::CLI::SystemExecError.new(command,1)
30
+ elsif @on_commands[command][:output]
31
+ output = @on_commands[command][:output]
32
+ yield(output)
33
+ end
19
34
  end
20
35
  nil
21
36
  end
37
+
38
+ # Configure the behavior of a specific command. This must be called before any
39
+ # command line commands are invoked.
40
+ #
41
+ # @param [String] command the *exact* command line invocation you want to configure.
42
+ # @param [false|true] raise_error if true, an execption is raised when this command is executed.
43
+ # @param [String] output if not-nil, this output is produced by the command.
44
+ def on_command(command, raise_error: false, output: nil)
45
+ if raise_error
46
+ @on_commands[command] = { raise_error: }
47
+ elsif output
48
+ @on_commands[command] = { output: }
49
+ end
50
+ end
22
51
  end
23
52
  # Create an ExecutionContext suitable for any command, but which allows
24
53
  # manipulating the data as needed for your test, or to access the various IO streams
25
54
  # used by the command.
55
+ # @param [Array<String>] argv the arguments remaining on the command line after parsing
56
+ # @param [Hash<Symbol|String,String>] options hash of options. The preceding dashes **must be omitted**. If strings are passed as keys, they are converted to symbols.
57
+ # @param [Hash<String,String>] env The UNIX environment to use.
58
+ # @param [StringIO] stdin IO to use as the standard input.
59
+ # @param [StringIO] stdout IO to use as the standard output. Note that you usually do not want to specify this, as the default is to create a `StringIO` which you can access directly from the returned execution context.
60
+ # @param [StringIO] stderr IO to use as the standard error. Note that you usually do not want to specify this, as the default is to create a `StringIO` which you can access directly from the returned execution context.
61
+ # @param [Brut::CLI::Executor] executor executor to use to execute sub commands. Note that you likely
62
+ # don't want to pass in a value for this. The default is an implementation that captures all the
63
+ # commands that were executed for your later inspection via the `have_executed` matcher.
64
+ # @yield [executor] If a block is passed, the executor is yielded to allow for configuration of its behavior.
65
+ # @yieldparam executor [Brut::CLI::Executor|Brut::SpecSupport::CLICommandSupport::CapturingExecutor] the executor. If you used the default and did not provide your own, you'll get a `CapturingExecutor` that you can use to configure responses to command line invocations.
26
66
  def test_execution_context(
27
67
  argv: [],
28
68
  options: {},
@@ -31,13 +71,26 @@ module Brut::SpecSupport::CLICommandSupport
31
71
  stdout: StringIO.new,
32
72
  stderr: StringIO.new,
33
73
  executor: :default,
34
- logger: :default
74
+ logger: :default,
75
+ &block
35
76
  )
36
77
  logger = if logger == :default
37
78
  Brut::CLI::Logger.new(app_name: $0, stdout:, stderr:)
38
79
  else
39
80
  logger
40
81
  end
82
+ executor = if executor == :default
83
+ CapturingExecutor.new(out: stdout, err: stderr, logger:)
84
+ else
85
+ executor
86
+ end
87
+
88
+ if block
89
+ block.(executor)
90
+ end
91
+ options = options.map { |key,value|
92
+ [ key.to_sym, value ]
93
+ }.to_h
41
94
  Brut::CLI::Commands::ExecutionContext.new(
42
95
  argv:,
43
96
  options: Brut::CLI::Options.new({ "log-level": "error" }.merge(options)),
@@ -45,7 +98,7 @@ module Brut::SpecSupport::CLICommandSupport
45
98
  stdin:,
46
99
  stdout:,
47
100
  stderr:,
48
- executor: executor == :default ? CapturingExecutor.new(out: stdout, err: stderr, logger:) : executor,
101
+ executor:
49
102
  )
50
103
  end
51
104
  end
@@ -1,5 +1,6 @@
1
1
  RSpec::Matchers.define :have_executed do |commands|
2
2
  match do |execution_context|
3
+ commands = Array(commands)
3
4
  commands.all? { |command|
4
5
  if command.kind_of?(Regexp)
5
6
  execution_context.executor.commands_executed.any? { it.kind_of?(String) && it.match(command) }
@@ -10,6 +11,7 @@ RSpec::Matchers.define :have_executed do |commands|
10
11
  end
11
12
 
12
13
  failure_message do |execution_context|
14
+ commands = Array(commands)
13
15
  not_executed = commands.reject { |command|
14
16
  if command.kind_of?(Regexp)
15
17
  execution_context.executor.commands_executed.any? { it.kind_of?(String) && it.match(command) }
data/lib/brut/version.rb CHANGED
@@ -1,4 +1,4 @@
1
1
  module Brut
2
2
  # @!visibility private
3
- VERSION = "0.20.2"
3
+ VERSION = "0.21.0.pre.1"
4
4
  end
@@ -9,18 +9,22 @@ SCRIPT_DIR=$( cd -- "$( dirname -- "${0}" )" > /dev/null 2>&1 && pwd )
9
9
  require_command "docker"
10
10
  load_docker_compose_env
11
11
 
12
- usage_description="Execute a command inside the app's container. Any command other than 'bash' will be run with bash -lc. Use -B to just run the command directly."
13
- usage_args="[-s service] [-B] command"
12
+ usage_description="Execute a command inside the app's container. Any command other than 'bash' will be run with bash -lc. Use -B to just run the command directly. Use -A to avoid ssh-agent."
13
+ usage_args="[-s service] [-B] [-A] command"
14
14
  usage_pre="exec.pre"
15
15
  usage_on_help "${usage_description}" "${usage_args}" "${usage_pre}" "" "${@}"
16
16
 
17
17
  SERVICE="${SERVICE_NAME:-${DEFAULT_SERVICE}}"
18
18
  INCLUDE_PREFIX_FOR_NON_BASH=true
19
- while getopts "s:B" opt "${@}"; do
19
+ SSH_AGENT=true
20
+ while getopts "s:AB" opt "${@}"; do
20
21
  case ${opt} in
21
22
  s )
22
23
  SERVICE="${OPTARG}"
23
24
  ;;
25
+ A )
26
+ SSH_AGENT=false
27
+ ;;
24
28
  B )
25
29
  INCLUDE_PREFIX_FOR_NON_BASH=false
26
30
  ;;
@@ -53,6 +57,9 @@ elif [ "$INCLUDE_PREFIX_FOR_NON_BASH" = "true" ]; then
53
57
  else
54
58
  COMMAND=("$@")
55
59
  fi
60
+ if [ "$SSH_AGENT" = "true" ]; then
61
+ COMMAND=(ssh-agent "${COMMAND[@]}")
62
+ fi
56
63
 
57
64
  log "🚂" "Running '${COMMAND[@]}' inside container with service name '${SERVICE}'"
58
65
 
@@ -0,0 +1,126 @@
1
+ # This is a multi-stage build, meaning one image will be built that
2
+ # has necessarily dev tools in it, and that image is used to create artifacts
3
+ # that will be copied into the second build, which will create the image
4
+ # you deploy to production.
5
+ #
6
+ # # Maintaing this file
7
+ #
8
+ # You own this file now. While there could be a way to create it from
9
+ # the Dockerfile.dx, for now, you'll need to stay on top of it:
10
+ #
11
+ # 1 - ensure the versions of Ruby match
12
+ # 2 - ensure the versions of NodeJS match
13
+ # 3 - ensure that anything you installed in Dockerfile.dx to make
14
+ # the app work or to pre-generate assets is set up here as well.
15
+ # You are advised to make that setup identical.
16
+ #
17
+ # You can test this locally but using the `--platform` flag to `brut deploy deploy`
18
+ # and running a container locally. You'll need all infrastructure available
19
+ # but it can be done to test things before you deploy. If you need to.
20
+
21
+ # Use Ruby 3.4 as a base.
22
+ FROM ruby:3.4 AS base
23
+
24
+ # bin/deploy will inject this value so that your app's GIT SHA1
25
+ # is in the environment in production, thus allowing you to be more
26
+ # sure of what's actually running.
27
+ ARG app_git_sha1
28
+
29
+ WORKDIR /brut-app
30
+
31
+ # Install base packages
32
+ #
33
+ # - ca-certificates is needed for other installs
34
+ # - curl is needed generally for other installs and by Heroku
35
+ # - gnupg is a PGP replacement used in making sure new APT repos work
36
+ # - libjemalloc2 in theory speeds up Ruby
37
+ # - lsb-release is used to generically access information for this OS's version
38
+ # - wget allows us to copy/paste commands from vendors about how to install
39
+ # software even though it does the same thing as curl
40
+ RUN --mount=type=cache,target=/var/cache/apt \
41
+ apt-get update --quiet --yes && \
42
+ apt-get install --no-install-recommends --quiet --yes \
43
+ ca-certificates \
44
+ curl \
45
+ gnupg \
46
+ libjemalloc2 \
47
+ lsb-release \
48
+ postgresql-common \
49
+ rsync \
50
+ wget
51
+
52
+ # Install the PostgreSQL client. The latest version is not available
53
+ # from Debian, so we set up our own. This should match what's in Dockerfile.dx
54
+ # and ideally the version of Postgres used in production.
55
+ #
56
+ # Incancation is based on: https://www.postgresql.org/download/linux/debian/
57
+ RUN /usr/share/postgresql-common/pgdg/apt.postgresql.org.sh -y && \
58
+ apt-get update --quiet --yes && \
59
+ apt-get --yes --quiet --no-install-recommends install postgresql-client-16 && \
60
+ rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/*
61
+
62
+ # Set basic env vars for production
63
+ ENV RACK_ENV="production" \
64
+ BUNDLE_DEPLOYMENT="1" \
65
+ BUNDLE_PATH="/usr/local/bundle" \
66
+ BUNDLE_WITHOUT="development"
67
+
68
+ RUN rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/*
69
+
70
+ # This makes a new image that we'll throw away after building
71
+ # needed artifacts.
72
+ FROM base AS build
73
+
74
+ # - build-essential is needed for almost any build tool we have to install
75
+ # - git is needed to install some things
76
+ # - libpq-dev is needed by postgres
77
+ # - pkg-config is, I guess, not considered "essential" (as in build-essential),
78
+ # but still needed to install downstream stuff
79
+ RUN --mount=type=cache,target=/var/cache/apt \
80
+ apt-get update --quiet --yes && \
81
+ apt-get install --no-install-recommends --quiet --yes \
82
+ build-essential \
83
+ git \
84
+ libpq-dev \
85
+ pkg-config
86
+
87
+ # Copy NodeJS from the official image. This should be faster than
88
+ # installing it via e.g. nvm
89
+ COPY --from=node:22-slim /usr/local /usr/local
90
+
91
+ # Copy Gemfile only as it may not change between deploys
92
+ COPY Gemfile Gemfile.lock .
93
+
94
+ # Install RubyGems from app's Gemfile
95
+ RUN --mount=type=cache,target=${BUNDLE_PATH}/cache \
96
+ bundle install --verbose && \
97
+ rm -rf ~/.bundle/ "${BUNDLE_PATH}"/ruby/*/cache "${BUNDLE_PATH}"/ruby/*/bundler/gems/*/.git
98
+
99
+ COPY package.json package-lock.json ./
100
+ # Install Node Modules from package.json
101
+ RUN npm clean-install --no-audit --no-fund --verbose
102
+
103
+ COPY . .
104
+
105
+ # Build all assets
106
+ RUN BRUT_DEBUG=true bundle exec brut --log-level=debug build-assets all && \
107
+ rm -rf node_modules
108
+
109
+ # We are now switching back to building the image that will be deployed.
110
+
111
+ FROM base
112
+ # Copy built artifacts from the throwaway image: gems, application
113
+ COPY --from=build "${BUNDLE_PATH}" "${BUNDLE_PATH}"
114
+ COPY --from=build /brut-app /brut-app
115
+
116
+ # For security, set directories that will be written to be owned by non-root
117
+ RUN groupadd --system --gid 1000 brut && \
118
+ useradd brut --uid 1000 --gid 1000 --create-home --shell /bin/bash && \
119
+ chown -R brut:brut logs tmp
120
+ USER 1000:1000
121
+
122
+ ENV APP_GIT_SHA1="${app_git_sha1}"
123
+
124
+ # This is used to execute other commands. When the app is run in production,
125
+ # this script is used to run it.
126
+ ENTRYPOINT ["/brut-app/deploy/docker-entrypoint"]
@@ -0,0 +1,17 @@
1
+ # This contains settings for your production Docker setup.
2
+ # You own and maintain this file. It is `require`d by brut deploy docker
3
+ class AppDeployConfig < Brut::CLI::Apps::Deploy::DeployConfig
4
+
5
+ # Return an array of ProcessDescription instances, that you can
6
+ # create with the `process_description` method.
7
+ #
8
+ # For example, if you have the Sidekiq segment installed, `bin/run sidekiq`
9
+ # runs Sidekiq, so you would implement the method as follows:
10
+ #
11
+ # def additional_processes = [
12
+ # process_description("sidekiq", [ "bundle", "exec", "bin/run sidekiq" ]),
13
+ # ]
14
+ #
15
+ def additional_processes
16
+ end
17
+ end
@@ -0,0 +1,15 @@
1
+ #!/bin/bash -e
2
+
3
+ # This is the Docker entrypoint, mean this script is used to execute
4
+ # the command given to the RUN directive.
5
+ #
6
+ # The reason this exists is to load jemalloc. If you don't want to use
7
+ # jemalloc, you can delete this script, as well as the ENTRYPOINT directive
8
+ # in deploy/Dockerfile.
9
+
10
+ # Enable jemalloc for reduced memory usage and latency.
11
+ if [ -z "${LD_PRELOAD+x}" ] && [ -f /usr/lib/*/libjemalloc.so.2 ]; then
12
+ export LD_PRELOAD="$(echo /usr/lib/*/libjemalloc.so.2)"
13
+ fi
14
+
15
+ exec "${@}"