cult 0.1.1.pre

Sign up to get free protection for your applications and to get access to all the features.
Files changed (55) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +9 -0
  3. data/Gemfile +4 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +240 -0
  6. data/Rakefile +6 -0
  7. data/cult +1 -0
  8. data/cult.gemspec +38 -0
  9. data/doc/welcome.txt +1 -0
  10. data/exe/cult +86 -0
  11. data/lib/cult/artifact.rb +45 -0
  12. data/lib/cult/cli/common.rb +265 -0
  13. data/lib/cult/cli/console_cmd.rb +124 -0
  14. data/lib/cult/cli/cri_extensions.rb +84 -0
  15. data/lib/cult/cli/init_cmd.rb +116 -0
  16. data/lib/cult/cli/load.rb +26 -0
  17. data/lib/cult/cli/node_cmd.rb +205 -0
  18. data/lib/cult/cli/provider_cmd.rb +123 -0
  19. data/lib/cult/cli/role_cmd.rb +149 -0
  20. data/lib/cult/cli/task_cmd.rb +140 -0
  21. data/lib/cult/commander.rb +103 -0
  22. data/lib/cult/config.rb +22 -0
  23. data/lib/cult/definition.rb +112 -0
  24. data/lib/cult/driver.rb +88 -0
  25. data/lib/cult/drivers/common.rb +192 -0
  26. data/lib/cult/drivers/digital_ocean_driver.rb +179 -0
  27. data/lib/cult/drivers/linode_driver.rb +282 -0
  28. data/lib/cult/drivers/load.rb +26 -0
  29. data/lib/cult/drivers/script_driver.rb +27 -0
  30. data/lib/cult/drivers/vultr_driver.rb +217 -0
  31. data/lib/cult/named_array.rb +129 -0
  32. data/lib/cult/node.rb +62 -0
  33. data/lib/cult/project.rb +169 -0
  34. data/lib/cult/provider.rb +134 -0
  35. data/lib/cult/role.rb +213 -0
  36. data/lib/cult/skel.rb +85 -0
  37. data/lib/cult/task.rb +64 -0
  38. data/lib/cult/template.rb +92 -0
  39. data/lib/cult/transferable.rb +61 -0
  40. data/lib/cult/version.rb +3 -0
  41. data/lib/cult.rb +4 -0
  42. data/skel/.cultconsolerc +4 -0
  43. data/skel/.cultrc.erb +29 -0
  44. data/skel/README.md.erb +22 -0
  45. data/skel/keys/.keep +0 -0
  46. data/skel/nodes/.keep +0 -0
  47. data/skel/providers/.keep +0 -0
  48. data/skel/roles/all/role.json +4 -0
  49. data/skel/roles/all/tasks/00000-do-something-cool +27 -0
  50. data/skel/roles/bootstrap/files/cult-motd +45 -0
  51. data/skel/roles/bootstrap/role.json +4 -0
  52. data/skel/roles/bootstrap/tasks/00000-set-hostname +22 -0
  53. data/skel/roles/bootstrap/tasks/00001-add-cult-user +21 -0
  54. data/skel/roles/bootstrap/tasks/00002-install-cult-motd +9 -0
  55. metadata +183 -0
@@ -0,0 +1,103 @@
1
+ require 'net/ssh'
2
+ require 'net/scp'
3
+ require 'shellwords'
4
+ require 'rainbow'
5
+ require 'rubygems/package'
6
+ require 'rubygems/package/tar_writer'
7
+
8
+ module Cult
9
+
10
+ class Bundle
11
+ attr_reader :tar
12
+ def initialize(io, &block)
13
+ @tar = Gem::Package::TarWriter.new(io)
14
+ if block_given?
15
+ begin
16
+ yield self
17
+ ensure
18
+ @tar.close
19
+ @tar = nil
20
+ end
21
+ end
22
+ end
23
+
24
+ def add_file(project, role, node, transferable)
25
+ data = transferable.contents(project, role, node, pwd: role.path)
26
+ tar.add_file(transferable.remote_path, transferable.file_mode) do |io|
27
+ io.write(data)
28
+ end
29
+ end
30
+ end
31
+
32
+ class Commander
33
+ attr_reader :project
34
+ attr_reader :node
35
+
36
+ def initialize(project:, node:)
37
+ @project = project
38
+ @node = node
39
+ end
40
+
41
+ def esc(s)
42
+ Shellwords.escape(s)
43
+ end
44
+
45
+ def send_bundle(ssh, role)
46
+ io = StringIO.new
47
+ Bundle.new(io) do |bundle|
48
+ puts "Building bundle..."
49
+ role.build_order.each do |r|
50
+ (r.artifacts + r.tasks).each do |transferable|
51
+ bundle.add_file(project, r, node, transferable)
52
+ end
53
+ end
54
+ end
55
+ filename = "cult-#{role.name}.tar"
56
+ puts "Uploading bundle #{filename}..."
57
+
58
+ scp = Net::SCP.new(ssh)
59
+ io.rewind
60
+ scp.upload!(io, filename)
61
+ ssh.exec! "tar -xf #{esc(filename)} && rm #{esc(filename)}"
62
+ end
63
+
64
+ def install!(role)
65
+ connect(user: role.definition['user']) do |ssh|
66
+ send_bundle(ssh, role)
67
+
68
+ role.build_order.each do |r|
69
+ puts "Installing role: #{Rainbow(r.name).blue}"
70
+ working_dir = r.remote_path
71
+ r.tasks.each do |t|
72
+ puts "Executing: #{t.remote_path}"
73
+ task_bin = r.relative_path(t.path)
74
+ res = ssh.exec! <<~BASH
75
+ cd #{esc(working_dir)}; \
76
+ if [ ! -f ./#{esc(task_bin)}.success ]; then \
77
+ touch ./#{esc(task_bin)}.attempt && \
78
+ ./#{esc(task_bin)} && \
79
+ mv ./#{esc(task_bin)}.attempt ./#{esc(task_bin)}.success; \
80
+ fi
81
+ BASH
82
+ unless res.empty?
83
+ puts Rainbow(res.gsub(/^/, ' ')).darkgray.italic
84
+ end
85
+ end
86
+ end
87
+ end
88
+ end
89
+
90
+ def bootstrap!
91
+ bootstrap_role = CLI.fetch_item('bootstrap', from: Role)
92
+ install!(bootstrap_role)
93
+ end
94
+
95
+ def connect(user:, &block)
96
+ puts "Connecting with user=#{user}"
97
+ Net::SSH.start(node.host, user) do |ssh|
98
+ yield ssh
99
+ end
100
+ end
101
+ end
102
+
103
+ end
@@ -0,0 +1,22 @@
1
+ module Cult
2
+ module_function
3
+ def project=(project)
4
+ @project = project
5
+ end
6
+
7
+ def project
8
+ @project
9
+ end
10
+
11
+ # This is a a mode we're considering: when it's set, certain objects
12
+ # will be frozen when they're created, and creating a "logically the same"
13
+ # instance (e.g., role with same name in the same project) will return
14
+ # the actual same object each time.
15
+ #
16
+ # I'm not sure if we'll commit to this, but having this toggle lets us
17
+ # easily see what breaks.
18
+ def immutable?
19
+ ENV['CULT_IMMUTABLE'] == '1'
20
+ end
21
+
22
+ end
@@ -0,0 +1,112 @@
1
+ require 'yaml'
2
+ require 'json'
3
+ require 'forwardable'
4
+
5
+ require 'cult/template'
6
+
7
+ module Cult
8
+ class Definition
9
+ attr_reader :object
10
+ attr_reader :bag
11
+
12
+ extend Forwardable
13
+ def_delegators :object, :definition_parameters, :definition_path,
14
+ :definition_parents
15
+
16
+
17
+ def initialize(object)
18
+ @object = object
19
+ end
20
+
21
+
22
+ def inspect
23
+ "\#<#{self.class.name} " +
24
+ "object: #{object.inspect}, " +
25
+ "params: #{definition_parameters}, " +
26
+ "parents: #{definition_parents}, " +
27
+ "direct_values: #{bag}>"
28
+ end
29
+ alias_method :to_s, :inspect
30
+
31
+
32
+ def filenames
33
+ Array(definition_path).map do |dp|
34
+ attempt = [ "#{dp}.yaml", "#{dp}.yml", "#{dp}.json" ]
35
+ existing = attempt.select do |filename|
36
+ File.exist?(filename)
37
+ end
38
+ if existing.size > 1
39
+ raise RuntimeError, "conflicting definition files: #{existing}"
40
+ end
41
+ existing[0]
42
+ end.compact
43
+ end
44
+
45
+
46
+ def decoder_for(filename)
47
+ @decoder_for ||= begin
48
+ case filename
49
+ when nil
50
+ nil
51
+ when /\.json\z/
52
+ JSON.method(:parse)
53
+ when /\.ya?ml\z/
54
+ YAML.method(:safe_load)
55
+ else
56
+ fail RuntimeError, "No decoder for file type: #{filename}"
57
+ end
58
+ end
59
+ end
60
+
61
+
62
+ def bag
63
+ @bag ||= begin
64
+ result = {}
65
+ filenames.each do |filename|
66
+ erb = Template.new(definition_parameters)
67
+ contents = erb.process(File.read(filename))
68
+ result.merge! decoder_for(filename).call(contents)
69
+ end
70
+ result
71
+ end
72
+ end
73
+ alias_method :to_h, :bag
74
+
75
+
76
+ def direct(k)
77
+ fail "Use string keys" unless k.is_a?(String)
78
+ bag[k]
79
+ end
80
+
81
+
82
+ def [](k)
83
+ fail "Use string keys" unless k.is_a?(String)
84
+ if bag.key?(k)
85
+ bag[k]
86
+ else
87
+ parent_responses = definition_parents.map do |p|
88
+ [p, p.definition[k]]
89
+ end.reject do |k, v|
90
+ v.nil?
91
+ end
92
+ consensus = parent_responses.group_by(&:last)
93
+ if consensus.empty?
94
+ return nil
95
+ elsif consensus.size != 1
96
+ msg = "#{object.inspect}: I didn't have key '#{k}', and " +
97
+ "my parents had conflicting answers: " +
98
+ "[answer, parents]: #{consensus}"
99
+ fail KeyError, msg
100
+ end
101
+ consensus.keys[0]
102
+ end
103
+ end
104
+
105
+
106
+ def []=(k, v)
107
+ fail "Use string keys" unless k.is_a?(String)
108
+ bag[k] = v
109
+ end
110
+
111
+ end
112
+ end
@@ -0,0 +1,88 @@
1
+ require 'cult/definition'
2
+ require 'cult/drivers/common'
3
+
4
+ module Cult
5
+ class Driver
6
+
7
+ # This is raised when a Driver is instantiated, but the required
8
+ # gems are not installed.
9
+ class GemNeededError < RuntimeError
10
+ attr_reader :gems
11
+ def initialize(gems)
12
+ @gems = gems
13
+ super(message)
14
+ end
15
+
16
+ def message
17
+ "gems required: #{gems.inspect}"
18
+ end
19
+ end
20
+
21
+
22
+ class << self
23
+ attr_accessor :required_gems
24
+
25
+ def driver_name
26
+ name.split('::')
27
+ .last
28
+ .sub(/Driver\z/, '')
29
+ .gsub(/([a-z])([A-Z])/, '\1-\2')
30
+ .downcase
31
+ end
32
+
33
+
34
+ def inspect
35
+ self == Driver ? super : "#{super}/#{driver_name}"
36
+ end
37
+ alias_method :to_s, :inspect
38
+
39
+
40
+ def named_array_identifier
41
+ driver_name
42
+ end
43
+ end
44
+
45
+ def inspect
46
+ "\#<#{self.class.name} \"#{self.class.driver_name}\">"
47
+ end
48
+
49
+
50
+ def to_s
51
+ self.class.driver_name
52
+ end
53
+
54
+
55
+ # Attempts to loads all of the required gems before doing any real work
56
+ def self.try_requires!
57
+ req = Array(required_gems).map do |gem|
58
+ begin
59
+ require gem
60
+ nil
61
+ rescue LoadError
62
+ gem
63
+ end
64
+ end.compact
65
+
66
+ unless req.empty?
67
+ fail GemNeededError.new(req)
68
+ end
69
+ end
70
+
71
+
72
+ # These are helpers that most implementations will need, but Driver itself
73
+ # doesn't depend on. Things like exponential back-off, awaiting an SSH
74
+ # port to open, etc.
75
+ include ::Cult::Drivers::Common
76
+
77
+
78
+ def self.setup!
79
+ try_requires!
80
+ end
81
+
82
+
83
+ def self.new(api_key:)
84
+ try_requires!
85
+ super
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,192 @@
1
+ require 'socket'
2
+ require 'net/ssh'
3
+
4
+ module Cult
5
+ module Drivers
6
+
7
+ module Common
8
+ module ClassMethods
9
+ # Lets us write a method "something_map" that returns {'ident' => ...},
10
+ # and also get a function "something" that returns the keys.
11
+ def with_id_mapping(method_name)
12
+ new_method = method_name.to_s.sub(/_map\z/, '')
13
+ define_method(new_method) do
14
+ send(method_name).keys
15
+ end
16
+ end
17
+
18
+
19
+ def memoize(method_name)
20
+ old_method_name = "#{method_name}_unmemoized".to_sym
21
+
22
+ alias_method old_method_name, method_name
23
+
24
+ var_name = "@#{method_name}".to_sym
25
+
26
+ define_method(method_name) do
27
+ if !instance_variable_defined?(var_name)
28
+ instance_variable_set(var_name, send(old_method_name))
29
+ end
30
+ instance_variable_get(var_name)
31
+ end
32
+
33
+ define_method("#{method_name}_unmemo!") do
34
+ remove_instance_variable(var_name)
35
+ end
36
+ end
37
+ end
38
+
39
+
40
+ def self.included(cls)
41
+ cls.extend(ClassMethods)
42
+ end
43
+
44
+
45
+ # works with with_id_mapping to convert a human-readible/normalized key
46
+ # to the id the backend service expects. Allows '=value' to force a
47
+ # literal value, and gives better error messages.
48
+ def fetch_mapped(name:, from:, key:)
49
+ # Allow for the override.
50
+ key = key.to_s
51
+ return key[1..-1] if key[0] == '='
52
+
53
+ begin
54
+ from.fetch(key)
55
+ rescue KeyError => e
56
+ raise ArgumentError, "Invalid #{name}: \"#{key}\". " +
57
+ "Use \"=#{key}\" to force, or use one of: " +
58
+ from.keys.inspect
59
+ end
60
+ end
61
+
62
+
63
+ def ssh_key_info(data: nil, file: nil)
64
+ if data.nil?
65
+ fail ArgumentError if file.nil?
66
+ data = File.read(file)
67
+ else
68
+ fail ArgumentError unless file.nil?
69
+ end
70
+
71
+ data = data.chomp
72
+ key = Net::SSH::KeyFactory.load_data_public_key(data, file)
73
+
74
+ fields = data.split(/ /)
75
+ return {
76
+ name: fields[-1],
77
+ fingerprint: key.fingerprint,
78
+ data: data,
79
+ file: file
80
+ }
81
+ end
82
+
83
+
84
+ # Enter this block once a node has been created. It makes sure it's
85
+ # destroyed if there's an error later in the procedure.
86
+ def rollback_on_error(id:, &block)
87
+ begin
88
+ yield
89
+ rescue Exception => e
90
+ begin
91
+ destroy!(id: id)
92
+ ensure
93
+ raise e
94
+ end
95
+ end
96
+ end
97
+
98
+
99
+ def slugify(s)
100
+ s.gsub(/[^a-z0-9]+/i, '-').gsub(/(^\-)|(-\z)/, '').downcase
101
+ end
102
+
103
+
104
+ def distro_name(s)
105
+ s = s.gsub(/\bx64\b/i, '')
106
+ # People sometimes add "LTS" to the name of Ubuntu LTS releases
107
+ s = s.gsub(/\blts\b/i, '') if s.match(/ubuntu/i)
108
+
109
+ # We don't particularly need the debian codename
110
+ s = s.gsub(/(\d)[\s-]+(\S+)/, '\1') if s.match(/^debian/i)
111
+ s
112
+ end
113
+
114
+
115
+ # Does back-off retrying. Defaults to not-exponential.
116
+ # Block must throw :done to signal they are done.
117
+ def backoff_loop(wait = 3, scale = 1.2, &block)
118
+ times = 0
119
+ total_wait = 0.0
120
+
121
+ catch :done do
122
+ loop do
123
+ yield times, total_wait
124
+ sleep wait
125
+ times += 1
126
+ total_wait += wait
127
+ wait *= scale
128
+ end
129
+ end
130
+ end
131
+
132
+
133
+ # Waits until SSH is available at host. "available" jsut means
134
+ # "listening"/acceping connections.
135
+ def await_ssh(host)
136
+ backoff_loop do
137
+ begin
138
+ sock = connect_timeout(host, 22, 1)
139
+ throw :done
140
+ rescue Errno::ETIMEDOUT, Errno::ECONNREFUSED
141
+ # Nothing, these are expected
142
+ ensure
143
+ sock.close if sock
144
+ end
145
+ end
146
+ end
147
+
148
+
149
+ # This should not be needed, but it is:
150
+ # https://spin.atomicobject.com/2013/09/30/socket-connection-timeout-ruby/
151
+ def connect_timeout(host, port, timeout = 5)
152
+ # Convert the passed host into structures the non-blocking calls
153
+ # can deal with
154
+ addr = Socket.getaddrinfo(host, nil)
155
+ sockaddr = Socket.pack_sockaddr_in(port, addr[0][3])
156
+
157
+ Socket.new(Socket.const_get(addr[0][0]), Socket::SOCK_STREAM, 0).tap do |socket|
158
+ socket.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_NODELAY, 1)
159
+
160
+ begin
161
+ # Initiate the socket connection in the background. If it doesn't
162
+ # fail immediately it will raise an IO::WaitWritable
163
+ # (Errno::EINPROGRESS) indicating the connection is in progress.
164
+ socket.connect_nonblock(sockaddr)
165
+
166
+ rescue IO::WaitWritable
167
+ # IO.select will block until the socket is writable or the timeout
168
+ # is exceeded - whichever comes first.
169
+ if IO.select(nil, [socket], nil, timeout)
170
+ begin
171
+ # Verify there is now a good connection
172
+ socket.connect_nonblock(sockaddr)
173
+ rescue Errno::EISCONN
174
+ # Good news everybody, the socket is connected!
175
+ rescue
176
+ # An unexpected exception was raised - the connection is no good.
177
+ socket.close
178
+ raise
179
+ end
180
+ else
181
+ # IO.select returns nil when the socket is not ready before
182
+ # timeout seconds have elapsed
183
+ socket.close
184
+ raise Errno::ETIMEDOUT
185
+ end
186
+ end
187
+ end
188
+ end
189
+
190
+ end
191
+ end
192
+ end
@@ -0,0 +1,179 @@
1
+ require 'cult/driver'
2
+ require 'cult/drivers/common'
3
+ require 'net/ssh'
4
+ require 'json'
5
+
6
+ module Cult
7
+ module Drivers
8
+
9
+ class DigitalOceanDriver < ::Cult::Driver
10
+ self.required_gems = 'droplet_kit'
11
+
12
+ include Common
13
+
14
+ attr_reader :client
15
+
16
+ def initialize(api_key:)
17
+ @client = DropletKit::Client.new(access_token: api_key)
18
+ end
19
+
20
+
21
+ def sizes_map
22
+ client.sizes.all.to_a.map do |s|
23
+ [s.slug, s.slug]
24
+ end.to_h
25
+ end
26
+ memoize :sizes_map
27
+ with_id_mapping :sizes_map
28
+
29
+
30
+ def images_map
31
+ distros = %w(ubuntu coreos centos freebsd fedora debian).join '|'
32
+ re = /^(#{distros})\-.*\-x64$/
33
+ client.images.all.to_a.select do |image|
34
+ image.public && image.slug && image.slug.match(re)
35
+ end.map do |image|
36
+ [slugify(distro_name(image.slug)), image.slug]
37
+ end.to_h
38
+ end
39
+ memoize :images_map
40
+ with_id_mapping :images_map
41
+
42
+
43
+ def zones_map
44
+ client.regions.all.map do |zone|
45
+ [zone.slug, zone.slug]
46
+ end.to_h
47
+ end
48
+ memoize :zones_map
49
+ with_id_mapping :zones_map
50
+
51
+
52
+ def ssh_keys
53
+ client.ssh_keys.all.to_a.map(&:to_h)
54
+ end
55
+ memoize :ssh_keys
56
+
57
+
58
+ def upload_ssh_key(file:)
59
+ key = ssh_key_info(file: file)
60
+ # If we already have one with this fingerprint, use it.
61
+ if (exist = ssh_keys.find {|dk| dk[:fingerprint] == key[:fingerprint]})
62
+ exist
63
+ else
64
+ ssh_keys_dememo!
65
+ client.ssh_keys.create \
66
+ DropletKit::SSHKey.new(fingerprint: key[:fingerprint],
67
+ public_key: key[:data],
68
+ name: key[:name])
69
+ end
70
+ end
71
+
72
+
73
+ def await_creation(droplet)
74
+ d = nil
75
+ backoff_loop do
76
+ d = client.droplets.find(id: droplet.id)
77
+ throw :done if d.status == 'active'
78
+ end
79
+ return d
80
+ end
81
+
82
+
83
+ def destroy!(id:)
84
+ client.droplets.delete(id: id)
85
+ end
86
+
87
+
88
+ def provision!(name:, size:, zone:, image:, ssh_key_files:)
89
+ fingerprints = Array(ssh_key_files).map do |file|
90
+ upload_ssh_key(file: file)[:fingerprint]
91
+ end
92
+
93
+ begin
94
+ params = {
95
+ name: name,
96
+ size: fetch_mapped(name: :size, from: sizes_map, key: size),
97
+ image: fetch_mapped(name: :image, from: images_map, key: image),
98
+ region: fetch_mapped(name: :zone, from: zones_map, key: zone),
99
+ ssh_keys: fingerprints,
100
+
101
+ private_networking: true,
102
+ ipv6: true
103
+ }
104
+ rescue KeyError => e
105
+ fail ArgumentError, "Invalid argument: #{e.message}"
106
+ end
107
+
108
+ droplet = DropletKit::Droplet.new(params)
109
+
110
+ if droplet.nil?
111
+ fail "Droplet was nil: #{params.inspect}"
112
+ end
113
+
114
+ rollback_on_error(id: droplet.id) do
115
+ droplet = client.droplets.create(droplet)
116
+ droplet = await_creation(droplet)
117
+
118
+ ipv4_public = droplet.networks.v4.find {|n| n.type == 'public' }
119
+ ipv4_private = droplet.networks.v4.find {|n| n.type == 'private' }
120
+ ipv6_public = droplet.networks.v6.find {|n| n.type == 'public' }
121
+ ipv6_private = droplet.networks.v6.find {|n| n.type == 'private' }
122
+
123
+ await_ssh(ipv4_public.ip_address)
124
+ return {
125
+ name: droplet.name,
126
+ size: size,
127
+ zone: zone,
128
+ image: image,
129
+ ssh_key_files: ssh_key_files,
130
+ ssh_keys: fingerprints,
131
+
132
+ id: droplet.id,
133
+ created_at: droplet.created_at,
134
+ host: ipv4_public&.ip_address,
135
+ ipv4_public: ipv4_public&.ip_address,
136
+ ipv4_private: ipv4_private&.ip_address,
137
+ ipv6_public: ipv6_public&.ip_address,
138
+ ipv6_private: ipv6_private&.ip_address,
139
+ # Get rid of magic in droplet.
140
+ meta: JSON.parse(droplet.to_json)
141
+ }
142
+ end
143
+ end
144
+
145
+
146
+ def self.setup!
147
+ super
148
+ url = "https://cloud.digitalocean.com/settings/api/tokens/new"
149
+
150
+ puts "Cult needs a read/write Access Token created for your " +
151
+ "DigitalOcean account."
152
+ puts "One can be generated at the following URL:"
153
+ puts
154
+ puts " #{url}"
155
+ puts
156
+
157
+ CLI.launch_browser(url) if CLI.yes_no?("Open Browser?")
158
+
159
+ api_key = CLI.prompt("Access Token")
160
+ unless api_key.match(/\A[0-9a-f]{64}\z/)
161
+ puts "That doesn't look like an access token, but we'll take it."
162
+ end
163
+
164
+ inst = new(api_key: api_key)
165
+
166
+ return {
167
+ api_key: api_key,
168
+ driver: driver_name,
169
+ configurations: {
170
+ sizes: inst.sizes,
171
+ zones: inst.zones,
172
+ images: inst.images,
173
+ }
174
+ }
175
+ end
176
+ end
177
+
178
+ end
179
+ end