dockside 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.
@@ -0,0 +1,216 @@
1
+ require "fileutils"
2
+
3
+ module Dockside
4
+ # One service of the compose file in one environment: start, stop, reset, exec.
5
+ class Dependency
6
+ LOG_LINES_ON_ERROR = 50
7
+
8
+ attr_reader :name, :project, :settings, :config
9
+
10
+ def initialize(name:, project:, settings:, config:, shared: false)
11
+ @name = name
12
+ @project = project
13
+ @settings = settings
14
+ @config = config
15
+ @shared = shared
16
+ end
17
+
18
+ def env
19
+ project.env
20
+ end
21
+
22
+ def shared?
23
+ @shared
24
+ end
25
+
26
+ def autostart?
27
+ settings.autostart?
28
+ end
29
+
30
+ def port
31
+ published = config.dig("ports", 0, "published")
32
+ published&.to_s&.[](/\d+/)&.to_i
33
+ end
34
+
35
+ def container_port
36
+ config.dig("ports", 0, "target")
37
+ end
38
+
39
+ def url
40
+ "http://localhost:#{port}" if port
41
+ end
42
+
43
+ def container_name
44
+ "#{project.name}-#{name}-1"
45
+ end
46
+
47
+ def running?
48
+ container&.running? || false
49
+ end
50
+
51
+ def ready?
52
+ running? && probe.ready?
53
+ end
54
+
55
+ def start(build: false, pull: false)
56
+ with_lock do
57
+ docker.preflight!
58
+ create_bind_mount_directories
59
+ return if !build && !pull && reuse_running_container
60
+
61
+ Dockside.log "starting #{name} (#{env})"
62
+ compose_up(build: build, pull: pull)
63
+ wait_until_ready
64
+ provisioner.run(container.id)
65
+ remember_config
66
+ Dockside.log "#{name} ready at #{url}"
67
+ end
68
+ end
69
+
70
+ def ensure_running!
71
+ start
72
+ end
73
+
74
+ def stop
75
+ compose.stop(name)
76
+ end
77
+
78
+ def reset
79
+ remove
80
+ start
81
+ end
82
+
83
+ # Removes the container, its volumes and the content of its data folders under tmp/.
84
+ def remove
85
+ compose.remove(name)
86
+ remove_shared_container
87
+ named_volumes.each { |volume| docker.remove_volume(volume) }
88
+ bind_mount_sources.select { |source| source.to_s.start_with?(project.root.join("tmp").to_s) }.each { |source| wipe(source) }
89
+ FileUtils.rm_f(provisioner.marker)
90
+ end
91
+
92
+ def remove_shared_container
93
+ return unless shared?
94
+
95
+ other = docker.find_container(project: "#{project.app_name}-#{project.other_env}", service: name)
96
+ docker.remove_container(other.id) if other
97
+ end
98
+
99
+ def exec(command, environment: {}, stdin: nil, workdir: nil, user: nil, allow_failure: false)
100
+ result = compose.exec(name, command, environment: environment, stdin: stdin, workdir: workdir, user: user)
101
+ raise CommandFailed.new(result) unless result.success? || allow_failure
102
+
103
+ result.stdout
104
+ end
105
+
106
+ def exec_succeeds?(command)
107
+ compose.exec(name, command).success?
108
+ end
109
+
110
+ def logs(tail: 100)
111
+ compose.logs(name, tail: tail).stdout
112
+ end
113
+
114
+ def probe
115
+ @probe ||= Readiness.probe_for(self, settings.ready)
116
+ end
117
+
118
+ def provisioner
119
+ @provisioner ||= Provisioner.new(self)
120
+ end
121
+
122
+ private
123
+
124
+ def compose
125
+ project.compose
126
+ end
127
+
128
+ def docker
129
+ project.docker
130
+ end
131
+
132
+ def container
133
+ docker.find_container(project: project.name, service: name)
134
+ end
135
+
136
+ def with_lock
137
+ project.work_dir.mkpath
138
+ File.open(project.work_dir.join(".lock"), File::RDWR | File::CREAT) do |file|
139
+ file.flock(File::LOCK_EX)
140
+ yield
141
+ end
142
+ end
143
+
144
+ def reuse_running_container
145
+ if !config_changed? && ready?
146
+ Dockside.log "#{name} already running at #{url}"
147
+ return true
148
+ end
149
+ return false unless shared?
150
+
151
+ other = docker.find_container(project: "#{project.app_name}-#{project.other_env}", service: name)
152
+ return false unless other&.running? && probe.ready?
153
+
154
+ Dockside.log "#{name} shared with #{project.other_env} at #{url}"
155
+ true
156
+ end
157
+
158
+ def compose_up(build:, pull:)
159
+ result = compose.up(name, build: build, pull: pull, timeout: settings.timeout)
160
+ return if result.success?
161
+
162
+ output = result.stdout
163
+ if output.match?(/port is already allocated|address already in use/i)
164
+ raise PortInUse, "#{name} (#{env}) could not start because port #{port} is in use. " \
165
+ "Run `docker ps` to see whether another container holds it.\n#{output}"
166
+ end
167
+
168
+ raise CommandFailed.new(result, "#{name} (#{env}) failed to start:\n#{output}\nLast #{LOG_LINES_ON_ERROR} log lines:\n#{last_logs}")
169
+ end
170
+
171
+ def wait_until_ready
172
+ Readiness.wait(probe, timeout: settings.timeout)
173
+ rescue ReadyTimeout
174
+ raise ReadyTimeout, "#{name} (#{env}) did not become ready within #{settings.timeout}s " \
175
+ "(waited until #{probe}). Last #{LOG_LINES_ON_ERROR} log lines:\n#{last_logs}"
176
+ end
177
+
178
+ def last_logs
179
+ logs(tail: LOG_LINES_ON_ERROR).chomp
180
+ end
181
+
182
+ def config_file
183
+ project.work_dir.join("#{name}.config.json")
184
+ end
185
+
186
+ def config_changed?
187
+ !config_file.exist? || config_file.read != JSON.generate(config)
188
+ end
189
+
190
+ def remember_config
191
+ config_file.write(JSON.generate(config))
192
+ end
193
+
194
+ def create_bind_mount_directories
195
+ bind_mount_sources.select { |source| source.to_s.start_with?(project.root.to_s) }.each do |source|
196
+ FileUtils.mkdir_p(source)
197
+ FileUtils.chmod(0o777, source)
198
+ end
199
+ end
200
+
201
+ # Empties the folder but keeps it: Docker Desktop keeps a deleted folder mounted as a stale mount.
202
+ def wipe(directory)
203
+ return unless directory.directory?
204
+
205
+ directory.children.each { |child| FileUtils.rm_rf(child) }
206
+ end
207
+
208
+ def bind_mount_sources
209
+ Array(config["volumes"]).select { |volume| volume["type"] == "bind" }.map { |volume| Pathname.new(volume["source"]) }
210
+ end
211
+
212
+ def named_volumes
213
+ Array(config["volumes"]).select { |volume| volume["type"] == "volume" }.map { |volume| "#{project.name}_#{volume["source"]}" }
214
+ end
215
+ end
216
+ end
@@ -0,0 +1,61 @@
1
+ module Dockside
2
+ # The docker commands the gem needs besides compose: checks, container lookup, removal.
3
+ class Docker
4
+ Container = Struct.new(:id, :state) do
5
+ def running?
6
+ state == "running"
7
+ end
8
+ end
9
+
10
+ def initialize(runner)
11
+ @runner = runner
12
+ @checked = false
13
+ end
14
+
15
+ def preflight!
16
+ return if @checked
17
+
18
+ check_daemon
19
+ check_compose
20
+ @checked = true
21
+ end
22
+
23
+ # The container compose created for the service in the project, or nil.
24
+ def find_container(project:, service:)
25
+ result = @runner.run!([
26
+ "docker", "ps", "--all",
27
+ "--filter", "label=com.docker.compose.project=#{project}",
28
+ "--filter", "label=com.docker.compose.service=#{service}",
29
+ "--format", "{{.ID}} {{.State}}"
30
+ ])
31
+ id, state = result.stdout.lines.first&.split
32
+ Container.new(id, state) if id
33
+ end
34
+
35
+ def remove_container(id)
36
+ @runner.run!(["docker", "rm", "--force", "--volumes", id])
37
+ end
38
+
39
+ def remove_volume(name)
40
+ @runner.run!(["docker", "volume", "rm", "--force", name])
41
+ end
42
+
43
+ private
44
+
45
+ def check_daemon
46
+ result = @runner.run(["docker", "version", "--format", "{{.Server.Version}}"])
47
+ return if result.success?
48
+
49
+ raise DockerUnavailable, "Docker is installed but the daemon does not answer: #{result.stderr.strip}\n" \
50
+ "Start Docker, or start with DOCKSIDE_AUTOSTART=0 to skip the containers."
51
+ end
52
+
53
+ def check_compose
54
+ result = @runner.run(["docker", "compose", "version"])
55
+ return if result.success?
56
+
57
+ raise ComposeMissing, "The docker compose plugin is missing. Install it from " \
58
+ "https://docs.docker.com/compose/install/ (docker-compose v1 is not supported)."
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,42 @@
1
+ module Dockside
2
+ class Error < StandardError; end
3
+
4
+ class ConfigError < Error; end
5
+
6
+ class UnknownDependency < Error; end
7
+
8
+ class DockerMissing < Error; end
9
+
10
+ class DockerUnavailable < Error; end
11
+
12
+ class ComposeMissing < Error; end
13
+
14
+ class ReadyTimeout < Error; end
15
+
16
+ class PortInUse < Error; end
17
+
18
+ class CommandFailed < Error
19
+ attr_reader :argv, :stdout, :stderr, :exit_status
20
+
21
+ def initialize(result, message = nil)
22
+ @argv = result.argv
23
+ @stdout = result.stdout
24
+ @stderr = result.stderr
25
+ @exit_status = result.exit_status
26
+ super(message || default_message)
27
+ end
28
+
29
+ # The last lines the command printed, stderr first.
30
+ def output_tail(lines = 10)
31
+ [stderr, stdout].map(&:strip).reject(&:empty?).join("\n").lines.last(lines).join.strip
32
+ end
33
+
34
+ private
35
+
36
+ def default_message
37
+ message = "`#{argv.join(" ")}` failed with status #{exit_status}"
38
+ details = [stderr, stdout].map(&:strip).reject(&:empty?).join("\n")
39
+ details.empty? ? message : "#{message}:\n#{details}"
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,51 @@
1
+ module Dockside
2
+ # The compose override file the gem generates for one environment: the environment block of every
3
+ # service plus the conventions (image tag for built services, host.docker.internal).
4
+ class Override
5
+ def initialize(project)
6
+ @project = project
7
+ end
8
+
9
+ def to_hash
10
+ services = @project.raw_services.to_h do |service, definition|
11
+ [service, service_override(service, definition)]
12
+ end
13
+ {"services" => services}
14
+ end
15
+
16
+ def to_yaml
17
+ tree = Psych::Visitors::YAMLTree.create
18
+ tree << to_hash
19
+ stream = tree.tree
20
+ mark_environment_lists_as_override(stream.children.first.root)
21
+ stream.to_yaml
22
+ end
23
+
24
+ private
25
+
26
+ def service_override(service, definition)
27
+ override = Settings.compose_overrides(definition, @project.env)
28
+ override["extra_hosts"] = Array(override["extra_hosts"] || definition["extra_hosts"]) | [Project::HOST_GATEWAY]
29
+ override["image"] = "#{@project.app_name}-#{service}" if definition.key?("build") && !definition.key?("image")
30
+ override
31
+ end
32
+
33
+ def environment_keys(service)
34
+ Settings.compose_overrides(@project.raw_services.fetch(service), @project.env).keys
35
+ end
36
+
37
+ # Lists in an environment block replace the list of the service instead of being appended to it.
38
+ def mark_environment_lists_as_override(root)
39
+ services = root.children.last
40
+ services.children.each_slice(2) do |name, service|
41
+ replaced = environment_keys(name.value)
42
+ service.children.each_slice(2) do |key, value|
43
+ next unless value.is_a?(Psych::Nodes::Sequence) && replaced.include?(key.value)
44
+
45
+ value.tag = "!override"
46
+ value.implicit = false
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,133 @@
1
+ module Dockside
2
+ # The app's compose file and the files the gem derives from it for one environment.
3
+ class Project
4
+ ENVIRONMENTS = %w[development test].freeze
5
+ HOST_GATEWAY = "host.docker.internal:host-gateway".freeze
6
+
7
+ attr_reader :root, :env, :app_name, :runner
8
+
9
+ def initialize(root:, env:, app_name:, runner:)
10
+ @root = Pathname.new(root)
11
+ @env = env.to_s
12
+ @app_name = app_name
13
+ @runner = runner
14
+ end
15
+
16
+ def name
17
+ "#{app_name}-#{env}"
18
+ end
19
+
20
+ def other_env
21
+ (env == "test") ? "development" : "test"
22
+ end
23
+
24
+ def compose_file
25
+ root.join("config/dockside.yml")
26
+ end
27
+
28
+ def env_file
29
+ file = root.join("config/dockside.#{env}.yml")
30
+ file if file.exist?
31
+ end
32
+
33
+ def work_dir
34
+ root.join("tmp/dockside", env)
35
+ end
36
+
37
+ def override_file
38
+ work_dir.join("override.yml")
39
+ end
40
+
41
+ # The app's compose file without the x-dockside part, so compose does not interpolate
42
+ # ${...} inside after_start commands or warn about them.
43
+ def plain_file
44
+ work_dir.join("dockside.yml")
45
+ end
46
+
47
+ def files
48
+ [plain_file, env_file, override_file].compact
49
+ end
50
+
51
+ def compose_env
52
+ {"RAILS_ENV" => env, "DOCKSIDE_ENV" => env}
53
+ end
54
+
55
+ def compose
56
+ @compose ||= Compose.new(self)
57
+ end
58
+
59
+ def docker
60
+ @docker ||= Docker.new(runner)
61
+ end
62
+
63
+ def service_names
64
+ raw_services.keys
65
+ end
66
+
67
+ def settings(service)
68
+ Settings.for(service, raw_services.fetch(service), env)
69
+ end
70
+
71
+ # The first host port the service publishes in the given environment, read from the file
72
+ # before compose resolves it. Used to find out whether two environments share a container.
73
+ def host_port(service, environment)
74
+ definition = raw_services.fetch(service)
75
+ ports = definition.dig("x-dockside", environment, "ports") || definition["ports"] || []
76
+ first = ports.first
77
+ return if first.nil?
78
+
79
+ published = first.is_a?(Hash) ? first["published"] : first.to_s.split(":")[-2]
80
+ published&.to_s
81
+ end
82
+
83
+ # Writes the gem's files and lets compose merge everything. Returns the resolved services.
84
+ def resolve!
85
+ write_files
86
+ compose.config.fetch("services")
87
+ end
88
+
89
+ # The compose file with everything merged, ready for a plain `docker compose -f`.
90
+ def resolved_yaml
91
+ write_files
92
+ compose.config_yaml
93
+ end
94
+
95
+ def write_files
96
+ work_dir.mkpath
97
+ plain_file.write(plain_document.to_yaml)
98
+ override_file.write(Override.new(self).to_yaml)
99
+ end
100
+
101
+ def raw_services
102
+ raw_document.fetch("services")
103
+ end
104
+
105
+ private
106
+
107
+ def raw_document
108
+ @raw_document ||= load_raw_document
109
+ end
110
+
111
+ def load_raw_document
112
+ raise ConfigError, "#{compose_file} does not exist. Run `bin/rails generate dockside:install`." unless compose_file.exist?
113
+
114
+ document = YAML.safe_load(compose_file.read, aliases: true) || {}
115
+ document["services"] ||= {}
116
+ document["services"].each { |service, definition| validate(service, definition) }
117
+ document
118
+ end
119
+
120
+ def plain_document
121
+ services = raw_services.transform_values { |definition| definition.except("x-dockside") }
122
+ raw_document.merge("services" => services)
123
+ end
124
+
125
+ def validate(service, definition)
126
+ if definition.key?("container_name")
127
+ raise ConfigError, "Service #{service} sets container_name. dockside names containers " \
128
+ "#{app_name}-<environment>-#{service}-1, remove container_name."
129
+ end
130
+ Settings.validate(service, definition["x-dockside"])
131
+ end
132
+ end
133
+ end
@@ -0,0 +1,81 @@
1
+ module Dockside
2
+ # Runs the after_start steps of a dependency once per container.
3
+ class Provisioner
4
+ Step = Struct.new(:exec, :run, :environment, :workdir, :user, :stdin, :allow_failure, :always)
5
+
6
+ class Step
7
+ KEYS = %w[exec run environment workdir user stdin allow_failure always].freeze
8
+
9
+ def self.parse_all(service, steps)
10
+ Array(steps).map { |step| parse(service, step) }
11
+ end
12
+
13
+ def self.parse(service, step)
14
+ unless step.is_a?(Hash) && (step.keys - KEYS).empty? && [step["exec"], step["run"]].compact.one?
15
+ raise ConfigError, "#{service}: every after_start step needs exec: or run:, " \
16
+ "with the options #{(KEYS - %w[exec run]).join(", ")}. Got #{step.inspect}"
17
+ end
18
+
19
+ new(**step.transform_keys(&:to_sym))
20
+ end
21
+
22
+ def description
23
+ exec ? "exec #{Array(exec).join(" ")}" : "run #{Array(run).join(" ")}"
24
+ end
25
+ end
26
+
27
+ def initialize(dependency)
28
+ @dependency = dependency
29
+ end
30
+
31
+ def marker
32
+ @dependency.project.work_dir.join("#{@dependency.name}.provisioned")
33
+ end
34
+
35
+ def provisioned?(container_id)
36
+ marker.exist? && marker.read.strip == container_id
37
+ end
38
+
39
+ # Runs every step for a new container, only the `always` steps for a known one.
40
+ def run(container_id)
41
+ due = !provisioned?(container_id)
42
+ @dependency.settings.after_start.each do |step|
43
+ run_step(step) if due || step.always
44
+ end
45
+ marker.write(container_id) if due
46
+ end
47
+
48
+ private
49
+
50
+ def run_step(step)
51
+ Dockside.log "#{@dependency.name}: #{step.description}"
52
+ environment = injected_environment.merge((step.environment || {}).transform_keys(&:to_s))
53
+ stdin = step.stdin && @dependency.project.root.join(step.stdin).read
54
+ if step.exec
55
+ @dependency.exec(step.exec, environment: environment, stdin: stdin, workdir: step.workdir, user: step.user)
56
+ else
57
+ run_on_host(step, environment, stdin)
58
+ end
59
+ rescue CommandFailed => error
60
+ raise unless step.allow_failure
61
+
62
+ Dockside.log "#{@dependency.name}: the step failed, moving on because of allow_failure:\n#{error.output_tail}"
63
+ end
64
+
65
+ def run_on_host(step, environment, stdin)
66
+ command = step.run.is_a?(Array) ? step.run : ["sh", "-c", step.run]
67
+ result = @dependency.project.runner.run(command, env: environment, stdin: stdin, chdir: @dependency.project.root)
68
+ raise CommandFailed.new(result) unless result.success?
69
+ end
70
+
71
+ def injected_environment
72
+ {
73
+ "DOCKSIDE_NAME" => @dependency.name,
74
+ "DOCKSIDE_ENV" => @dependency.env,
75
+ "DOCKSIDE_PORT" => @dependency.port.to_s,
76
+ "DOCKSIDE_URL" => @dependency.url.to_s,
77
+ "DOCKSIDE_CONTAINER" => @dependency.container_name
78
+ }
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,16 @@
1
+ require "rails/railtie"
2
+
3
+ module Dockside
4
+ class Railtie < Rails::Railtie
5
+ config.dockside = ActiveSupport::OrderedOptions.new
6
+ config.dockside.autostart = nil
7
+
8
+ rake_tasks do
9
+ load File.expand_path("../tasks/dockside.rake", __dir__)
10
+ end
11
+
12
+ config.after_initialize do |app|
13
+ Autostart.boot(app.config.dockside, env: Rails.env, server_process: defined?(Rails::Server))
14
+ end
15
+ end
16
+ end