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,120 @@
1
+ require "net/http"
2
+ require "socket"
3
+
4
+ module Dockside
5
+ # Probes that tell whether a started container really answers.
6
+ module Readiness
7
+ def self.probe_for(dependency, spec)
8
+ case spec
9
+ when "auto" then dependency.port ? Tcp.new(dependency.port) : None.new
10
+ when "none" then None.new
11
+ when Hash then probe_from_hash(dependency, spec)
12
+ else raise ConfigError, "#{dependency.name}: ready must be auto, none, {http:}, {log:} or {command:}, got #{spec.inspect}"
13
+ end
14
+ end
15
+
16
+ def self.probe_from_hash(dependency, spec)
17
+ if spec.key?("http")
18
+ Http.new("#{dependency.url}#{spec["http"]}", status: spec["status"])
19
+ elsif spec.key?("log")
20
+ Log.new(dependency, spec["log"])
21
+ elsif spec.key?("command")
22
+ Command.new(dependency, spec["command"])
23
+ else
24
+ probe_for(dependency, "auto")
25
+ end
26
+ end
27
+
28
+ # Polls the probe until it answers. Raises ReadyTimeout, whose message the caller enriches with logs.
29
+ def self.wait(probe, timeout:)
30
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
31
+ until probe.ready?
32
+ raise ReadyTimeout if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
33
+
34
+ sleep Dockside.poll_interval
35
+ end
36
+ end
37
+
38
+ class Tcp
39
+ def initialize(port)
40
+ @port = port
41
+ end
42
+
43
+ # Docker accepts connections on a published port before the service inside listens and closes
44
+ # them right away, so a connection only counts when it stays open or the service says something.
45
+ def ready?
46
+ Socket.tcp("127.0.0.1", @port, connect_timeout: 1) do |socket|
47
+ return true unless socket.wait_readable(0.2)
48
+
49
+ !socket.read_nonblock(1, exception: false).nil?
50
+ end
51
+ rescue SystemCallError, IOError
52
+ false
53
+ end
54
+
55
+ def to_s
56
+ "port #{@port} accepts connections"
57
+ end
58
+ end
59
+
60
+ class Http
61
+ def initialize(url, status: nil)
62
+ @uri = URI(url)
63
+ @status = status
64
+ end
65
+
66
+ def ready?
67
+ response = Net::HTTP.start(@uri.host, @uri.port, open_timeout: 1, read_timeout: 5) do |http|
68
+ http.get(@uri.request_uri)
69
+ end
70
+ @status.nil? || response.code.to_i == @status
71
+ rescue SystemCallError, IOError, Net::OpenTimeout, Net::ReadTimeout
72
+ false
73
+ end
74
+
75
+ def to_s
76
+ "#{@uri} answers#{" with status #{@status}" if @status}"
77
+ end
78
+ end
79
+
80
+ class Log
81
+ def initialize(dependency, pattern)
82
+ @dependency = dependency
83
+ @pattern = Regexp.new(pattern)
84
+ end
85
+
86
+ def ready?
87
+ @dependency.logs(tail: 200).match?(@pattern)
88
+ end
89
+
90
+ def to_s
91
+ "log matches #{@pattern.source}"
92
+ end
93
+ end
94
+
95
+ class Command
96
+ def initialize(dependency, command)
97
+ @dependency = dependency
98
+ @command = command
99
+ end
100
+
101
+ def ready?
102
+ @dependency.exec_succeeds?(@command)
103
+ end
104
+
105
+ def to_s
106
+ "command #{Array(@command).join(" ")} succeeds"
107
+ end
108
+ end
109
+
110
+ class None
111
+ def ready?
112
+ true
113
+ end
114
+
115
+ def to_s
116
+ "container is running"
117
+ end
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,55 @@
1
+ module Dockside
2
+ # All dependencies of the app for one environment, built once from the resolved compose file.
3
+ class Registry
4
+ include Enumerable
5
+
6
+ def initialize(project)
7
+ @project = project
8
+ @dependencies = build
9
+ end
10
+
11
+ def names
12
+ @dependencies.keys
13
+ end
14
+
15
+ def fetch(name)
16
+ @dependencies.fetch(name.to_sym) do
17
+ raise UnknownDependency, "Unknown dependency #{name}. Known: #{names.join(", ")}."
18
+ end
19
+ end
20
+ alias_method :[], :fetch
21
+
22
+ def each(&block)
23
+ @dependencies.each_value(&block)
24
+ end
25
+
26
+ def autostart
27
+ select(&:autostart?)
28
+ end
29
+
30
+ private
31
+
32
+ def build
33
+ resolved = @project.resolve!
34
+ @project.service_names.to_h do |service|
35
+ dependency = Dependency.new(
36
+ name: service,
37
+ project: @project,
38
+ settings: @project.settings(service),
39
+ config: resolved.fetch(service),
40
+ shared: shared?(service)
41
+ )
42
+ [service.to_sym, dependency]
43
+ end
44
+ end
45
+
46
+ def shared?(service)
47
+ port = @project.host_port(service, @project.env)
48
+ return false if port.nil? || port != @project.host_port(service, @project.other_env)
49
+
50
+ Dockside.log "warning: #{service} uses port #{port} in development and test, " \
51
+ "so both environments share one container and its data"
52
+ true
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,55 @@
1
+ require "open3"
2
+
3
+ module Dockside
4
+ # Runs commands given as argv arrays, never as shell strings.
5
+ class Runner
6
+ Result = Struct.new(:argv, :stdout, :stderr, :exit_status) do
7
+ def success?
8
+ exit_status.zero?
9
+ end
10
+ end
11
+
12
+ # Returns a Result. With stream: true the output is also printed while the command runs.
13
+ def run(argv, env: {}, stdin: nil, chdir: nil, stream: false)
14
+ if stream
15
+ stream_run(argv, env: env, chdir: chdir)
16
+ else
17
+ capture_run(argv, env: env, stdin: stdin, chdir: chdir)
18
+ end
19
+ rescue Errno::ENOENT
20
+ raise DockerMissing, "#{argv.first} was not found. Install Docker with the Compose plugin " \
21
+ "(https://docs.docker.com/get-docker/) or start with DOCKSIDE_AUTOSTART=0 to skip the containers."
22
+ end
23
+
24
+ def run!(argv, **options)
25
+ result = run(argv, **options)
26
+ raise CommandFailed.new(result) unless result.success?
27
+
28
+ result
29
+ end
30
+
31
+ private
32
+
33
+ def capture_run(argv, env:, stdin:, chdir:)
34
+ options = {}
35
+ options[:chdir] = chdir.to_s if chdir
36
+ options[:stdin_data] = stdin if stdin
37
+ stdout, stderr, status = Open3.capture3(env, *argv, **options)
38
+ Result.new(argv: argv, stdout: stdout, stderr: stderr, exit_status: status.exitstatus)
39
+ end
40
+
41
+ def stream_run(argv, env:, chdir:)
42
+ options = {}
43
+ options[:chdir] = chdir.to_s if chdir
44
+ output = +""
45
+ Open3.popen2e(env, *argv, **options) do |input, combined, wait|
46
+ input.close
47
+ combined.each_line do |line|
48
+ Dockside.output.print(line)
49
+ output << line
50
+ end
51
+ Result.new(argv: argv, stdout: output, stderr: "", exit_status: wait.value.exitstatus)
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,38 @@
1
+ module Dockside
2
+ # The x-dockside part of a service, merged for one environment.
3
+ Settings = Struct.new(:ready, :timeout, :after_start, :autostart)
4
+
5
+ class Settings
6
+ KEYS = %w[ready timeout after_start autostart].freeze
7
+ DEFAULTS = {"ready" => "auto", "timeout" => 300, "after_start" => [], "autostart" => true}.freeze
8
+
9
+ def self.for(service, definition, env)
10
+ extension = definition["x-dockside"] || {}
11
+ environment_block = extension[env] || {}
12
+ values = DEFAULTS.merge(extension.slice(*KEYS)).merge(environment_block.slice(*KEYS))
13
+ new(**values.transform_keys(&:to_sym)).tap do |settings|
14
+ settings.after_start = Provisioner::Step.parse_all(service, settings.after_start)
15
+ end
16
+ end
17
+
18
+ def self.validate(service, extension)
19
+ return if extension.nil?
20
+
21
+ unknown = extension.keys - KEYS - Project::ENVIRONMENTS
22
+ return if unknown.empty?
23
+
24
+ raise ConfigError, "Unknown x-dockside key#{"s" if unknown.size > 1} #{unknown.join(", ")} for service #{service}. " \
25
+ "Allowed: #{(KEYS + Project::ENVIRONMENTS).join(", ")}."
26
+ end
27
+
28
+ # The compose keys of the environment block, the part compose has to merge.
29
+ def self.compose_overrides(definition, env)
30
+ block = definition.dig("x-dockside", env) || {}
31
+ block.except(*KEYS)
32
+ end
33
+
34
+ def autostart?
35
+ autostart != false
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,3 @@
1
+ module Dockside
2
+ VERSION = "0.1.0"
3
+ end
data/lib/dockside.rb ADDED
@@ -0,0 +1,101 @@
1
+ require "json"
2
+ require "pathname"
3
+ require "yaml"
4
+ require "zeitwerk"
5
+
6
+ loader = Zeitwerk::Loader.for_gem
7
+ loader.ignore("#{__dir__}/generators")
8
+ loader.ignore("#{__dir__}/tasks")
9
+ loader.ignore("#{__dir__}/dockside/railtie.rb")
10
+ loader.ignore("#{__dir__}/dockside/errors.rb")
11
+ loader.setup
12
+
13
+ require "dockside/errors"
14
+
15
+ # Starts the Docker containers an app needs, described in config/dockside.yml.
16
+ module Dockside
17
+ class << self
18
+ attr_writer :root, :env, :app_name, :runner, :output
19
+ attr_accessor :poll_interval
20
+
21
+ def root
22
+ @root ||= Pathname.new((defined?(Rails) && Rails.root) ? Rails.root : Dir.pwd)
23
+ end
24
+
25
+ def env
26
+ @env ||= (defined?(Rails) && Rails.env.present?) ? Rails.env.to_s : ENV.fetch("RAILS_ENV", "development")
27
+ end
28
+
29
+ def app_name
30
+ @app_name ||= default_app_name
31
+ end
32
+
33
+ def runner
34
+ @runner ||= Runner.new
35
+ end
36
+
37
+ def output
38
+ @output || $stdout
39
+ end
40
+
41
+ def project
42
+ @project ||= Project.new(root: root, env: env, app_name: app_name, runner: runner)
43
+ end
44
+
45
+ def registry
46
+ @registry ||= Registry.new(project)
47
+ end
48
+
49
+ def reset!
50
+ @root = @env = @app_name = @runner = @project = @registry = nil
51
+ end
52
+
53
+ def names
54
+ registry.names
55
+ end
56
+
57
+ def fetch(name)
58
+ registry.fetch(name)
59
+ end
60
+
61
+ def ensure_running!(*names)
62
+ dependencies = names.empty? ? registry.autostart : names.map { |name| fetch(name) }
63
+ dependencies.each(&:ensure_running!)
64
+ end
65
+
66
+ def down
67
+ registry.each(&:remove_shared_container)
68
+ project.compose.down
69
+ end
70
+
71
+ def log(message)
72
+ output.puts("dockside: #{message}")
73
+ end
74
+
75
+ def respond_to_missing?(name, include_private = false)
76
+ registry.names.include?(name) || super
77
+ end
78
+
79
+ def method_missing(name, *args)
80
+ if args.empty? && registry.names.include?(name)
81
+ fetch(name)
82
+ else
83
+ super
84
+ end
85
+ end
86
+
87
+ private
88
+
89
+ def default_app_name
90
+ if defined?(Rails) && Rails.application
91
+ Rails.application.class.module_parent_name.underscore.dasherize
92
+ else
93
+ root.basename.to_s.downcase.tr("_ ", "--")
94
+ end
95
+ end
96
+ end
97
+ end
98
+
99
+ Dockside.poll_interval = 1
100
+
101
+ require "dockside/railtie" if defined?(Rails::Railtie)
@@ -0,0 +1,15 @@
1
+ require "rails/generators"
2
+
3
+ module Dockside
4
+ module Generators
5
+ class InstallGenerator < Rails::Generators::Base
6
+ source_root File.expand_path("templates", __dir__)
7
+
8
+ desc "Creates config/dockside.yml with a commented example"
9
+
10
+ def create_compose_file
11
+ template "dockside.yml.tt", "config/dockside.yml"
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,25 @@
1
+ # The containers this app needs in development and test. dockside starts them with `rails server` and
2
+ # before the first test. This is a normal compose file; only the `x-dockside` block belongs to the gem.
3
+ #
4
+ # See https://github.com/renuo/dockside for every option.
5
+ services:
6
+ # minio:
7
+ # image: minio/minio
8
+ # command: server /data
9
+ # environment:
10
+ # MINIO_ROOT_USER: minio
11
+ # MINIO_ROOT_PASSWORD: minio-secret
12
+ # ports: ["9000:9000"]
13
+ # volumes:
14
+ # - ./tmp/dockside/${RAILS_ENV}/minio:/data
15
+ # x-dockside:
16
+ # ready: { http: "/minio/health/live" } # auto (port answers), none, {http:}, {log:} or {command:}
17
+ # timeout: 60 # seconds to wait until ready, default 300
18
+ # after_start: # runs once, after the container is ready
19
+ # - exec: mc alias set local http://localhost:9000 $MINIO_ROOT_USER $MINIO_ROOT_PASSWORD
20
+ # - exec: mc mb --ignore-existing local/uploads
21
+ # test: # what differs in the test environment
22
+ # ports: ["9010:9000"]
23
+ # volumes: []
24
+ # Remove the {} below when you add your first service.
25
+ {}
@@ -0,0 +1,38 @@
1
+ namespace :dockside do
2
+ names = ->(args) { [args[:names], *args.extras].compact.map(&:strip) }
3
+
4
+ desc "Start the containers (BUILD=1 rebuilds, PULL=1 pulls). Names separated by commas."
5
+ task :up, [:names] => :environment do |_task, args|
6
+ Dockside::Commands.up(names.call(args), build: ENV["BUILD"] == "1", pull: ENV["PULL"] == "1")
7
+ end
8
+
9
+ desc "Stop the containers, keep the data"
10
+ task :stop, [:names] => :environment do |_task, args|
11
+ Dockside::Commands.stop(names.call(args))
12
+ end
13
+
14
+ desc "Remove the containers, keep the data"
15
+ task down: :environment do
16
+ Dockside::Commands.down
17
+ end
18
+
19
+ desc "Remove the containers and the data, start again"
20
+ task :reset, [:names] => :environment do |_task, args|
21
+ Dockside::Commands.reset(names.call(args))
22
+ end
23
+
24
+ desc "Show every container, its state and URL"
25
+ task status: :environment do
26
+ Dockside::Commands.status
27
+ end
28
+
29
+ desc "Show the container log (TAIL=200, FOLLOW=1)"
30
+ task :logs, [:name] => :environment do |_task, args|
31
+ Dockside::Commands.logs(args.fetch(:name), tail: ENV.fetch("TAIL", 100).to_i, follow: ENV["FOLLOW"] == "1")
32
+ end
33
+
34
+ desc "Show the compose file the gem really uses"
35
+ task config: :environment do
36
+ Dockside::Commands.config
37
+ end
38
+ end
metadata ADDED
@@ -0,0 +1,97 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: dockside
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Alessandro Rodi
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: railties
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '7.1'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '7.1'
26
+ - !ruby/object:Gem::Dependency
27
+ name: zeitwerk
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '2.6'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '2.6'
40
+ description: Describe the containers your app needs in a compose file. dockside starts
41
+ them before the server and the test suite, waits until they are ready, sets them
42
+ up the first time and keeps development and test apart.
43
+ email:
44
+ - alessandro.rodi@renuo.ch
45
+ executables: []
46
+ extensions: []
47
+ extra_rdoc_files: []
48
+ files:
49
+ - CHANGELOG.md
50
+ - LICENSE.txt
51
+ - README.md
52
+ - lib/dockside.rb
53
+ - lib/dockside/autostart.rb
54
+ - lib/dockside/commands.rb
55
+ - lib/dockside/compose.rb
56
+ - lib/dockside/dependency.rb
57
+ - lib/dockside/docker.rb
58
+ - lib/dockside/errors.rb
59
+ - lib/dockside/override.rb
60
+ - lib/dockside/project.rb
61
+ - lib/dockside/provisioner.rb
62
+ - lib/dockside/railtie.rb
63
+ - lib/dockside/readiness.rb
64
+ - lib/dockside/registry.rb
65
+ - lib/dockside/runner.rb
66
+ - lib/dockside/settings.rb
67
+ - lib/dockside/version.rb
68
+ - lib/generators/dockside/install_generator.rb
69
+ - lib/generators/dockside/templates/dockside.yml.tt
70
+ - lib/tasks/dockside.rake
71
+ homepage: https://github.com/coorasse/dockside
72
+ licenses:
73
+ - MIT
74
+ metadata:
75
+ homepage_uri: https://github.com/coorasse/dockside
76
+ source_code_uri: https://github.com/coorasse/dockside
77
+ changelog_uri: https://github.com/coorasse/dockside/blob/main/CHANGELOG.md
78
+ funding_uri: https://github.com/sponsors/coorasse
79
+ rubygems_mfa_required: 'true'
80
+ rdoc_options: []
81
+ require_paths:
82
+ - lib
83
+ required_ruby_version: !ruby/object:Gem::Requirement
84
+ requirements:
85
+ - - ">="
86
+ - !ruby/object:Gem::Version
87
+ version: '3.2'
88
+ required_rubygems_version: !ruby/object:Gem::Requirement
89
+ requirements:
90
+ - - ">="
91
+ - !ruby/object:Gem::Version
92
+ version: '0'
93
+ requirements: []
94
+ rubygems_version: 4.0.8
95
+ specification_version: 4
96
+ summary: Starts the Docker containers your Rails app needs, when your app starts.
97
+ test_files: []