wslc-wip 1.1.0 → 1.1.2

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e2bffbab317006bdd799443ef1dc7e16d9527219261f91c9758504ae85aef638
4
- data.tar.gz: b1e0f8f72d44878564573fcc8076af18defe14375d1bee4cd4946c72139bcc1e
3
+ metadata.gz: 37c2e70c286c4dd7b163201674eff9b4111fbe3f05ef032500a5b7ac516f97b5
4
+ data.tar.gz: fbad7b94e10bb0cbd9efc8c70f1a2546830ac0c20f276a925b48e0d668679902
5
5
  SHA512:
6
- metadata.gz: d16eda21cb080e2dd7decc2522c4dac7c99536f3c720c1de7d64f0065edd7487c633450db9eeb687b12138b2763d3093628e0d169b82d7896c781e7c8298bb3c
7
- data.tar.gz: 5f668930355e17877237892da08dc440630f2015253b072c715e324c38045598765c58cb84d9657d7769527d3f27774f8d4f03f6bec2cc1bb785bca9a308cce2
6
+ metadata.gz: 70319980be04f8abdbb6d5e167e8941ca4fa3a8ab65b0cd71ec884daff639205a70935b4c5a756ec3403678982c2859423051930379aa3d79f302506b0f88326
7
+ data.tar.gz: f100b5622d566ab77da4437cb028138a7fa33f47d4d19ff70874c683e2995e9c3213bc95b0109c03c82556827a26ee22b47a034bf7fc96895effc2c8fcb3950f
data/README.md CHANGED
@@ -244,8 +244,10 @@ top level (`networks:`, `volumes:`, `configs:`, `secrets:`, ...) is the one exce
244
244
  real Compose tools, not by `wip`, so `wip` silently ignores it rather than rejecting an otherwise
245
245
  valid compose.yml over sections it doesn't need to look at:
246
246
 
247
- - Per service: `image`, `build` (string or `{context:, dockerfile:}`; resolved relative to
248
- `compose.yml`, not wherever `wip` is invoked from), `command` (shell or exec form), `environment`
247
+ - Per service: `image`, `build` (string or `{context:, dockerfile:, args:, shadow_context:}`;
248
+ `context` is resolved relative to `compose.yml`, not wherever `wip` is invoked from; `dockerfile`
249
+ stays relative to `context` itself — see [.dockerignore](#dockerignore) for what `shadow_context`
250
+ does), `command` (shell or exec form), `environment`
249
251
  (mapping or `KEY=VALUE` array — a mapping value must not be null; host environment pass-through
250
252
  isn't supported), `ports`/`volumes` (short syntax only — `"host:container"` strings, not
251
253
  long-syntax mappings), `working_dir`, `user`, `depends_on` (ordering only — a `condition:` other
@@ -272,13 +274,14 @@ to load a different file instead.
272
274
 
273
275
  `wip build` reads `.dockerignore` from the build context and excludes anything it matches before
274
276
  handing the context to `wslc build`, since `wslc` sends the context as-is otherwise. Set
275
- `commands.build.shadow_context` in `wip.yml` to a directory on the Windows filesystem to enable a
276
- persistent shadow context for projects outside `/mnt/<drive>`. The first build copies every
277
- included file; later builds only copy added or changed files and remove deleted or newly ignored
278
- files. Without this setting the optimization is disabled, and it only applies under WSL2 on WSL1
279
- (or anywhere else) the context is handed to `wslc build` directly. Projects already on `/mnt/c` (or
280
- another mounted Windows drive) also continue to build directly even when the setting is present.
281
- The path must live outside the build context itself, or `wip build` refuses it.
277
+ `commands.build.shadow_context` in `wip.yml` (or `build.shadow_context` on a compose-native
278
+ `build:` service in `compose.yml`) to a directory on the Windows filesystem to enable a persistent
279
+ shadow context for projects outside `/mnt/<drive>`. The first build copies every included file;
280
+ later builds only copy added or changed files and remove deleted or newly ignored files. Without
281
+ this setting the optimization is disabled, and it only applies under WSL2 on WSL1 (or anywhere
282
+ else) the context is handed to `wslc build` directly. Projects already on `/mnt/c` (or another
283
+ mounted Windows drive) also continue to build directly even when the setting is present. The path
284
+ must live outside the build context itself, or `wip build`/`wip up` refuses it.
282
285
 
283
286
  ### Source sync
284
287
 
@@ -30,6 +30,11 @@ module Wip
30
30
  end
31
31
  end
32
32
 
33
+ # Whether the upcoming stage will use the Windows-side shadow directory
34
+ # (configured shadow_root, on WSL2, with a context outside /mnt) rather
35
+ # than staging in place or under a WSL-side tmpdir.
36
+ def shadow? = shadow_required?
37
+
33
38
  private
34
39
 
35
40
  # A shadow root under the context would itself be walked by included_files
data/lib/wip/cli.rb CHANGED
@@ -76,8 +76,7 @@ module Wip
76
76
  build_context = BuildContext.new(context, shadow_root: settings['shadow_context'])
77
77
  build_context.stage(on_progress: progress.method(:tick)) do |staged_context|
78
78
  progress.finish
79
- built = builder.build(settings: settings.merge('context' => staged_context), extra: extra)
80
- execute(built, interactive: tty?(true))
79
+ run_staged_build(build_context, staged_context, settings, extra)
81
80
  end
82
81
  ensure
83
82
  progress&.finish
@@ -248,10 +247,12 @@ module Wip
248
247
  execute(exec_target(command, interactive: interactive), interactive: tty?(interactive))
249
248
  end
250
249
 
251
- def execute(command, interactive: false, exit_on_failure: true)
250
+ def execute(command, interactive: false, exit_on_failure: true, chdir: nil)
252
251
  runner = CommandRunner.new(debug: debug?)
252
+ run_options = { interactive: interactive }
253
+ run_options[:chdir] = chdir if chdir
253
254
  code = reporter.step("running: #{CommandDisplay.for_debug(command)}", live: !interactive) do
254
- runner.run(command, interactive: interactive)
255
+ runner.run(command, **run_options)
255
256
  end
256
257
  exit(code) if exit_on_failure && !code.zero?
257
258
  code
@@ -322,15 +323,23 @@ module Wip
322
323
  extra = compose_build_extra(spec)
323
324
  warn "wip: building service '#{name}' (tag: #{spec['tag']}) from #{spec['context']}"
324
325
  progress = StagingProgress.new
325
- BuildContext.new(spec['context']).stage(on_progress: progress.method(:tick)) do |staged_context|
326
+ build_context = BuildContext.new(spec['context'], shadow_root: spec['shadow_context'])
327
+ build_context.stage(on_progress: progress.method(:tick)) do |staged_context|
326
328
  progress.finish
327
- settings = { 'context' => staged_context, 'tag' => spec['tag'] }
328
- execute(builder.build(settings: settings, extra: extra), interactive: tty?(true))
329
+ run_staged_build(build_context, staged_context, { 'tag' => spec['tag'] }, extra)
329
330
  end
330
331
  ensure
331
332
  progress&.finish
332
333
  end
333
334
 
335
+ # wslc build crashes (ERROR_UNHANDLED_EXCEPTION) when handed an absolute
336
+ # context path; running from inside the context and passing "." avoids it.
337
+ def run_staged_build(build_context, staged_context, settings, extra)
338
+ warn "wip: using shadow build context at #{staged_context}" if build_context.shadow?
339
+ built = builder.build(settings: settings.merge('context' => '.'), extra: extra)
340
+ execute(built, interactive: tty?(true), chdir: staged_context)
341
+ end
342
+
334
343
  def build_extra_options(extra)
335
344
  extra = extra.dup
336
345
  extra.shift if extra.first == '--'
@@ -356,7 +365,9 @@ module Wip
356
365
 
357
366
  Dir.mktmpdir('wip-sync-build-') do |dir|
358
367
  File.write(File.join(dir, 'Dockerfile'), settings.build['dockerfile'])
359
- execute(builder.sync_build(dir))
368
+ # wslc build crashes (ERROR_UNHANDLED_EXCEPTION) when handed an absolute
369
+ # context path; running from inside the context and passing "." avoids it.
370
+ execute(builder.sync_build('.'), chdir: dir)
360
371
  end
361
372
  end
362
373
 
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'open3'
4
+ require 'pty' unless Gem.win_platform?
5
+ require 'io/console'
4
6
 
5
7
  module Wip
6
8
  # Executes a built command, pumping its I/O and returning the exit status.
@@ -14,13 +16,14 @@ module Wip
14
16
  @debug = debug
15
17
  end
16
18
 
17
- def run(command, env: {}, interactive: false)
19
+ def run(command, env: {}, interactive: false, chdir: nil)
18
20
  @stderr.puts "+ #{CommandDisplay.for_debug(command)}" if @debug
19
- return run_attached(command, env) if interactive
21
+ return run_interactive(command, env, chdir) if interactive
20
22
 
23
+ opts = chdir ? { chdir: chdir } : {}
21
24
  captured = +''
22
25
  status = nil
23
- Open3.popen3(env, *command) do |input, output, error, wait|
26
+ Open3.popen3(env, *command, opts) do |input, output, error, wait|
24
27
  input.close
25
28
  threads = [pump(output, @stdout, captured), pump(error, @stderr, captured)]
26
29
  threads.each(&:join)
@@ -38,6 +41,10 @@ module Wip
38
41
 
39
42
  private
40
43
 
44
+ def run_interactive(command, env, chdir)
45
+ Gem.win_platform? ? run_inherited(command, env, chdir) : run_attached(command, env, chdir)
46
+ end
47
+
41
48
  # exitstatus is nil when the process was killed by a signal instead of
42
49
  # exiting normally; fall back to the conventional 128+signal shell code
43
50
  # so callers always get a comparable integer.
@@ -52,15 +59,128 @@ module Wip
52
59
 
53
60
  # Piping stdin/stdout/stderr (as `run` does above) closes the child's
54
61
  # stdin immediately, which breaks anything that reads from the terminal
55
- # (a shell, `rails console`, ...). Inherit the real file descriptors
56
- # instead so the child gets a genuine TTY.
57
- def run_attached(command, env)
58
- pid = Process.spawn(env, *command)
62
+ # (a shell, `rails console`, ...). Run it behind a pseudo-terminal instead
63
+ # of inheriting wip's real fds directly: a pty still gives the child a
64
+ # genuine controlling terminal (job control, Ctrl-C -> SIGINT, isatty-gated
65
+ # rendering all work the same as direct inheritance), but routes output
66
+ # through wip first, so report_hint can still see it — inherited fds go
67
+ # straight to the terminal and wip never would. wip's own terminal is
68
+ # switched to raw mode for the duration so only the pty's line discipline
69
+ # echoes input; without that, every keystroke would echo twice.
70
+ def run_attached(command, env, chdir)
71
+ opts = chdir ? { chdir: chdir } : {}
72
+ captured = +''
73
+ status = nil
74
+ PTY.spawn(env, *command, opts) do |output, input, pid|
75
+ sync_winsize(output)
76
+ with_winsize_sync(output) { with_raw_stdin { pump_attached(output, input, captured) } }
77
+ _, status = Process.wait2(pid)
78
+ end
79
+ report_hint(captured) unless status.success?
80
+ exitstatus(status)
81
+ rescue Errno::ENOENT => e
82
+ @stderr.puts e.message
83
+ 127
84
+ rescue Interrupt
85
+ @stderr.puts "\nwip: interrupted"
86
+ 130
87
+ end
88
+
89
+ # Ruby's PTY library wraps openpty(3), which native Windows builds don't
90
+ # ship (require 'pty' is skipped there entirely, see the top of this
91
+ # file). Fall back to letting the child inherit wip's real stdio
92
+ # directly, same as `run` would've done pre-pty for the piped case: it
93
+ # still gives the child a real console (job control, Ctrl-C, isatty
94
+ # rendering all work), just without a way for wip to see the output, so
95
+ # report_hint can't run on failure here.
96
+ def run_inherited(command, env, chdir)
97
+ opts = chdir ? { chdir: chdir } : {}
98
+ opts[:in] = @stdin if io_stream?(@stdin)
99
+ opts[:out] = @stdout if io_stream?(@stdout)
100
+ opts[:err] = @stderr if io_stream?(@stderr)
101
+ pid = nil
102
+ pid = Process.spawn(env, *command, opts)
59
103
  _, status = Process.wait2(pid)
60
104
  exitstatus(status)
61
105
  rescue Errno::ENOENT => e
62
106
  @stderr.puts e.message
63
107
  127
108
+ rescue Interrupt
109
+ reap_interrupted_child(pid)
110
+ @stderr.puts "\nwip: interrupted"
111
+ 130
112
+ end
113
+
114
+ # Process.spawn can only redirect to real OS-backed streams (an IO, or
115
+ # something that wraps one via to_io) — StringIO and friends (used by
116
+ # tests, and by wip's own quiet/capture modes) aren't valid redirection
117
+ # targets. Fall back to the process's own inherited fds for those rather
118
+ # than passing them through and having Process.spawn reject them.
119
+ def io_stream?(stream) = stream.respond_to?(:to_io)
120
+
121
+ # The child shares wip's real controlling terminal here (unlike the pty
122
+ # path), so it already received the same Ctrl-C the terminal delivered to
123
+ # wip; there's nothing to kill, just something to reap so it doesn't
124
+ # linger as a zombie once wip moves on.
125
+ def reap_interrupted_child(pid)
126
+ return unless pid
127
+
128
+ Process.wait2(pid)
129
+ rescue Errno::ECHILD, Interrupt
130
+ nil
131
+ end
132
+
133
+ def pump_attached(output, input, captured)
134
+ stdin_thread = forward_stdin(input)
135
+ loop do
136
+ chunk = output.readpartial(4096)
137
+ @stdout.write(chunk)
138
+ captured << chunk
139
+ end
140
+ rescue Errno::EIO, IOError
141
+ # the pty's slave side closed when the child exited
142
+ ensure
143
+ stdin_thread.kill
144
+ end
145
+
146
+ # Keeps the child's pty sized to wip's real terminal so full-screen
147
+ # programs (an editor, `less`, ...) render correctly. A non-tty @stdout
148
+ # (piped output, tests) has no size to read, so the pty keeps its default.
149
+ def sync_winsize(output)
150
+ output.winsize = @stdout.winsize if @stdout.respond_to?(:winsize) && @stdout.tty?
151
+ end
152
+
153
+ # The child's pty only gets the terminal size wip had at spawn time
154
+ # (sync_winsize, above) — it's a separate pty from wip's own real one, so
155
+ # later resizes of wip's terminal don't reach it on their own. Trap
156
+ # SIGWINCH for the duration to re-sync it live, restoring whatever handler
157
+ # was already installed (if any) once the command finishes.
158
+ def with_winsize_sync(output)
159
+ return yield unless @stdout.respond_to?(:winsize) && @stdout.tty?
160
+
161
+ previous = Signal.trap('WINCH') { sync_winsize(output) }
162
+ yield
163
+ ensure
164
+ Signal.trap('WINCH', previous) if previous
165
+ end
166
+
167
+ # A non-tty @stdin (piped input, tests) has no raw mode to switch to, so
168
+ # it's forwarded to the child as-is.
169
+ def with_raw_stdin(&)
170
+ return yield unless @stdin.respond_to?(:raw) && @stdin.tty?
171
+
172
+ @stdin.raw(&)
173
+ end
174
+
175
+ def forward_stdin(input)
176
+ Thread.new do
177
+ loop do
178
+ chunk = @stdin.readpartial(4096)
179
+ input.write(chunk)
180
+ end
181
+ rescue IOError, Errno::EIO, Errno::EBADF
182
+ nil
183
+ end
64
184
  end
65
185
 
66
186
  # An Interrupt during the main thread's `join` closes these pipes out from
@@ -24,7 +24,7 @@ module Wip
24
24
  # service already shares one project network (config.rb); and `wslc run`/`exec` has no
25
25
  # restart-policy or capability flag to forward `restart:`/`cap_add:` to.
26
26
  IGNORED_SERVICE_KEYS = %w[tty stdin_open networks restart cap_add].freeze
27
- BUILD_KEYS = %w[context dockerfile args].freeze
27
+ BUILD_KEYS = %w[context dockerfile args shadow_context].freeze
28
28
  SUPPORTED_CONDITIONS = %w[service_started].freeze
29
29
  LIST_HINT = 'only supports short syntax ("host:container"), not long-syntax mappings'
30
30
 
@@ -140,9 +140,11 @@ module Wip
140
140
  unless unknown.empty?
141
141
 
142
142
  context = resolve_context(presence(value['context']) || '.')
143
- dockerfile = presence(value['dockerfile'])
144
- { 'context' => context, 'dockerfile' => dockerfile && Pathname(context).join(dockerfile).to_s,
145
- 'args' => normalize_kv(name, value['args'], 'build.args') }.compact
143
+ # Kept relative to context (not resolved against it) so `-f` still finds it once
144
+ # `wip up`/`wip build` chdir into a staged or shadowed copy of that context.
145
+ { 'context' => context, 'dockerfile' => presence(value['dockerfile']),
146
+ 'args' => normalize_kv(name, value['args'], 'build.args'),
147
+ 'shadow_context' => presence(value['shadow_context']) }.compact
146
148
  end
147
149
 
148
150
  # build.context is relative to compose.yml's own directory (Compose's own rule),
data/lib/wip/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Wip
4
- VERSION = '1.1.0'
4
+ VERSION = '1.1.2'
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: wslc-wip
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.1.0
4
+ version: 1.1.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Wip contributors