File: compose.rb

package info (click to toggle)
vagrant 2.2.14%2Bdfsg-2
  • links: PTS, VCS
  • area: main
  • in suites: bullseye
  • size: 9,800 kB
  • sloc: ruby: 97,301; sh: 375; makefile: 16; lisp: 1
file content (312 lines) | stat: -rw-r--r-- 11,668 bytes parent folder | download | duplicates (4)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
require "json"
require "log4r"

module VagrantPlugins
  module DockerProvider
    class Driver
      class Compose < Driver

        # @return [Integer] Maximum number of seconds to wait for lock
        LOCK_TIMEOUT = 60
        # @return [String] Compose file format version
        COMPOSE_VERSION = "2".freeze

        # @return [Pathname] data directory to store composition
        attr_reader :data_directory
        # @return [Vagrant::Machine]
        attr_reader :machine

        # Create a new driver instance
        #
        # @param [Vagrant::Machine] machine Machine instance for this driver
        def initialize(machine)
          if !Vagrant::Util::Which.which("docker-compose")
            raise Errors::DockerComposeNotInstalledError
          end
          super()
          @machine = machine
          @data_directory = Pathname.new(machine.env.local_data_path).
            join("docker-compose")
          @data_directory.mkpath
          @logger = Log4r::Logger.new("vagrant::docker::driver::compose")
          @compose_lock = Mutex.new
          @logger.debug("Docker compose driver initialize for machine `#{@machine.name}` (`#{@machine.id}`)")
          @logger.debug("Data directory for composition file `#{@data_directory}`")
        end

        # Updates the docker compose config file with the given arguments
        #
        # @param [String] dir - local directory or git repo URL
        # @param [Hash] opts - valid key: extra_args
        # @param [Block] block
        # @return [Nil]
        def build(dir, **opts, &block)
          name = machine.name.to_s
          @logger.debug("Applying build for `#{name}` using `#{dir}` directory.")
          begin
            update_composition do |composition|
              services = composition["services"] ||= {}
              services[name] ||= {}
              services[name]["build"] = {"context" => dir}
              # Extract custom dockerfile location if set
              if opts[:extra_args] && opts[:extra_args].include?("--file")
                services[name]["build"]["dockerfile"] = opts[:extra_args][opts[:extra_args].index("--file") + 1]
              end
              # Extract any build args that can be found
              case opts[:extra_args]
              when Array
                if opts[:extra_args].include?("--build-arg")
                  idx = 0
                  extra_args = {}
                  while(idx < opts[:extra_args].size)
                    arg_value = opts[:extra_args][idx]
                    idx += 1
                    if arg_value.start_with?("--build-arg")
                      if !arg_value.include?("=")
                        arg_value = opts[:extra_args][idx]
                        idx += 1
                      end
                      key, val = arg_value.to_s.split("=", 2).to_s.split("=")
                      extra_args[key] = val
                    end
                  end
                end
              when Hash
                services[name]["build"]["args"] = opts[:extra_args]
              end
            end
          rescue => error
            @logger.error("Failed to apply build using `#{dir}` directory: #{error.class} - #{error}")
            update_composition do |composition|
              composition["services"].delete(name)
            end
            raise
          end
        end

        def create(params, **opts, &block)
          # NOTE: Use the direct machine name as we don't
          # need to worry about uniqueness with compose
          name    = machine.name.to_s
          image   = params.fetch(:image)
          links   = Array(params.fetch(:links, [])).map do |link|
            case link
            when Array
              link
            else
              link.to_s.split(":")
            end
          end
          ports   = Array(params[:ports])
          volumes = Array(params[:volumes]).map do |v|
            v = v.to_s
            host, guest = v.split(":", 2)
            if v.include?(":") && (Vagrant::Util::Platform.windows? || Vagrant::Util::Platform.wsl?)
              host = Vagrant::Util::Platform.windows_path(host)
              # NOTE: Docker does not support UNC style paths (which also
              # means that there's no long path support). Hopefully this
              # will be fixed someday and the gsub below can be removed.
              host.gsub!(/^[^A-Za-z]+/, "")
            end
            # if host path is a volume key, don't expand it.
            # if both exist (a path and a key) show warning and move on
            # otherwise assume it's a realative path and expand the host path
            compose_config = get_composition
            if compose_config["volumes"] && compose_config["volumes"].keys.include?(host)
              if File.directory?(@machine.env.cwd.join(host).to_s)
                @machine.env.ui.warn(I18n.t("docker_provider.volume_path_not_expanded",
                                           host: host))
              end
            else
              @logger.debug("Path expanding #{host} to current Vagrant working dir instead of docker-compose config file directory")
              host = @machine.env.cwd.join(host).to_s
            end
            "#{host}:#{guest}"
          end
          cmd     = Array(params.fetch(:cmd))
          env     = Hash[*params.fetch(:env).flatten.map(&:to_s)]
          expose  = Array(params[:expose])
          @logger.debug("Creating container `#{name}`")
          begin
            update_args = [:apply]
            update_args.push(:detach) if params[:detach]
            update_args << block
            update_composition(*update_args) do |composition|
              services = composition["services"] ||= {}
              services[name] ||= {}
              if params[:extra_args].is_a?(Hash)
                services[name].merge!(
                  Hash[
                    params[:extra_args].map{ |k, v|
                      [k.to_s, v]
                    }
                  ]
                )
              end
              services[name].merge!(
                "environment" => env,
                "expose" => expose,
                "ports" => ports,
                "volumes" => volumes,
                "links" => links,
                "command" => cmd
              )
              services[name]["image"] = image if image
              services[name]["hostname"] = params[:hostname] if params[:hostname]
              services[name]["privileged"] = true if params[:privileged]
              services[name]["pty"] = true if params[:pty]
            end
          rescue => error
            @logger.error("Failed to create container `#{name}`: #{error.class} - #{error}")
            update_composition do |composition|
              composition["services"].delete(name)
            end
            raise
          end
          get_container_id(name)
        end

        def rm(cid)
          if created?(cid)
            destroy = false
            synchronized do
              compose_execute("rm", "-f", machine.name.to_s)
              update_composition do |composition|
                if composition["services"] && composition["services"].key?(machine.name.to_s)
                  @logger.info("Removing container `#{machine.name}`")
                  if composition["services"].size > 1
                    composition["services"].delete(machine.name.to_s)
                  else
                    destroy = true
                  end
                end
              end
              if destroy
                @logger.info("No containers remain. Destroying full environment.")
                compose_execute("down", "--volumes", "--rmi", "local")
                @logger.info("Deleting composition path `#{composition_path}`")
                composition_path.delete
              end
            end
          end
        end

        def rmi(*_)
          true
        end

        def created?(cid)
          result = super
          if !result
            composition = get_composition
            if composition["services"] && composition["services"].has_key?(machine.name.to_s)
              result = true
            end
          end
          result
        end

        private

        # Lookup the ID for the container with the given name
        #
        # @param [String] name Name of container
        # @return [String] Container ID
        def get_container_id(name)
          compose_execute("ps", "-q", name).chomp
        end

        # Execute a `docker-compose` command
        def compose_execute(*cmd, **opts, &block)
          synchronized do
            execute("docker-compose", "-f", composition_path.to_s,
              "-p", machine.env.cwd.basename.to_s, *cmd, **opts, &block)
          end
        end

        # Apply any changes made to the composition
        def apply_composition!(*args)
          block = args.detect{|arg| arg.is_a?(Proc) }
          execute_args = ["up", "--remove-orphans"]
          if args.include?(:detach)
            execute_args << "-d"
          end
          machine.env.lock("compose", retry: true) do
            if block
              compose_execute(*execute_args, &block)
            else
              compose_execute(*execute_args)
            end
          end
        end

        # Update the composition and apply changes if requested
        #
        # @param [Boolean] apply Apply composition changes
        def update_composition(*args)
          synchronized do
            machine.env.lock("compose", retry: true) do
              composition = get_composition
              result = yield composition
              write_composition(composition)
              if args.include?(:apply) || (args.include?(:conditional) && result)
                apply_composition!(*args)
              end
            end
          end
        end

        # @return [Hash] current composition contents
        def get_composition
          composition = {"version" => COMPOSE_VERSION.dup}
          if composition_path.exist?
            composition = Vagrant::Util::DeepMerge.deep_merge(composition, YAML.load(composition_path.read))
          end
          composition = Vagrant::Util::DeepMerge.deep_merge(composition, machine.provider_config.compose_configuration.dup)
          @logger.debug("Fetched composition with provider configuration applied: #{composition}")
          composition
        end

        # Save the composition
        #
        # @param [Hash] composition New composition
        def write_composition(composition)
          @logger.debug("Saving composition to `#{composition_path}`: #{composition}")
          tmp_file = Tempfile.new("vagrant-docker-compose")
          tmp_file.write(composition.to_yaml)
          tmp_file.close
          synchronized do
            FileUtils.mv(tmp_file.path, composition_path.to_s)
          end
        end

        # @return [Pathname] path to the docker-compose.yml file
        def composition_path
          data_directory.join("docker-compose.yml")
        end

        def synchronized
          if !@compose_lock.owned?
            timeout = LOCK_TIMEOUT.to_f
            until @compose_lock.owned?
              if @compose_lock.try_lock
                if timeout > 0
                  timeout -= sleep(1)
                else
                  raise Errors::ComposeLockTimeoutError
                end
              end
            end
            got_lock = true
          end
          begin
            result = yield
          ensure
            @compose_lock.unlock if got_lock
          end
          result
        end
      end
    end
  end
end