wslc-wip 0.4.2 → 0.5.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: da7bb5fa632921c307adeeea727e792d38e688204393dfd3ee2c7759e40791b4
4
- data.tar.gz: 2e2a277a4ab2dcf0ce540778b6263a142553430452905924f67158dc13ffed54
3
+ metadata.gz: '0845dfb67f17323b8a99e6f2f92ef06c17bc03fd1316680cf5f43f2640d73da3'
4
+ data.tar.gz: 6eea9bb520aed0fff5b401b71a20dccdade6b50d9a71403037e59e4e5b83ab2e
5
5
  SHA512:
6
- metadata.gz: f71156a19e193d0402f2fa4dc501aca0f32b27d63807c20b7a1a8dcddf1932f091af8ec12a0310aac36084a6c28034d843100abbf4adc3ac9c081d4e9304c18c
7
- data.tar.gz: 538f842fd08441c9ec1603fc147d7c5ce7a5c6f2f2ce22edd0673b12709da19a6c23d3e7f1231a73d69d675337f09a17bc312aa07792308df414f1130e11b2a5
6
+ metadata.gz: 1b522d206ba3070244f51bda9d4e144dfe95df63ab01bb92b35d0fe0e84a29035c997d7b061c2ec23286c8bd9f586c69e276b7304abd1c8ad81fe808293599b5
7
+ data.tar.gz: d7fc237820edfcc734f55775e027133c5f9877c24b16f5b1ca6aa98304b35fdea5825a179ff7f64b4d22fe2634d52d11761777284b68a86e76faa56432d33230
data/README.md CHANGED
@@ -103,6 +103,22 @@ commands:
103
103
  credential, or auth. Keep real secrets out of the config file and in your runtime environment
104
104
  instead.
105
105
 
106
+ ### .env
107
+
108
+ Like `docker compose`, `wip` automatically loads a `.env` file next to `wip.yml` (one `KEY=VALUE`
109
+ per line; `#` comments, blank lines, `export` prefixes, and quoted values are all supported) and
110
+ passes its keys through as container environment variables on `build`, `up`, `run`, `exec`, and
111
+ custom commands. `.env` only fills in keys that aren't already set by `defaults.env` or a
112
+ command/dependency's own `env` — those always win on conflict. Pass `--env-file PATH` to load a
113
+ different file instead.
114
+
115
+ ### .dockerignore
116
+
117
+ `wip build` reads `.dockerignore` from the build context and stages a filtered copy of the
118
+ context (skipping anything it matches) before handing it to `wslc build`, since `wslc` sends the
119
+ context as-is otherwise. If there's no `.dockerignore`, the original context directory is used
120
+ directly with no copying.
121
+
106
122
  ### Dependency containers
107
123
 
108
124
  If your app needs sidecar services (a database, Redis, ...), declare them under `dependencies`
@@ -148,14 +164,30 @@ exit, but the timestamp of the `+ ...` line tells you when wip finished its own
148
164
  off to `wslc`/`docker` — useful for telling wip-side overhead apart from time spent booting inside
149
165
  the container.
150
166
 
151
- While a step is still running, wip also prints a host resource snapshot (load average, memory
152
- used, and the top CPU-consuming processes) every 5 seconds, so a hang is visible even before the
153
- command has produced any output of its own:
167
+ While a step is still running, wip also prints a host resource snapshot (load average, memory,
168
+ disk I/O, and the top CPU-consuming processes) every 5 seconds, so a hang is visible even before
169
+ the command has produced any output of its own:
154
170
 
155
171
  ```console
156
- wip: [debug] still running (load 3.42 2.10 1.05 | mem 6.1G/15.6G | top: wslc.exe(8842) cpu 61.0%/mem 3.2%, ...): running: wslc.exe exec -it -w /app app bin/rails c
172
+ wip: [debug] still running (load 3.42 2.10 1.05 | mem 6.1G/15.6G | io read 12000KB/s write 400KB/s | top: wslc.exe(8842) cpu 61.0%/mem 3.2%, ...): running: wslc.exe exec -it -w /app app bin/rails c
157
173
  ```
158
174
 
175
+ The disk I/O figure is worth watching first if the host's CPU and memory look idle — a slow
176
+ `bundle`/`rails` boot is often WSL2's bind-mounted (`.:/app`-style) volumes doing a lot of small
177
+ reads, not the container starving for CPU.
178
+
179
+ For commands that hand the real terminal to the child (`-it`, e.g. `rails c`), these periodic
180
+ snapshots go to a log file instead of your terminal — wip prints the path once at the start —
181
+ since writing into a terminal the child controls in raw mode would garble both outputs. Commands
182
+ that don't need a TTY still get the snapshots printed live.
183
+
184
+ Override that choice with `--debug-log`:
185
+
186
+ - `--debug-log=-` forces snapshots inline even for `-it` commands (only useful if you know your
187
+ terminal/pager can tolerate the interleaving).
188
+ - `--debug-log=PATH` always writes snapshots to `PATH`, including for non-TTY commands, e.g. to
189
+ keep every run's snapshots in one place: `wip rails c --debug --debug-log=/tmp/wip-debug.log`.
190
+
159
191
  ## doctor
160
192
 
161
193
  Each check prints as `[OK]`, `[WARN]`, or `[FAIL]`. Warnings alone exit 0; a WSL2, interop, WSLC,
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'pathname'
5
+ require 'tmpdir'
6
+
7
+ module Wip
8
+ # Stages a build context into a scratch directory with anything matched by
9
+ # .dockerignore left out, since wslc build (unlike `docker build`) sends the
10
+ # context as-is instead of filtering it itself.
11
+ class BuildContext
12
+ def initialize(context, ignore: nil)
13
+ @root = Pathname(context).expand_path
14
+ @ignore = ignore || DockerIgnore.load(@root.join('.dockerignore'))
15
+ end
16
+
17
+ def stage
18
+ return yield @root.to_s if @ignore.empty?
19
+
20
+ Dir.mktmpdir('wip-build-context-') do |dir|
21
+ copy_included_files(Pathname(dir))
22
+ yield dir
23
+ end
24
+ end
25
+
26
+ private
27
+
28
+ def copy_included_files(destination)
29
+ each_included_file do |relative_path|
30
+ target = destination.join(relative_path)
31
+ FileUtils.mkdir_p(target.dirname)
32
+ FileUtils.cp(@root.join(relative_path), target)
33
+ end
34
+ end
35
+
36
+ def each_included_file
37
+ Dir.glob('**/*', File::FNM_DOTMATCH, base: @root.to_s).each do |entry|
38
+ next if %w[. ..].include?(entry)
39
+
40
+ path = @root.join(entry)
41
+ next unless path.file?
42
+ next if @ignore.ignored?(entry)
43
+
44
+ yield entry
45
+ end
46
+ end
47
+ end
48
+ end
data/lib/wip/cli.rb CHANGED
@@ -9,7 +9,9 @@ module Wip
9
9
  # Thor-based command-line interface for wip.
10
10
  class CLI < Thor
11
11
  class_option :config, type: :string, desc: 'Path to wip.yml'
12
+ class_option :env_file, type: :string, desc: 'Path to a dotenv file (default: .env next to wip.yml)'
12
13
  class_option :debug, type: :boolean, default: false, desc: 'Print progress and timing for each step'
14
+ class_option :debug_log, type: :string, desc: 'Where --debug snapshots go: a file path, or "-" for inline'
13
15
  default_task :dispatch
14
16
 
15
17
  def self.exit_on_failure? = true
@@ -51,7 +53,11 @@ module Wip
51
53
  desc 'build [OPTIONS]', 'Build the configured image'
52
54
  def build(*extra)
53
55
  extra.shift if extra.first == '--'
54
- execute(builder.build(settings: load_config.command('build') || {}, extra: extra))
56
+ settings = load_config.command('build') || {}
57
+ context = settings['context'] || load_config.defaults['context'] || '.'
58
+ BuildContext.new(context).stage do |staged_context|
59
+ execute(builder.build(settings: settings.merge('context' => staged_context), extra: extra))
60
+ end
55
61
  end
56
62
 
57
63
  desc 'up', 'Start the configured container and its dependencies, creating them if necessary'
@@ -117,11 +123,20 @@ module Wip
117
123
  def loader = ConfigLoader.new(path: options[:config])
118
124
  def load_config = (@load_config ||= loader.load)
119
125
  def resolver = CommandResolver.new
120
- def builder = CommandBuilder.new(wslc: resolver.resolve(load_config.wslc_command), config: load_config)
126
+
127
+ def dotenv_path
128
+ options[:env_file] ? Pathname(options[:env_file]).expand_path : Pathname(load_config.path).dirname.join('.env')
129
+ end
130
+
131
+ def dotenv = @dotenv ||= DotenvLoader.new(dotenv_path).load
132
+
133
+ def builder
134
+ CommandBuilder.new(wslc: resolver.resolve(load_config.wslc_command), config: load_config, dotenv: dotenv)
135
+ end
121
136
 
122
137
  def execute(command, interactive: false, exit_on_failure: true)
123
138
  runner = CommandRunner.new(debug: debug?)
124
- code = reporter.step("running: #{CommandDisplay.for_debug(command)}") do
139
+ code = reporter.step("running: #{CommandDisplay.for_debug(command)}", live: !interactive) do
125
140
  runner.run(command, interactive: interactive)
126
141
  end
127
142
  exit(code) if exit_on_failure && !code.zero?
@@ -143,7 +158,7 @@ module Wip
143
158
  end
144
159
 
145
160
  def debug? = options[:debug] || !ENV['WIP_DEBUG'].to_s.empty?
146
- def reporter = @reporter ||= DebugReporter.new(enabled: debug?)
161
+ def reporter = @reporter ||= DebugReporter.new(enabled: debug?, log: options[:debug_log])
147
162
 
148
163
  def ensure_network
149
164
  network = load_config.network
@@ -5,10 +5,11 @@ require 'shellwords'
5
5
  module Wip
6
6
  # Builds the argument arrays for wslc build/exec/run/custom invocations.
7
7
  class CommandBuilder
8
- def initialize(wslc:, config:, environment: Environment.new)
8
+ def initialize(wslc:, config:, environment: Environment.new, dotenv: {})
9
9
  @wslc = wslc
10
10
  @config = config
11
11
  @environment = environment
12
+ @dotenv = dotenv
12
13
  end
13
14
 
14
15
  def exec(arguments, settings: {}, interactive: true)
@@ -115,7 +116,7 @@ module Wip
115
116
  def options(values, include_container: false, include_publish: true)
116
117
  result = []
117
118
  result.push('-w', values['workdir']) unless values['workdir'].to_s.empty?
118
- values.fetch('env', {}).each { |key, value| result.push('-e', "#{key}=#{value}") }
119
+ merged_env(values).each { |key, value| result.push('-e', "#{key}=#{value}") }
119
120
  if include_publish
120
121
  Array(values['ports']).each { |port| result.push('-p', port.to_s) }
121
122
  Array(values['volumes']).each { |volume| result.push('-v', volume.to_s) }
@@ -124,6 +125,9 @@ module Wip
124
125
  result
125
126
  end
126
127
 
128
+ # .env supplies defaults; env set in wip.yml (defaults or per-command) wins on conflict.
129
+ def merged_env(values) = @dotenv.merge(values.fetch('env', {}))
130
+
127
131
  def required(values, key)
128
132
  value = values[key]
129
133
  raise ConfigError, "Configured #{key} must not be empty" if value.to_s.empty?
@@ -1,27 +1,59 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'tmpdir'
4
+
3
5
  module Wip
4
6
  # Prints each step wip takes and how long it took, when debug mode is enabled.
5
7
  class DebugReporter
6
- def initialize(enabled:, out: $stderr)
8
+ # `log:` overrides where resource snapshots go, regardless of `live`:
9
+ # nil - automatic (see `step`'s `live:`)
10
+ # '-' - always print inline, even for interactive steps
11
+ # path - always write to this file, even for non-interactive steps
12
+ def initialize(enabled:, out: $stderr, log: nil)
7
13
  @enabled = enabled
8
14
  @out = out
15
+ @log = log
9
16
  end
10
17
 
11
- def step(label)
18
+ # `live: false` is for steps that hand the real TTY to the child process
19
+ # (e.g. `wslc exec -it`). The child owns raw-mode cursor control there, so
20
+ # writing periodic snapshots straight into that same terminal races with
21
+ # it and garbles the output; those snapshots go to a log file instead,
22
+ # unless overridden by `log:` in the constructor.
23
+ def step(label, live: true)
12
24
  return yield unless @enabled
13
25
 
14
26
  started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
15
27
  @out.puts "wip: [debug] #{label}"
16
- monitor = ResourceMonitor.new(out: @out)
28
+ file = log_file(live)
29
+ monitor = ResourceMonitor.new(out: file || @out)
17
30
  monitor.start(label)
18
31
  begin
19
32
  yield
20
33
  ensure
21
34
  monitor.stop
35
+ file&.close
22
36
  elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started
23
37
  @out.puts format('wip: [debug] done in %<elapsed>.2fs: %<label>s', elapsed: elapsed, label: label)
24
38
  end
25
39
  end
40
+
41
+ private
42
+
43
+ def log_file(live)
44
+ return nil if @log == '-'
45
+ return open_log(@log, "streaming resource snapshots to #{@log}") if @log
46
+ return nil if live
47
+
48
+ path = File.join(Dir.tmpdir, "wip-debug-#{Process.pid}-#{Time.now.to_i}.log")
49
+ open_log(path, "command owns the terminal; streaming resource snapshots to #{path}")
50
+ end
51
+
52
+ def open_log(path, notice)
53
+ @out.puts "wip: [debug] #{notice}"
54
+ file = File.open(path, 'a') # rubocop:disable Style/FileOpen -- closed by `step`'s ensure block
55
+ file.sync = true
56
+ file
57
+ end
26
58
  end
27
59
  end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'pathname'
4
+
5
+ module Wip
6
+ # Parses a .dockerignore file and decides whether a build-context-relative
7
+ # path should be excluded, following the same pattern rules as the Docker
8
+ # CLI (gitignore-like globs, later rules override earlier ones, `!` negates).
9
+ class DockerIgnore
10
+ Rule = Struct.new(:pattern, :negate)
11
+
12
+ FNMATCH_FLAGS = File::FNM_PATHNAME | File::FNM_DOTMATCH | File::FNM_EXTGLOB
13
+
14
+ def self.load(path)
15
+ path = Pathname(path)
16
+ return new([]) unless path.file?
17
+
18
+ new(path.readlines)
19
+ end
20
+
21
+ def initialize(lines)
22
+ @rules = lines.filter_map { |line| parse(line) }
23
+ end
24
+
25
+ def empty? = @rules.empty?
26
+
27
+ def ignored?(relative_path)
28
+ @rules.reduce(false) do |ignored, rule|
29
+ matches?(rule.pattern, relative_path) ? !rule.negate : ignored
30
+ end
31
+ end
32
+
33
+ private
34
+
35
+ def parse(line)
36
+ line = line.strip
37
+ return nil if line.empty? || line.start_with?('#')
38
+
39
+ negate = line.start_with?('!')
40
+ pattern = negate ? line[1..] : line
41
+ pattern = pattern.delete_suffix('/')
42
+ anchored = pattern.start_with?('/')
43
+ pattern = pattern.delete_prefix('/')
44
+ pattern = "**/#{pattern}" if !anchored && !pattern.include?('/')
45
+ Rule.new(pattern, negate)
46
+ end
47
+
48
+ # A match on a directory component also excludes everything under it,
49
+ # so every prefix of the path (not just the full path) is tested.
50
+ def matches?(pattern, relative_path)
51
+ components = relative_path.split('/')
52
+ (1..components.size).any? do |i|
53
+ File.fnmatch(pattern, components[0...i].join('/'), FNMATCH_FLAGS)
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'pathname'
4
+
5
+ module Wip
6
+ # Parses a .env file the way `docker compose` does, so values don't have to
7
+ # be duplicated into wip.yml just to reach the container as -e flags.
8
+ class DotenvLoader
9
+ LINE_PATTERN = /\A(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)=(.*)\z/
10
+
11
+ def initialize(path)
12
+ @path = Pathname(path)
13
+ end
14
+
15
+ def load
16
+ return {} unless @path.file?
17
+
18
+ @path.readlines.each_with_object({}) do |line, env|
19
+ line = line.strip
20
+ next if line.empty? || line.start_with?('#')
21
+
22
+ match = LINE_PATTERN.match(line)
23
+ next unless match
24
+
25
+ env[match[1]] = unquote(match[2])
26
+ end
27
+ end
28
+
29
+ private
30
+
31
+ def unquote(value)
32
+ value = value.strip
33
+ return value[1..-2] if value.start_with?('"') && value.end_with?('"') && value.length >= 2
34
+ return value[1..-2] if value.start_with?("'") && value.end_with?("'") && value.length >= 2
35
+
36
+ value.split(/\s+#/, 2).first.to_s.strip
37
+ end
38
+ end
39
+ end
@@ -1,19 +1,22 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Wip
4
- # Periodically prints host CPU/memory/process info while a debug step is still
5
- # running, so a hung or slow step is visible even before it produces output.
4
+ # Periodically prints host CPU/memory/disk-IO/process info while a debug step
5
+ # is still running, so a hung or slow step is visible even before it produces
6
+ # output of its own.
6
7
  class ResourceMonitor
7
8
  def initialize(interval: 5, out: $stderr)
8
9
  @interval = interval
9
10
  @out = out
11
+ @last_disk = disk_sectors
12
+ @last_sampled_at = monotonic
10
13
  end
11
14
 
12
15
  def start(label)
13
16
  @thread = Thread.new do
14
17
  loop do
15
18
  sleep @interval
16
- @out.puts "wip: [debug] still running (#{load_average} | #{memory} | top: #{top_processes}): #{label}"
19
+ @out.puts "wip: [debug] still running (#{snapshot}): #{label}"
17
20
  end
18
21
  end
19
22
  end
@@ -25,6 +28,12 @@ module Wip
25
28
 
26
29
  private
27
30
 
31
+ def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
32
+
33
+ def snapshot
34
+ [load_average, memory, disk_io, top_processes].join(' | ')
35
+ end
36
+
28
37
  def load_average
29
38
  "load #{File.read('/proc/loadavg').split[0..2].join(' ')}"
30
39
  rescue Errno::ENOENT, IOError
@@ -40,15 +49,46 @@ module Wip
40
49
  'mem n/a'
41
50
  end
42
51
 
52
+ # WSL2's bind-mounted (9p) volumes are often the real bottleneck behind a
53
+ # slow `bundle`/`rails` boot, and that shows up here rather than in CPU.
54
+ def disk_io
55
+ now = monotonic
56
+ elapsed = [now - @last_sampled_at, 0.001].max
57
+ current = disk_sectors
58
+ read_kbps = (current[:read] - @last_disk[:read]) * 0.5 / elapsed
59
+ write_kbps = (current[:write] - @last_disk[:write]) * 0.5 / elapsed
60
+ @last_disk = current
61
+ @last_sampled_at = now
62
+ format('io read %<read>.0fKB/s write %<write>.0fKB/s', read: read_kbps, write: write_kbps)
63
+ rescue Errno::ENOENT, IOError
64
+ 'io n/a'
65
+ end
66
+
67
+ def disk_sectors
68
+ totals = { read: 0, write: 0 }
69
+ File.readlines('/proc/diskstats').each do |line|
70
+ fields = line.split
71
+ next if fields[2].to_s.match?(/\A(loop|ram)/)
72
+
73
+ totals[:read] += fields[5].to_i
74
+ totals[:write] += fields[9].to_i
75
+ end
76
+ totals
77
+ rescue Errno::ENOENT, IOError
78
+ { read: 0, write: 0 }
79
+ end
80
+
43
81
  def top_processes
44
- `ps -eo pid,pcpu,pmem,comm --sort=-pcpu 2>/dev/null`.lines.drop(1).first(3).filter_map do |line|
82
+ lines = `ps -eo pid,pcpu,pmem,comm --sort=-pcpu 2>/dev/null`.lines.drop(1).first(3)
83
+ entries = lines.filter_map do |line|
45
84
  pid, cpu, mem, comm = line.split(nil, 4)
46
85
  next unless comm
47
86
 
48
87
  "#{comm.strip}(#{pid}) cpu #{cpu}%/mem #{mem}%"
49
- end.join(', ')
88
+ end
89
+ "top: #{entries.join(', ')}"
50
90
  rescue StandardError
51
- 'n/a'
91
+ 'top: n/a'
52
92
  end
53
93
  end
54
94
  end
data/lib/wip/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Wip
4
- VERSION = '0.4.2'
4
+ VERSION = '0.5.0'
5
5
  end
data/lib/wip.rb CHANGED
@@ -5,6 +5,9 @@ require_relative 'wip/errors'
5
5
  require_relative 'wip/config'
6
6
  require_relative 'wip/config_loader'
7
7
  require_relative 'wip/environment'
8
+ require_relative 'wip/dotenv_loader'
9
+ require_relative 'wip/docker_ignore'
10
+ require_relative 'wip/build_context'
8
11
  require_relative 'wip/command_resolver'
9
12
  require_relative 'wip/error_interpreter'
10
13
  require_relative 'wip/command_builder'
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: 0.4.2
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Wip contributors
@@ -33,6 +33,7 @@ files:
33
33
  - README.md
34
34
  - exe/wip
35
35
  - lib/wip.rb
36
+ - lib/wip/build_context.rb
36
37
  - lib/wip/cli.rb
37
38
  - lib/wip/command_builder.rb
38
39
  - lib/wip/command_display.rb
@@ -41,7 +42,9 @@ files:
41
42
  - lib/wip/config.rb
42
43
  - lib/wip/config_loader.rb
43
44
  - lib/wip/debug_reporter.rb
45
+ - lib/wip/docker_ignore.rb
44
46
  - lib/wip/doctor.rb
47
+ - lib/wip/dotenv_loader.rb
45
48
  - lib/wip/environment.rb
46
49
  - lib/wip/error_interpreter.rb
47
50
  - lib/wip/errors.rb