rascal 0.1.0

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