rascal 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,121 @@
1
+ module Rascal
2
+ module Docker
3
+ class Container
4
+ include IOHelper
5
+
6
+ def initialize(name, image)
7
+ @name = name
8
+ @prefixed_name = "#{NAME_PREFIX}#{name}"
9
+ @image = image
10
+ end
11
+
12
+ def download_missing
13
+ unless image_exists?
14
+ say "Downloading image for #{@name}"
15
+ Docker.interface.run(
16
+ 'pull',
17
+ @image,
18
+ stdout: stdout,
19
+ )
20
+ end
21
+ end
22
+
23
+ def running?
24
+ if id
25
+ container_info = Docker.interface.run(
26
+ 'container',
27
+ 'inspect',
28
+ id,
29
+ output: :json
30
+ ).first
31
+ !!container_info.dig('State', 'Running')
32
+ else
33
+ false
34
+ end
35
+ end
36
+
37
+ def exists?
38
+ !!id
39
+ end
40
+
41
+ def start(network: nil, network_alias: nil)
42
+ say "Starting container for #{@name}"
43
+ create(network: network, network_alias: network_alias) unless exists?
44
+ Docker.interface.run(
45
+ 'container',
46
+ 'start',
47
+ id,
48
+ )
49
+ end
50
+
51
+ def create(network: nil, network_alias: nil)
52
+ @id = Docker.interface.run(
53
+ 'container',
54
+ 'create',
55
+ '--name', @prefixed_name,
56
+ *(['--network', network.id] if network),
57
+ *(['--network-alias', network_alias] if network_alias),
58
+ @image,
59
+ output: :id
60
+ )
61
+ end
62
+
63
+ def run_and_attach(*command, env: {}, network: nil, volumes: [], working_dir: nil, allow_failure: false)
64
+ Docker.interface.run_and_attach(@image, *command,
65
+ env: env,
66
+ stdout: stdout,
67
+ stderr: stderr,
68
+ stdin: stdin,
69
+ network: network&.id,
70
+ volumes: volumes,
71
+ working_dir: working_dir,
72
+ allow_failure: allow_failure
73
+ )
74
+ end
75
+
76
+ def clean
77
+ stop_container if running?
78
+ remove_container if exists?
79
+ end
80
+
81
+ private
82
+
83
+ def id
84
+ @id ||= Docker.interface.run(
85
+ 'container',
86
+ 'ps',
87
+ '--all',
88
+ '--quiet',
89
+ '--filter', "name=^/#{@prefixed_name}$",
90
+ output: :id
91
+ )
92
+ end
93
+
94
+ def image_exists?
95
+ Docker.interface.run(
96
+ 'image',
97
+ 'inspect',
98
+ @image,
99
+ output: :json,
100
+ allow_failure: true,
101
+ ).first
102
+ end
103
+
104
+ def stop_container
105
+ Docker.interface.run(
106
+ 'container',
107
+ 'stop',
108
+ id,
109
+ )
110
+ end
111
+
112
+ def remove_container
113
+ Docker.interface.run(
114
+ 'container',
115
+ 'rm',
116
+ id,
117
+ )
118
+ end
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,111 @@
1
+ require 'json'
2
+ require 'open3'
3
+
4
+ module Rascal
5
+ module Docker
6
+ class Interface
7
+ class Error < Rascal::Error; end
8
+
9
+ def run(*command, output: :ignore, stdout: nil, redirect_io: {}, allow_failure: false)
10
+ save_stdout = ''
11
+ save_stderr = ''
12
+ exit_status = nil
13
+ popen3('docker', *stringify_command(command)) do |docker_stdin, docker_stdout, docker_stderr, wait_thr|
14
+ docker_stdin.close
15
+ output_threads = [
16
+ read_lines(docker_stdout, save_stdout, stdout),
17
+ read_lines(docker_stderr, save_stderr),
18
+ ]
19
+ exit_status = wait_thr.value
20
+ output_threads.each(&:join)
21
+ end
22
+ unless allow_failure || exit_status.success?
23
+ raise Error, "docker command '#{command.join(' ')}' failed with error:\n#{save_stderr}"
24
+ end
25
+ case output
26
+ when :json
27
+ begin
28
+ JSON.parse(save_stdout)
29
+ rescue JSON::ParserError
30
+ raise Error, "could not parse output of docker command '#{command.join(' ')}':\n#{save_stdout}"
31
+ end
32
+ when :id
33
+ save_stdout[/[0-9a-f]+/]
34
+ when :ignore
35
+ nil
36
+ else
37
+ raise ArgumentError, 'unknown option for :output'
38
+ end
39
+ end
40
+
41
+ def run_and_attach(image, *command, stdout: nil, stderr: nil, stdin: nil, env: {}, network: nil, volumes: [], working_dir: nil, allow_failure: false)
42
+ process_redirections = {}
43
+ args = []
44
+ if stdout
45
+ process_redirections[:out] = stdout
46
+ args += ['-a', 'STDOUT']
47
+ end
48
+ if stderr
49
+ process_redirections[:err] = stderr
50
+ args += ['-a', 'STDERR']
51
+ end
52
+ if stdin
53
+ process_redirections[:in] = stdin
54
+ args += ['-a', 'STDIN', '--interactive', '--tty']
55
+ end
56
+ if working_dir
57
+ args += ['-w', working_dir.to_s]
58
+ end
59
+ volumes.each do |volume|
60
+ args += ['-v', volume.to_param]
61
+ end
62
+ env.each do |key, value|
63
+ args += ['-e', "#{key}=#{value}"]
64
+ end
65
+ if network
66
+ args += ['--network', network.to_s]
67
+ end
68
+ exit_status = spawn(
69
+ env,
70
+ 'docker',
71
+ 'container',
72
+ 'run',
73
+ '--rm',
74
+ *args,
75
+ image.to_s,
76
+ *stringify_command(command),
77
+ process_redirections,
78
+ )
79
+ unless allow_failure || exit_status.success?
80
+ raise Error, "docker container run failed"
81
+ end
82
+ end
83
+
84
+ private
85
+
86
+ def stringify_command(command)
87
+ command.collect(&:to_s)
88
+ end
89
+
90
+ def spawn(*command)
91
+ pid = Process.spawn(*command)
92
+ Process.wait(pid)
93
+ $?
94
+ end
95
+
96
+ def popen3(*command, &block)
97
+ Open3.popen3(*command, &block)
98
+ end
99
+
100
+ def read_lines(io, save_to, output_to = nil)
101
+ Thread.new do
102
+ io.each_line do |l|
103
+ output_to&.write(l)
104
+ save_to << l
105
+ end
106
+ rescue IOError
107
+ end
108
+ end
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,42 @@
1
+ module Rascal
2
+ module Docker
3
+ class Network
4
+ def initialize(name)
5
+ @name = name
6
+ @prefixed_name = "#{NAME_PREFIX}#{name}"
7
+ end
8
+
9
+ def create
10
+ Docker.interface.run(
11
+ 'network',
12
+ 'create',
13
+ @prefixed_name,
14
+ )
15
+ end
16
+
17
+ def exists?
18
+ !!id
19
+ end
20
+
21
+ def clean
22
+ if exists?
23
+ Docker.interface.run(
24
+ 'network',
25
+ 'rm',
26
+ id,
27
+ )
28
+ end
29
+ end
30
+
31
+ def id
32
+ @id ||= Docker.interface.run(
33
+ 'network',
34
+ 'ls',
35
+ '--quiet',
36
+ '--filter', "name=^#{@prefixed_name}$",
37
+ output: :id,
38
+ )
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,42 @@
1
+ module Rascal
2
+ module Docker
3
+ module Volume
4
+ class Base
5
+ end
6
+
7
+ class Named < Base
8
+ def initialize(name, container_path)
9
+ @prefixed_name = "#{NAME_PREFIX}#{name}"
10
+ @container_path = container_path
11
+ end
12
+
13
+ def to_param
14
+ "#{@prefixed_name}:#{@container_path}"
15
+ end
16
+
17
+ def clean
18
+ Docker.interface.run(
19
+ 'volume',
20
+ 'rm',
21
+ @prefixed_name,
22
+ )
23
+ end
24
+ end
25
+
26
+ class Bind < Base
27
+ def initialize(local_path, container_path)
28
+ @local_path = local_path
29
+ @container_path = container_path
30
+ end
31
+
32
+ def to_param
33
+ "#{@local_path}:#{@container_path}"
34
+ end
35
+
36
+ def clean
37
+ # nothing to do
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,18 @@
1
+ module Rascal
2
+ module Docker
3
+ NAME_PREFIX = 'rascal-'
4
+
5
+ autoload :Container, 'rascal/docker/container'
6
+ autoload :Interface, 'rascal/docker/interface'
7
+ autoload :Network, 'rascal/docker/network'
8
+ autoload :Volume, 'rascal/docker/volume'
9
+
10
+ class << self
11
+ attr_writer :interface
12
+
13
+ def interface
14
+ @interface ||= Interface.new
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,47 @@
1
+ module Rascal
2
+ class Environment
3
+ attr_reader :name
4
+
5
+ def initialize(name, image:, env_variables: {}, services: [], volumes: [], before_shell: [], working_dir: nil)
6
+ @name = name
7
+ @network = Docker::Network.new(name)
8
+ @container = Docker::Container.new(name, image)
9
+ @env_variables = env_variables
10
+ @services = services
11
+ @volumes = volumes
12
+ @working_dir = working_dir
13
+ @before_shell = before_shell
14
+ end
15
+
16
+ def run_shell
17
+ download_missing
18
+ start_services
19
+ command = [*@before_shell, 'bash'].join(';')
20
+ @container.run_and_attach('bash', '-c', command,
21
+ env: @env_variables,
22
+ network: @network,
23
+ volumes: @volumes,
24
+ working_dir: @working_dir,
25
+ allow_failure: true
26
+ )
27
+ end
28
+
29
+ def clean
30
+ @services.each(&:clean)
31
+ @network.clean
32
+ @volumes.each(&:clean)
33
+ end
34
+
35
+ private
36
+
37
+ def download_missing
38
+ @container.download_missing
39
+ @services.each(&:download_missing)
40
+ end
41
+
42
+ def start_services
43
+ @network.create unless @network.exists?
44
+ @services.each { |s| s.start_if_stopped(network: @network) }
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,117 @@
1
+ require 'yaml'
2
+
3
+ module Rascal
4
+ module EnvironmentsDefinition
5
+ class Gitlab
6
+ class << self
7
+ def detect(path)
8
+ if path.directory?
9
+ path = path.join('.gitlab-ci.yml')
10
+ end
11
+ if path.file?
12
+ new(path)
13
+ end
14
+ end
15
+ end
16
+
17
+ class Config
18
+ def initialize(config, prefix)
19
+ @config = config
20
+ @prefix = prefix
21
+ end
22
+
23
+ def get(key, *default)
24
+ if @config.has_key?(key)
25
+ @config[key]
26
+ elsif default.size > 0
27
+ default.first
28
+ else
29
+ raise Error.new("missing config for '#{@prefix}.#{key}'")
30
+ end
31
+ end
32
+ end
33
+
34
+
35
+ def initialize(config_path)
36
+ @info = parse_definition(config_path.read)
37
+ @repo_dir = config_path.parent
38
+ @rascal_config = @info.fetch('.rascal', {})
39
+ end
40
+
41
+ def environment(name)
42
+ environments.detect do |e|
43
+ e.name == name
44
+ end
45
+ end
46
+
47
+ def available_environment_names
48
+ environments.collect(&:name).sort
49
+ end
50
+
51
+ private
52
+
53
+ def parse_definition(yaml)
54
+ YAML.safe_load(yaml, [], [], true)
55
+ end
56
+
57
+ def environments
58
+ @environments ||= begin
59
+ @info.collect do |key, environment_config|
60
+ config = Config.new(deep_merge(environment_config, @rascal_config), key)
61
+ docker_repo_dir = config.get('repo_dir')
62
+ unless key.start_with?('.')
63
+ Environment.new(key,
64
+ image: config.get('image'),
65
+ env_variables: (config.get('variables', {})),
66
+ services: build_services(key, config.get('services', [])),
67
+ volumes: [build_repo_volume(docker_repo_dir), *build_volumes(key, config.get('volumes', {}))],
68
+ before_shell: config.get('before_shell', []),
69
+ working_dir: docker_repo_dir,
70
+ )
71
+ end
72
+ end.compact
73
+ end
74
+ end
75
+
76
+ def deep_merge(hash1, hash2)
77
+ if hash1.is_a?(Hash) && hash2.is_a?(Hash)
78
+ result = {}
79
+ hash1.each do |key1, value1|
80
+ if hash2.has_key?(key1)
81
+ result[key1] = deep_merge(value1, hash2[key1])
82
+ else
83
+ result[key1] = value1
84
+ end
85
+ end
86
+ hash2.each do |key2, value2|
87
+ result[key2] ||= value2
88
+ end
89
+ result
90
+ else
91
+ hash2
92
+ end
93
+ end
94
+
95
+ def build_services(name, services)
96
+ services.collect do |service_config|
97
+ service_alias = service_config['alias']
98
+ Service.new("#{name}_#{service_alias}",
99
+ alias_name: service_config['alias'],
100
+ image: service_config['name'],
101
+ )
102
+ end
103
+ end
104
+
105
+ def build_repo_volume(docker_repo_dir)
106
+ Docker::Volume::Bind.new(@repo_dir, docker_repo_dir)
107
+ end
108
+
109
+ def build_volumes(name, volume_config)
110
+ volume_config.collect do |volume_name, docker_path|
111
+ Docker::Volume::Named.new("#{name}-#{volume_name}", docker_path)
112
+ end
113
+ end
114
+ end
115
+ end
116
+ end
117
+
@@ -0,0 +1,23 @@
1
+ module Rascal
2
+ module EnvironmentsDefinition
3
+ autoload :Gitlab, 'rascal/environments_definition/gitlab'
4
+
5
+ class << self
6
+ def detect(working_dir)
7
+ definition_formats.each do |format|
8
+ definition = format.detect(working_dir)
9
+ return definition if definition
10
+ end
11
+ nil
12
+ end
13
+
14
+ private
15
+
16
+ def definition_formats
17
+ [
18
+ Gitlab
19
+ ]
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,30 @@
1
+ module Rascal
2
+ module IOHelper
3
+ class << self
4
+ attr_accessor :stdout, :stdin, :stderr
5
+
6
+ def setup
7
+ @stdout = $stdout
8
+ @stderr = $stderr
9
+ @stdin = $stdin
10
+ end
11
+ end
12
+ setup
13
+
14
+ def say(message)
15
+ stdout.puts(message)
16
+ end
17
+
18
+ def stdout
19
+ IOHelper.stdout
20
+ end
21
+
22
+ def stderr
23
+ IOHelper.stderr
24
+ end
25
+
26
+ def stdin
27
+ IOHelper.stdin
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,25 @@
1
+ module Rascal
2
+ class Service
3
+ attr_reader :name
4
+
5
+ def initialize(name, image:, alias_name:)
6
+ @name = name
7
+ @container = Docker::Container.new(name, image)
8
+ @alias = alias_name
9
+ end
10
+
11
+ def download_missing
12
+ @container.download_missing
13
+ end
14
+
15
+ def start_if_stopped(network: nil)
16
+ unless @container.running?
17
+ @container.start(network: network, network_alias: @alias)
18
+ end
19
+ end
20
+
21
+ def clean
22
+ @container.clean
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,3 @@
1
+ module Rascal
2
+ VERSION = "0.1.0"
3
+ end
data/lib/rascal.rb ADDED
@@ -0,0 +1,11 @@
1
+ require "rascal/version"
2
+
3
+ module Rascal
4
+ class Error < StandardError; end
5
+
6
+ autoload :Docker, 'rascal/docker'
7
+ autoload :Environment, 'rascal/environment'
8
+ autoload :EnvironmentsDefinition, 'rascal/environments_definition'
9
+ autoload :IOHelper, 'rascal/io_helper'
10
+ autoload :Service, 'rascal/service'
11
+ end
data/rascal.gemspec ADDED
@@ -0,0 +1,37 @@
1
+
2
+ lib = File.expand_path("../lib", __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require "rascal/version"
5
+
6
+ Gem::Specification.new do |spec|
7
+ spec.name = "rascal"
8
+ spec.version = Rascal::VERSION
9
+ spec.authors = ["Tobias Kraze"]
10
+ spec.email = ["tobias.kraze@makandra.de"]
11
+
12
+ spec.summary = "Spin up CI environments locally."
13
+ spec.homepage = "https://github.com/makandra/rascal"
14
+ spec.license = "MIT"
15
+
16
+ # Prevent pushing this gem to RubyGems.org. To allow pushes either set the 'allowed_push_host'
17
+ # to allow pushing to a single host or delete this section to allow pushing to any host.
18
+ if spec.respond_to?(:metadata)
19
+ spec.metadata["homepage_uri"] = spec.homepage
20
+ spec.metadata["source_code_uri"] = "https://github.com/makandra/rascal"
21
+ spec.metadata["changelog_uri"] = "https://github.com/makandra/rascal/blob/master/CHANGELOG.md"
22
+ else
23
+ raise "RubyGems 2.0 or newer is required to protect against " \
24
+ "public gem pushes."
25
+ end
26
+
27
+ # Specify which files should be added to the gem when it is released.
28
+ # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
29
+ spec.files = Dir.chdir(File.expand_path('..', __FILE__)) do
30
+ `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
31
+ end
32
+ spec.bindir = "exe"
33
+ spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
34
+ spec.require_paths = ["lib"]
35
+
36
+ spec.add_dependency "thor", "~> 0.20.3"
37
+ end