squared 0.3.5 → 0.4.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,572 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Squared
4
+ module Workspace
5
+ module Project
6
+ class Docker < Base
7
+ include Prompt
8
+
9
+ COMPOSEFILE = %w[compose.yaml compose.yml docker-compose.yaml compose.yml docker-compose.yml].freeze
10
+ BAKEFILE = %w[docker-bake.json docker-bake.hcl docker-bake.override.json docker-bake.override.hcl].freeze
11
+ DIR_DOCKER = (COMPOSEFILE + BAKEFILE).freeze
12
+ OPT_DOCKER = {
13
+ common: %w[tls tlsverify config=p c|context=b D|debug H|host=q l|log-level=b tlscacert=p tlscert=p
14
+ tlskey=p].freeze,
15
+ buildx: {
16
+ common: %w[builder=b D|debug],
17
+ build: %w[load pull push add-host=q annotation=q attest=q build-arg=qq ent=q iidfile=p label=q a-file=p
18
+ network=b no-cache-filter=b o|output=q platform=b q|quiet secret=qq shm-size=b ssh=qq t|tag=b
19
+ target=b ulimit=q].freeze,
20
+ bake: %w[load print pull push list=q metadata-file=p set=q].freeze,
21
+ shared: %w[check no-cache allow=q call=b? f|file=p progress=b provenance=q sbom=q].freeze
22
+ }.freeze,
23
+ compose: {
24
+ common: %w[all-resources compatibility dry-run ansi|b env-file=p f|file=p parallel=b profile=b progress=b
25
+ project-directory=p p|project-name=e].freeze,
26
+ build: %w[no-cache pull push with-dependencies q|quiet build-arg=qq builder=b m|memory=b ssh=qq].freeze,
27
+ exec: %w[dry-run privileged d|detach e|env=qq index=i T|no-TTY=b? user=e w|workdir=q].freeze,
28
+ run: %w[build dry-run no-deps quiet-pull remove-orphans rm P|service-ports use-aliases cap-add=b cap-drop=b
29
+ d|detach entrypoint=q e|env=qq i|interactive=b? l|label=q name=b T|no-TTY=b? p|publish=e pull=b
30
+ u|user=e v|volume=q w|workdir=q].freeze,
31
+ up: %w[y abort-on-container-exit abort-on-container-failure always-recreate-deps attach-dependencies build
32
+ d|detach dry-run force-recreate menu no-build no-color no-deps no-log-prefix no-recreate no-start
33
+ quiet-pull remove-orphans V|renew-anon-volumes timestamps wait w|watch attach=b exit-code-from=b
34
+ no-attach=b pull=b scale=i t|timeout=i wait-timeout=i].freeze
35
+ }.freeze,
36
+ container: {
37
+ run: %w[d|detach init i|interactive no-healthcheck oom-kill-disable privileged P|publish-all q|quiet
38
+ read-only rm runtime t|tty add-host=q annotation=q a|attach=b blkio-weight-device=i cap-add=b
39
+ cap-drop=b cgroup-parent=b cgroupns=b cidfile=p detach-keys=q device=q device-cgroup-rule=q
40
+ device-read-bps=q device-read-iops=q device-write-bps=q device-write-iops=q
41
+ disable-content-trust=b? dns=e dns-option=e dns-search=e domainname=b entrypoint=q e|env=qq
42
+ env-file=p expose=e gpus=q group-add=b health-cmd=q health-interval=b health-retries=i
43
+ health-start-interval=b health-start-period=b health-timeout=b h|hostname=e io-maxbandwidth=b
44
+ io-maxiops=b ip=b ip6=e ipc=b isolation=b kernel-memory=b l|label=q label-file=p link=b
45
+ link-local-ip=b log-driver=b log-opt=q mac-address=e memory-swappiness=b mount=q name=b network=b
46
+ network-alias=b oom-score-adj=b pid=b platform=b p|publish=e pull=b restart=b runtime=b
47
+ security-opt=q shm-size=b sig-proxy=b? stop-signal=b stop-timeout=i storage-opt=q sysctl=q tmpfs=q
48
+ ulimit=q user=e userns=b uts=b v|volume=q volume-driver=b volumes-from=b w|workdir=q].freeze,
49
+ exec: %w[d|detach i|interactive privileged t|tty detach-keys=q e|env=qq env-file=p user=e
50
+ w|workdir=q].freeze,
51
+ update: %w[blkio-weight=i cpu-period=i cpu-quota=i cpu-rt-period=i cpu-rt-runtime=i c|cpu-shares=i cpus=f
52
+ cpuset-cpus=b cpuset-mems=b m|memory=b memory-reservation=b memory-swap=b pids-limit=b
53
+ restart=q].freeze,
54
+ commit: %w[a|author=q c|change=q m|message=q pause=b?].freeze,
55
+ inspect: %w[s|size f|format=q].freeze,
56
+ start: %w[a|attach i|interactive detach-keys=q].freeze,
57
+ stop: %w[s|signal=b t|time=i].freeze,
58
+ restart: %w[s|signal=b t|time=i].freeze,
59
+ kill: %w[s|signal=b].freeze,
60
+ stats: %w[no-trunc format|q].freeze
61
+ }.freeze,
62
+ image: {
63
+ list: %w[a|all digests no-trunc f|filter=q format=q].freeze,
64
+ push: %w[disable-content-trust=b? platform=b q|quiet].freeze,
65
+ rm: %w[f|force no-prune].freeze
66
+ }.freeze
67
+ }.freeze
68
+ private_constant :COMPOSEFILE, :BAKEFILE, :OPT_DOCKER
69
+
70
+ class << self
71
+ def tasks
72
+ [].freeze
73
+ end
74
+
75
+ def config?(val)
76
+ return false unless (val = as_path(val))
77
+
78
+ val.join('Dockerfile').exist? || DIR_DOCKER.any? { |file| val.join(file).exist? }
79
+ end
80
+ end
81
+
82
+ @@tasks[ref] = {
83
+ 'build' => %i[tag context bake].freeze,
84
+ 'compose' => %i[build run exec up].freeze,
85
+ 'image' => %i[list rm].freeze,
86
+ 'container' => %i[run exec update commit inspect diff start stop restart pause unpause top stats kill
87
+ rm].freeze
88
+ }.freeze
89
+
90
+ attr_reader :context
91
+ attr_accessor :tag
92
+
93
+ def initialize(*, file: nil, context: nil, tag: nil, secrets: nil, registry: nil, **kwargs)
94
+ super
95
+ return unless dockerfile(file).exist?
96
+
97
+ @context = context
98
+ @tag = tag || "#{@project}:latest"
99
+ @secrets = secrets
100
+ @registry = registry
101
+ initialize_ref Docker.ref
102
+ initialize_logger(**kwargs)
103
+ initialize_env(**kwargs)
104
+ @output[4] = merge_opts(kwargs[:args], @output[4]) if kwargs[:args]
105
+ end
106
+
107
+ def ref
108
+ Docker.ref
109
+ end
110
+
111
+ def populate(*, **)
112
+ super
113
+ return unless ref?(Docker.ref)
114
+
115
+ namespace name do
116
+ @@tasks[Docker.ref].each do |action, flags|
117
+ next if @pass.include?(action)
118
+
119
+ namespace action do
120
+ flags.each do |flag|
121
+ case action
122
+ when 'build'
123
+ case flag
124
+ when :tag, :context
125
+ format_desc(action, flag, 'opts*', before: flag == :tag ? 'name' : 'dir')
126
+ task flag, [flag] do |_, args|
127
+ param = param_guard(action, flag, args: args, key: flag)
128
+ buildx(:build, args.to_a.drop(1), "#{flag}": param)
129
+ end
130
+ when :bake
131
+ format_desc action, flag, 'opts*,target*,context?'
132
+ task flag do |_, args|
133
+ args = param_guard(action, flag, args: args.to_a)
134
+ buildx flag, args
135
+ end
136
+ end
137
+ when 'compose'
138
+ case flag
139
+ when :build, :up
140
+ format_desc action, flag, 'opts*,service*'
141
+ task flag do |_, args|
142
+ composex flag, args.to_a
143
+ end
144
+ when :exec, :run
145
+ format_desc action, flag, "service,command#{flag == :exec ? '' : '?'},args*,opts*"
146
+ task flag, [:service] do |_, args|
147
+ service = param_guard(action, flag, args: args, key: :service)
148
+ composex(flag, args.to_a.drop(1), service: service)
149
+ end
150
+ end
151
+ when 'container'
152
+ case flag
153
+ when :exec, :commit
154
+ format_desc(action, flag, 'id/name,opts*', after: flag == :exec ? 'args+' : 'tag?')
155
+ task flag, [:id] do |_, args|
156
+ id = param_guard(action, flag, args: args, key: :id)
157
+ container(flag, args.to_a.drop(1), id: id)
158
+ end
159
+ when :run
160
+ format_desc action, flag, 'image,opts*,args*'
161
+ task flag, [:image] do |_, args|
162
+ image = param_guard(action, flag, args: args, key: :image)
163
+ container(flag, args.to_a.drop(1), id: image)
164
+ end
165
+ else
166
+ format_desc(action, flag, 'opts*', after: "id/name#{flag == :update ? '+' : '*'}")
167
+ task flag do |_, args|
168
+ container flag, args.to_a
169
+ end
170
+ end
171
+ when 'image'
172
+ format_desc(action, flag, flag == :rm ? 'id*,opts*' : 'opts*,args*')
173
+ task flag do |_, args|
174
+ image flag, args.to_a
175
+ end
176
+ end
177
+ end
178
+ end
179
+ end
180
+ end
181
+ end
182
+
183
+ def clean(*, sync: invoked_sync?('clean'), **)
184
+ return super unless @clean.nil?
185
+
186
+ image(:rm, sync: sync)
187
+ end
188
+
189
+ def compose(opts, flags = nil, script: false, args: nil, from: :build, **)
190
+ return opts if script == false
191
+
192
+ ret = docker_session
193
+ if from == :build
194
+ case (n = filetype)
195
+ when 1, 2
196
+ ret << 'buildx' << 'bake'
197
+ append_file n
198
+ from = :bake
199
+ when 3, 4
200
+ ret << 'compose' << 'build'
201
+ append_file n
202
+ from = :compose
203
+ else
204
+ ret << 'build'
205
+ end
206
+ else
207
+ ret << from
208
+ end
209
+ case opts
210
+ when String
211
+ ret << opts
212
+ when Hash
213
+ ret.merge(append_hash(opts, build: true))
214
+ when Enumerable
215
+ ret.merge(opts.to_a)
216
+ end
217
+ [args, flags].each_with_index do |target, index|
218
+ next unless target
219
+
220
+ target = append_any(target, target: []) unless target.is_a?(Array)
221
+ ret.merge(target.map { |arg| index == 0 ? fill_option(arg) : quote_option('build-arg', arg) })
222
+ end
223
+ case from
224
+ when :build
225
+ case @secrets
226
+ when String
227
+ quote_option('secret', @secrets, double: true)
228
+ when Hash
229
+ append = lambda do |type|
230
+ as_a(@secrets[type]).each { |arg| ret << quote_option('secret', "type=#{type},#{arg}", double: true) }
231
+ end
232
+ append.(:file)
233
+ append.(:env)
234
+ else
235
+ as_a(@secrets).each { |arg| ret << quote_option('secret', arg) }
236
+ end
237
+ if (val = option('tag', ignore: false))
238
+ ret << quote_option('tag', val)
239
+ elsif !session_arg?('t', 'tag')
240
+ ret << quote_option('tag', tag)
241
+ end
242
+ append_context
243
+ when :bake, :compose
244
+ if (val = option(from == :bake ? 'target' : 'service', ignore: false))
245
+ ret.merge(split_escape(val).map { |s| shell_escape(s) })
246
+ end
247
+ end
248
+ ret
249
+ end
250
+
251
+ def buildx(flag, opts = [], tag: nil, context: nil)
252
+ cmd, opts = docker_session('buildx', opts: opts)
253
+ opts = option_sanitize(opts, OPT_DOCKER[:buildx][:common]).first
254
+ cmd << flag
255
+ out = option_sanitize(opts, OPT_DOCKER[:buildx][flag] + OPT_DOCKER[:buildx][:shared]).first
256
+ case flag
257
+ when :build
258
+ cmd.merge(as_a(tag).map { |val| quote_option('tag', val) })
259
+ append_context context
260
+ when :bake
261
+ unless out.empty?
262
+ args = out.dup
263
+ out.clear
264
+ if Dir.exist?(args.last)
265
+ if projectpath?(val = args.pop)
266
+ context = val
267
+ else
268
+ out << val
269
+ end
270
+ end
271
+ append_value(args, escape: true)
272
+ contextdir(context) if context
273
+ end
274
+ end
275
+ option_clear out
276
+ run(from: :"buildx:#{flag}")
277
+ end
278
+
279
+ def composex(flag, opts = [], service: nil)
280
+ cmd, opts = docker_session('compose', opts: opts)
281
+ opts = option_sanitize(opts, OPT_DOCKER[:compose][:common]).first
282
+ append_file filetype unless session_arg?('f', 'file')
283
+ cmd << flag
284
+ out = option_sanitize(opts, OPT_DOCKER[:compose][flag] + OPT_DOCKER[:common]).first
285
+ from = :"compose:#{flag}"
286
+ case flag
287
+ when :build, :up
288
+ append_value(out, escape: true)
289
+ when :exec, :run
290
+ append_command(flag, service, out, from: from)
291
+ end
292
+ run(from: from)
293
+ end
294
+
295
+ def container(flag, opts = [], id: nil)
296
+ cmd, opts = docker_session('container', flag, opts: opts)
297
+ list = OPT_DOCKER[:container].fetch(flag, [])
298
+ list += OPT_DOCKER[:container][:update] if flag == :run
299
+ out = option_sanitize(opts, list, first: flag == :exec).first
300
+ from = :"container:#{flag}"
301
+ case flag
302
+ when :exec, :run
303
+ append_command(flag, id.to_s.empty? ? tag : id, out, target: cmd, from: from)
304
+ when :update
305
+ raise_error('missing container', hint: from) if out.empty?
306
+ append_value(out, escape: true)
307
+ when :commit
308
+ latest = out.shift || tag
309
+ cmd << id << latest
310
+ raise_error("unknown args: #{out.join(', ')}", hint: from) unless out.empty?
311
+ return unless confirm_command(cmd.to_s, title: from, target: id, as: latest)
312
+
313
+ registry = option('registry') || @registry
314
+ run(from: from, exception: registry.nil? ? exception : true)
315
+ return unless registry
316
+
317
+ opts = []
318
+ append_option('platform', target: opts, equals: true)
319
+ case option('disable-content-trust', ignore: false)
320
+ when 'true', '1'
321
+ opts << 'disable-content-trust'
322
+ when 'false', '0'
323
+ opts << 'disable-content-trust=false'
324
+ end
325
+ opts << '--quiet' unless verbose
326
+ return image(:push, opts, id: latest, registry: registry)
327
+ else
328
+ if out.empty?
329
+ ps = docker_output 'ps', '-a'
330
+ status = []
331
+ no = true
332
+ case flag
333
+ when :inspect, :diff
334
+ no = false
335
+ when :start
336
+ status = %w[created exited]
337
+ no = false
338
+ when :stop, :pause
339
+ status = %w[running restarting]
340
+ when :restart
341
+ status = %w[running paused exited]
342
+ when :unpause
343
+ status << 'paused'
344
+ no = false
345
+ when :top, :stats
346
+ status << 'running'
347
+ cmd << '--no-stream' if flag == :stats
348
+ no = false
349
+ when :kill
350
+ status = %w[running restarting paused]
351
+ when :rm
352
+ status = %w[created exited dead]
353
+ end
354
+ ps.merge(status.map { |s| "--filter=\"status=#{s}\"" })
355
+ list_image(flag, ps, no: no, hint: "status: #{status.join(', ')}", from: from) do |id|
356
+ run(cmd.temp(id), from: from)
357
+ end
358
+ return
359
+ else
360
+ append_value(out, escape: true)
361
+ end
362
+ end
363
+ run(from: from)
364
+ end
365
+
366
+ def image(flag, opts = [], sync: true, id: nil, registry: nil)
367
+ cmd, opts = docker_session('image', flag == :exec ? :list : flag, opts: opts)
368
+ out = option_sanitize(opts, OPT_DOCKER[:image][flag]).first
369
+ from = :"image:#{flag}"
370
+ case flag
371
+ when :list
372
+ if opts.size == out.size
373
+ index = 0
374
+ name = nil
375
+ opts.each do |opt|
376
+ if (name = opt[/^name=["']?(.+)["']?$/, 1])
377
+ opts.delete(opt)
378
+ break
379
+ end
380
+ end
381
+ flag = :run if flag == :list
382
+ list_image(flag, cmd << '-a', from: from) do |val|
383
+ container(flag, if name
384
+ opts.dup << "name=#{index == 0 ? name : "#{name}-#{index}"}"
385
+ else
386
+ opts
387
+ end, id: val)
388
+ index += 1
389
+ end
390
+ return
391
+ else
392
+ option_clear out
393
+ end
394
+ when :rm
395
+ if id
396
+ cmd << id
397
+ elsif !out.empty?
398
+ out.each { |val| run(cmd.temp(val), sync: sync, from: from) }
399
+ return
400
+ else
401
+ list_image(flag, docker_output('image', 'ls -a'), from: from) do |val|
402
+ image(:rm, opts, sync: sync, id: val)
403
+ end
404
+ return
405
+ end
406
+ when :push
407
+ id ||= tag
408
+ raise_error(id ? "unknown args: #{out.join(', ')}" : 'no id/tag given', hint: from) unless id && out.empty?
409
+ reg = (registry || @registry).chomp('/')
410
+ uri = shell_quote("#{reg}/#{id}")
411
+ cmd << uri
412
+ img = docker_output 'image', 'tag', id, uri
413
+ return unless confirm_command(img.to_s, cmd.to_s, target: id, as: reg, title: from)
414
+
415
+ run(img, exception: true, sync: false, banner: false)
416
+ end
417
+ run(sync: sync, from: from)
418
+ end
419
+
420
+ def build?
421
+ @output[0] != false && dockerfile.exist?
422
+ end
423
+
424
+ def clean?
425
+ super || dockerfile.exist?
426
+ end
427
+
428
+ def dockerfile(val = nil)
429
+ if val == 'Dockerfile'
430
+ @file = false
431
+ elsif val
432
+ @file = if val.is_a?(Array)
433
+ val = val.select { |file| basepath(file).exist? }
434
+ val.size > 1 ? val : val.first
435
+ else
436
+ val || DIR_DOCKER.find { |file| basepath(file).exist? }
437
+ end
438
+ @file ||= false
439
+ end
440
+ basepath((@file.is_a?(Array) ? @file.first : @file) || 'Dockerfile')
441
+ end
442
+
443
+ private
444
+
445
+ def docker_session(*cmd, opts: nil)
446
+ return session('docker', *cmd) unless opts
447
+
448
+ ret = session 'docker'
449
+ opts = option_sanitize(opts, OPT_DOCKER[:common]).first
450
+ [ret.merge(cmd), opts]
451
+ end
452
+
453
+ def docker_output(*cmd, **kwargs)
454
+ session('docker', *cmd, main: false, options: false, **kwargs)
455
+ end
456
+
457
+ def append_command(flag, val, list, target: @session, from: nil)
458
+ raise_error('no command args', hint: from) if flag == :exec && list.empty?
459
+ target << val << list.shift
460
+ target << shell_quote(list.join(' && '), double: true, option: false) unless list.empty?
461
+ end
462
+
463
+ def append_file(type, target: @session)
464
+ return unless type == 2 || type == 4 || @file.is_a?(Array)
465
+
466
+ target.merge(as_a(@file).map { |val| quote_option('file', basepath(val)) })
467
+ end
468
+
469
+ def append_context(ctx = nil, target: @session)
470
+ if @file.is_a?(String) && !session_arg?('f', 'file') && !bake?(dockerfile) && !compose?(dockerfile)
471
+ target << quote_option('file', dockerfile)
472
+ end
473
+ target << contextdir(ctx || context)
474
+ end
475
+
476
+ def list_image(flag, cmd, hint: nil, from: nil, no: true, &blk)
477
+ pwd_set do
478
+ found = false
479
+ IO.popen(session_done(cmd << '--format=json')).each_with_index do |line, index|
480
+ data = JSON.parse(line)
481
+ rt = [data['Repository'], data['Tag']].reject { |val| val == '<none>' }.join(':')
482
+ rt = nil if tag.empty?
483
+ aa = if data['Names']
484
+ as_a(data['Names']).join(', ')
485
+ elsif tag
486
+ dd = true
487
+ data['Repository']
488
+ else
489
+ data['ID']
490
+ end
491
+ bb = index.succ.to_s
492
+ cc = bb.size + 1
493
+ a = sub_style(data['Image'] || rt || aa, styles: theme[:inline])
494
+ b = "Execute #{sub_style(flag, styles: theme[:active])} on #{a} (#{data['ID']})"
495
+ c, d = no ? ['y/N', 'N'] : ['Y/n', 'Y']
496
+ e = time_format(time_offset(data['CreatedAt']), pass: ['ms'])
497
+ f = sub_style(ARG[:BORDER][0], styles: theme[:inline])
498
+ g = ' ' * (cc + 1)
499
+ h = "#{sub_style(bb.rjust(cc), styles: theme[:current])} #{f} "
500
+ puts unless index == 0
501
+ puts "#{h + sub_style(aa, styles: theme[:subject])} (created #{e} ago)"
502
+ %w[Tag Status Ports Size].each do |key|
503
+ next if (key == 'Tag' && !dd) || (key == 'Size' && data[key] == '0B')
504
+
505
+ puts "#{g + f} #{key}: #{as_a(data[key]).join(', ')}" unless data[key].to_s.empty?
506
+ end
507
+ w = 9 + flag.to_s.size + 4 + aa.size
508
+ puts g + sub_style(ARG[:BORDER][6] + (ARG[:BORDER][1] * w), styles: theme[:inline])
509
+ found = true
510
+ next unless confirm("#{h + b}? [#{c}] ", d, timeout: 60)
511
+
512
+ puts if @@print_order == 0
513
+ blk.call data['ID']
514
+ end
515
+ puts log_message(Logger::INFO, 'none detected', subject: "#{name}:#{from}", hint: hint) unless found
516
+ end
517
+ rescue StandardError => e
518
+ log.error e
519
+ ret = on(:error, from, e)
520
+ raise if exception && ret != true
521
+
522
+ warn log_message(Logger::WARN, e, pass: true) if warning?
523
+ end
524
+
525
+ def confirm_command(*args, title: nil, target: nil, as: nil)
526
+ return false unless title && target
527
+
528
+ puts unless @@print_order == 0
529
+ t = title.to_s.split(':')
530
+ emphasize(args, title: message(t.first.upcase, *t.drop(1)), border: borderstyle, sub: [
531
+ { pat: /\A(\w+(?: => \w+)+)(.*)\z/, styles: theme[:header] },
532
+ { pat: /\A(.+)\z/, styles: theme[:caution] }
533
+ ])
534
+ @@print_order += 1
535
+ a = t.last.capitalize
536
+ b = sub_style(target, styles: theme[:subject])
537
+ c = as && sub_style(as, styles: theme[:inline])
538
+ confirm("#{a} #{b}#{c ? " as #{c}" : ''}? [y/N] ", 'N', timeout: 60)
539
+ end
540
+
541
+ def filetype(val = dockerfile)
542
+ case File.extname(val)
543
+ when '.hcl', '.json'
544
+ bake?(val) ? 1 : 2
545
+ when '.yml', '.yaml'
546
+ if compose?(val)
547
+ path.children.any? { |file| bake?(file) } ? 1 : 3
548
+ else
549
+ 4
550
+ end
551
+ else
552
+ 0
553
+ end
554
+ end
555
+
556
+ def contextdir(val = nil)
557
+ val && projectpath?(val) ? shell_quote(basepath(val)) : '.'
558
+ end
559
+
560
+ def compose?(file)
561
+ COMPOSEFILE.include?(File.basename(file))
562
+ end
563
+
564
+ def bake?(file)
565
+ BAKEFILE.include?(File.basename(file))
566
+ end
567
+ end
568
+
569
+ Application.implement Docker
570
+ end
571
+ end
572
+ end