wslc-wip 0.6.0 → 0.7.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: c125a123c76462cbb6b78fe0ead5ea400b8fd0f7a8c825421985547190603d02
4
- data.tar.gz: 0762dbbf6480a84d9ef58d9481d1398672b5d2ef7e8d644d39be655558704022
3
+ metadata.gz: b0fdc63759c329dc9cbc9a60a0ef184db31087d454070871bc3f39a1982d7426
4
+ data.tar.gz: 447d4f3206b3662def93d376b950de99564f3efbfc85e8607bbaad6fb432545d
5
5
  SHA512:
6
- metadata.gz: b91868415f3069167ddd479616d9bd2937d3684af2e7c6228789b198cd7af952e9d2705fe363877ef05a6831a7f5a8e9a203e073623bb0c4b46102f2771429c5
7
- data.tar.gz: 88b8d3fdec972776b0bdefedd5753ebcb4a025cb9bf54122ad7eb723e663f4cac64dff5a4ec201bd45b5959b9f44ba6801e8441419ff72f388631eb7eee80f67
6
+ metadata.gz: fc07a96116a3c737332a313447663a1730d6d15729c329b6e121a356de2bf62ba696b7386a854c685a7bb2415fc5b7c0edc42437ec86b80b2d13de99b6075cad
7
+ data.tar.gz: 61b8a28cd2953335ca38a97cf15f52302aa070698d183d95fefc1edcd7d85d661cb8461ebe1de830969f4b9480734dfeb13c6431821925a500377eb56b1a4221
data/README.md CHANGED
@@ -97,6 +97,11 @@ commands:
97
97
  type: build
98
98
  context: .
99
99
  tag: slidict/slidict:development
100
+ sync: # optional; mirror the source into a named volume instead of bind-mounting it live
101
+ exclude:
102
+ - .git
103
+ - tmp/
104
+ - node_modules/
100
105
  ```
101
106
 
102
107
  `env` values are stringified. `wip config` masks any key matching token, password, secret,
@@ -129,6 +134,56 @@ the same way Compose's service names resolve. `wip down` tears the main containe
129
134
  dependencies down (the network itself is left in place). Each dependency entry accepts `image`
130
135
  (required), `command`, `env`, `ports`, `volumes`, and `workdir` — the same shape as `defaults`.
131
136
 
137
+ ### Source sync
138
+
139
+ Bind-mounting the app directory (`.:/app`) is what usually makes a container boot crawl under
140
+ wslc — see [Slow boot when the app directory is
141
+ bind-mounted](#slow-boot-when-the-app-directory-is-bind-mounted) for why. A `sync:` block hands
142
+ that problem to `wip`: the source is mounted read-only, the app runs off a named volume, and wip
143
+ mirrors one into the other with `rsync`.
144
+
145
+ ```yaml
146
+ sync:
147
+ source: . # host path, relative to wip.yml (default: the wip.yml directory)
148
+ target: /app # container path served by the volume (default: defaults.workdir, else /app)
149
+ volume: app-src # named volume holding the mirror (default: "<defaults.container>-src")
150
+ mount: /host-src # where the source is bind-mounted read-only (default: /host-src)
151
+ exclude: # rsync --exclude patterns
152
+ - .git
153
+ - tmp/
154
+ - node_modules/
155
+ delete: true # rsync --delete (default: true)
156
+ command: rsync # binary that does the mirroring (default: rsync)
157
+ options: [] # extra flags appended to the rsync invocation
158
+ interval: 2 # seconds between syncs for `wip sync --watch` (default: 2)
159
+ ```
160
+
161
+ Everything below `sync:` is optional — `sync: {}` alone already works. With it in place:
162
+
163
+ - Any `defaults.volumes` entry mounting `target` (the usual `.:/app`) is replaced by
164
+ `<source>:/host-src:ro` plus `app-src:/app`, so the running app only ever touches the volume.
165
+ Other volumes (`bundle:/usr/local/bundle`, ...) are passed through untouched, and
166
+ `dependencies:` keep mounting whatever they declare.
167
+ - `wip up` mirrors the source into the volume before the container boots; `wip up --no-sync`
168
+ skips that step.
169
+ - `wip sync` mirrors on demand — `wslc exec`ing rsync inside the container when it's running, and
170
+ falling back to a throwaway container with the same mounts when it isn't.
171
+ - `wip sync --watch [--interval N]` keeps re-syncing until Ctrl-C, so host edits reach the
172
+ container with a short delay. Run it in a second terminal alongside `wip up -d`.
173
+ - `wip doctor` reports the resolved source, volume, and target, and fails if the source is missing.
174
+
175
+ Like every built-in command, `wip sync` takes precedence over a `commands:` entry of the same
176
+ name; wip says so and points at `wip dispatch sync`, which still runs yours.
177
+
178
+ Two things to keep in mind. The mirror runs `rsync` *inside* the container, so the image needs it
179
+ (`RUN apt-get update && apt-get install -y rsync`) — or point `sync.command` at a copy tool the
180
+ image already has. And the mirror is one-way (host → volume): anything the app writes under
181
+ `target` is removed by the next `--delete` pass unless you `exclude` it, give it its own volume,
182
+ or set `delete: false`.
183
+
184
+ `sync:` is mutually exclusive with `compose:` — in compose mode the compose file owns the volume
185
+ layout.
186
+
132
187
  ### Compose mode
133
188
 
134
189
  If your project already has a real `compose.yml`, don't duplicate it in `dependencies:` — point
@@ -178,12 +233,13 @@ found, its version, and which compose file `wip` resolved.
178
233
  | `wip doctor` | Diagnose WSL2, interop, WSLC, config, architecture, and Git |
179
234
  | `wip config` | Print the effective configuration (secrets masked) |
180
235
  | `wip build -- --no-cache` | Build the image from the `build` definition |
181
- | `wip up [-d]` | Start `defaults.container` and its `dependencies` (creating any that are missing, on `defaults.network` if set). `-d` runs the main container in the background |
236
+ | `wip up [-d] [--no-sync]` | Start `defaults.container` and its `dependencies` (creating any that are missing, on `defaults.network` if set). `-d` runs the main container in the background; with `sync:` configured, the source is mirrored into the volume first unless `--no-sync` |
182
237
  | `wip down` | Stop and remove `defaults.container` and its `dependencies` |
183
238
  | `wip exec [--no-interactive] COMMAND...` | Run a command in the existing container |
184
239
  | `wip run [--no-interactive] COMMAND...` | Run a command in a new `--rm` container (compose mode: `exec`s into `compose.service` instead) |
185
240
  | `wip shell` | Open the configured shell, falling back to `bash` then `sh` |
186
241
  | `wip logs [-f] [SERVICE...]` | Follow compose service logs (compose mode only) |
242
+ | `wip sync [-w] [--interval N]` | Mirror the source into the sync volume once, or keep re-syncing with `--watch` (needs `sync:`) |
187
243
  | `wip NAME ARGS...` | Run `commands.NAME`, appending any extra arguments |
188
244
 
189
245
  TTY allocation is decided by combining the command's config, the CLI option, and whether both
@@ -261,12 +317,11 @@ side can look busy while almost no data is actually transferred, and the process
261
317
  for minutes with barely any resource usage to show for it.
262
318
 
263
319
  If a debug log shows a boot-time command "stuck" with low CPU/mem/IO in `resource_monitor`'s
264
- output, this is worth checking before assuming the app itself is broken. The workaround is to stop
265
- bind-mounting the source live and instead mirror it into a named volume: mount the host path
266
- read-only (`.:/host-src:ro`), add a named volume for `/app`, and have the container's entrypoint
267
- `rsync` from the read-only mount into the volume before boot, then keep syncing in the background
268
- (e.g. every couple of seconds) so edits still show up with a short delay. The app then only ever
269
- touches fast native storage once it's running.
320
+ output, this is worth checking before assuming the app itself is broken. The fix is to stop
321
+ bind-mounting the source live and mirror it into a named volume instead, so the app only ever
322
+ touches fast native storage once it's running. `wip` does that for you add a `sync:` block and
323
+ it rewrites the mounts, mirrors before boot, and re-syncs on demand. See [Source
324
+ sync](#source-sync).
270
325
 
271
326
  ### CPU architecture mismatch
272
327
 
@@ -330,10 +385,12 @@ for `wip`, roughly in priority order:
330
385
  3. **Bind-mount boot time (`rails c`, `bundle`, ...)** — commands like `wip rails c` still start
331
386
  noticeably slower than the equivalent under `docker compose`, mostly from WSL2 bind-mounted
332
387
  (`.:/app`-style) volumes doing many small reads for gems/`node_modules` (use `--debug` to
333
- confirm it's disk I/O and not `wip`'s own overhead). This isn't really `wip`'s responsibility —
334
- the fix has to come from either the mount layer or the project's own volume layout. We're also
335
- hoping for improvements on the `wslc` side itself (faster bind-mount/cache behavior); `wip`
336
- will pick those up for free as soon as they land.
388
+ confirm it's disk I/O and not `wip`'s own overhead). [Source sync](#source-sync) works around
389
+ this today by running the app off a named volume and mirroring the host tree into it, at the
390
+ cost of a one-way sync with a short delay. A tighter loop (host-side file watching instead of
391
+ interval polling, two-way sync) is the natural next step. We're also hoping for improvements on
392
+ the `wslc` side itself (faster bind-mount/cache behavior); `wip` will pick those up for free as
393
+ soon as they land.
337
394
 
338
395
  Each of these should stay additive to the existing `wip.yml` shape — no breaking changes to
339
396
  `defaults`, `commands`, `dependencies`, or `compose` are planned. A resident daemon and a GUI
data/lib/wip/cli.rb CHANGED
@@ -63,6 +63,7 @@ module Wip
63
63
 
64
64
  desc 'up', 'Start the configured container and its dependencies, creating them if necessary'
65
65
  option :detach, type: :boolean, default: false, aliases: '-d'
66
+ option :sync, type: :boolean, default: true, desc: 'Mirror the source into the sync volume first (--no-sync skips)'
66
67
  def up
67
68
  if load_config.compose?
68
69
  return execute(compose_bridge.up(detach: options[:detach]), interactive: tty?(!options[:detach]))
@@ -70,9 +71,29 @@ module Wip
70
71
 
71
72
  ensure_network
72
73
  load_config.dependencies.each_key { |name| ensure_dependency(name) }
74
+ sync_before_boot if options[:sync]
73
75
  ensure_container
74
76
  end
75
77
 
78
+ desc 'sync', 'Mirror the source tree into the sync volume'
79
+ option :watch, type: :boolean, default: false, aliases: '-w', desc: 'Keep re-syncing until interrupted'
80
+ option :interval, type: :numeric, desc: 'Seconds between syncs when watching (default: sync.interval)'
81
+ def sync
82
+ settings = sync_settings!
83
+ warn_shadowed_command('sync')
84
+ return run_sync unless options[:watch]
85
+
86
+ interval = watch_interval(settings)
87
+ warn "wip: syncing #{settings.source} -> #{settings.volume}:#{settings.target} " \
88
+ "every #{interval}s (Ctrl-C to stop)"
89
+ loop do
90
+ run_sync(exit_on_failure: false)
91
+ sleep interval
92
+ end
93
+ rescue Interrupt
94
+ warn "\nwip: sync stopped"
95
+ end
96
+
76
97
  desc 'down', 'Stop and remove the configured container and its dependencies'
77
98
  def down
78
99
  return execute(compose_bridge.down, exit_on_failure: false) if load_config.compose?
@@ -228,6 +249,46 @@ module Wip
228
249
  end
229
250
  end
230
251
 
252
+ def sync_settings!
253
+ load_config.sync || raise(ConfigError, '`wip sync` needs a sync: block in wip.yml')
254
+ end
255
+
256
+ # `sync.interval` is validated when the config loads; --interval isn't, and
257
+ # a negative one would only surface as an ArgumentError from `sleep`.
258
+ def watch_interval(settings)
259
+ interval = options[:interval] || settings.interval
260
+ raise ConfigError, '--interval must be a positive number' unless interval.positive?
261
+
262
+ interval
263
+ end
264
+
265
+ # A built-in command wins over a `commands:` entry of the same name, so
266
+ # point at `wip dispatch` rather than letting the custom one vanish.
267
+ def warn_shadowed_command(name)
268
+ return unless load_config.commands.key?(name)
269
+
270
+ warn "wip: commands.#{name} in wip.yml is shadowed by the built-in `wip #{name}`; " \
271
+ "run it with `wip dispatch #{name}`"
272
+ end
273
+
274
+ # Inside the running container the mirror is a plain `exec`; otherwise it
275
+ # takes a throwaway container that mounts the same source and volume.
276
+ def run_sync(exit_on_failure: true)
277
+ command = container_running? ? builder.sync_exec : builder.sync_run
278
+ execute(command, exit_on_failure: exit_on_failure)
279
+ end
280
+
281
+ def container_running? = resource_exists?(builder.find_running)
282
+
283
+ def sync_before_boot
284
+ settings = load_config.sync
285
+ return unless settings
286
+
287
+ warn "wip: syncing #{settings.source} -> #{settings.volume}:#{settings.target}"
288
+ execute(builder.sync_run)
289
+ warn "wip: run `wip sync --watch` in another terminal to keep #{settings.target} up to date"
290
+ end
291
+
231
292
  def ensure_container
232
293
  container = load_config.defaults['container']
233
294
  interactive = tty?(!options[:detach])
@@ -49,6 +49,26 @@ module Wip
49
49
  [@wslc, 'list', '--all', '--filter', "name=#{container}", '--format', 'json']
50
50
  end
51
51
 
52
+ def find_running
53
+ container = required(@config.defaults, 'container')
54
+ [@wslc, 'list', '--filter', "name=#{container}", '--format', 'json']
55
+ end
56
+
57
+ # Mirrors into the volume from a throwaway container, for when the app
58
+ # container isn't running yet (or at all) — e.g. just before `up` boots it.
59
+ def sync_run
60
+ sync = required_sync
61
+ command = [@wslc, 'run', '--rm']
62
+ sync.volume_specs.each { |spec| command.push('-v', spec) }
63
+ command.push(required(@config.defaults, 'image')).concat(sync.mirror_command)
64
+ end
65
+
66
+ # Mirrors from inside the running container, which already has both the
67
+ # read-only source mount and the volume attached.
68
+ def sync_exec
69
+ [@wslc, 'exec', required(@config.defaults, 'container'), *required_sync.mirror_command]
70
+ end
71
+
52
72
  def down
53
73
  [@wslc, 'stop', required(@config.defaults, 'container')]
54
74
  end
@@ -70,7 +90,7 @@ module Wip
70
90
  command = [@wslc, 'run', '--name', name.to_s]
71
91
  command.push('--network', @config.network) if @config.network
72
92
  command << '-d' if detach
73
- command.concat(options(values)).push(required(values, 'image'))
93
+ command.concat(options(values, sync: false)).push(required(values, 'image'))
74
94
  command.concat(Shellwords.split(values['command'].to_s)) unless values['command'].to_s.empty?
75
95
  command
76
96
  end
@@ -113,18 +133,29 @@ module Wip
113
133
 
114
134
  private
115
135
 
116
- def options(values, include_container: false, include_publish: true)
136
+ def options(values, include_container: false, include_publish: true, sync: true)
117
137
  result = []
118
138
  result.push('-w', values['workdir']) unless values['workdir'].to_s.empty?
119
139
  merged_env(values).each { |key, value| result.push('-e', "#{key}=#{value}") }
120
140
  if include_publish
121
141
  Array(values['ports']).each { |port| result.push('-p', port.to_s) }
122
- Array(values['volumes']).each { |volume| result.push('-v', volume.to_s) }
142
+ volume_specs(values, sync: sync).each { |volume| result.push('-v', volume) }
123
143
  end
124
144
  result << required(values, 'container') if include_container
125
145
  result
126
146
  end
127
147
 
148
+ # With sync configured, a live bind mount of the target (`.:/app`) is
149
+ # swapped for the read-only source mount plus the named volume, so the
150
+ # running app only ever touches the volume.
151
+ def volume_specs(values, sync: true)
152
+ specs = Array(values['volumes']).map(&:to_s)
153
+ settings = sync ? @config.sync : nil
154
+ return specs unless settings
155
+
156
+ specs.reject { |spec| settings.replaces?(spec) } + settings.volume_specs
157
+ end
158
+
128
159
  # .env supplies defaults; env set in wip.yml (defaults or per-command) wins on conflict.
129
160
  def merged_env(values) = @dotenv.merge(values.fetch('env', {}))
130
161
 
@@ -142,6 +173,10 @@ module Wip
142
173
  network
143
174
  end
144
175
 
176
+ def required_sync
177
+ @config.sync || raise(ConfigError, 'No sync: block configured in wip.yml')
178
+ end
179
+
145
180
  def dependency_values(name)
146
181
  @config.dependency(name) || raise(ConfigError, "Unknown dependency: #{name}")
147
182
  end
data/lib/wip/config.rb CHANGED
@@ -26,6 +26,13 @@ module Wip
26
26
  def compose_file = compose && compose['file']
27
27
  def compose_project = compose && compose['project']
28
28
  def compose_command = compose && compose['command']
29
+ def sync? = !!sync
30
+
31
+ def sync
32
+ return @sync if defined?(@sync)
33
+
34
+ @sync = @raw.key?('sync') ? build_sync : nil
35
+ end
29
36
 
30
37
  def command(name)
31
38
  entry = commands[name.to_s]
@@ -44,6 +51,7 @@ module Wip
44
51
  def to_h(redact: true)
45
52
  value = { 'version' => 1, 'wslc' => { 'command' => wslc_command }, 'defaults' => defaults,
46
53
  'up' => { 'command' => up_command }, 'dependencies' => dependencies, 'compose' => compose,
54
+ 'sync' => sync&.to_h,
47
55
  'commands' => commands.transform_values { |entry| defaults.merge('type' => 'exec').merge(entry) } }
48
56
  redact ? redact_secrets(value) : value
49
57
  end
@@ -57,6 +65,17 @@ module Wip
57
65
  validate_commands!
58
66
  validate_dependencies!
59
67
  validate_compose!
68
+ validate_sync!
69
+ end
70
+
71
+ def build_sync
72
+ SyncSettings.new(@raw['sync'], base: path && File.dirname(path.to_s),
73
+ workdir: defaults['workdir'], container: defaults['container'])
74
+ end
75
+
76
+ def validate_sync!
77
+ return unless sync
78
+ raise ConfigError, 'sync is mutually exclusive with compose' if compose?
60
79
  end
61
80
 
62
81
  def validate_commands!
data/lib/wip/doctor.rb CHANGED
@@ -21,9 +21,7 @@ module Wip
21
21
  results << result(@environment.wsl2? ? :ok : :fail, *wsl2_messages)
22
22
  results << interop_result unless @environment.windows?
23
23
  results << Result.new(:ok, "Architecture: #{@environment.architecture}")
24
- config = load_config(results)
25
- check_wslc(config, results) if config
26
- check_compose(config, results) if config&.compose?
24
+ check_config(load_config(results), results)
27
25
  results << result(command_available?('git') ? :ok : :warn, 'Git is available',
28
26
  'Git is not available to the WSLC build environment')
29
27
  results
@@ -53,6 +51,14 @@ module Wip
53
51
  nil
54
52
  end
55
53
 
54
+ def check_config(config, results)
55
+ return unless config
56
+
57
+ check_wslc(config, results)
58
+ check_compose(config, results) if config.compose?
59
+ check_sync(config, results) if config.sync?
60
+ end
61
+
56
62
  def check_wslc(config, results)
57
63
  command = resolve(config, results)
58
64
  check_version(command, results) if command
@@ -98,6 +104,13 @@ module Wip
98
104
  results << Result.new(:fail, e.message)
99
105
  end
100
106
 
107
+ def check_sync(config, results)
108
+ sync = config.sync
109
+ results << result(File.directory?(sync.source) ? :ok : :fail,
110
+ "Sync source #{sync.source} mirrors into volume #{sync.volume} at #{sync.target}",
111
+ "Sync source not found: #{sync.source}")
112
+ end
113
+
101
114
  def result(condition, ok_message, fail_message)
102
115
  Result.new(condition,
103
116
  condition == :ok ? ok_message : fail_message)
@@ -3,6 +3,14 @@
3
3
  module Wip
4
4
  # Translates raw WSLC error output into friendlier hints.
5
5
  class ErrorInterpreter
6
+ # Shells report a missing rsync as "rsync: not found", while the container
7
+ # runtime names the executable either before or after its own phrasing.
8
+ RSYNC_MISSING = Regexp.union(
9
+ /rsync: (?:command )?not found/i,
10
+ /rsync[^\n]*executable file not found/i,
11
+ /executable file not found[^\n]*rsync/i
12
+ )
13
+
6
14
  def initialize(architecture: Environment.new.architecture)
7
15
  @architecture = architecture
8
16
  end
@@ -11,11 +19,24 @@ module Wip
11
19
  case output
12
20
  when /pull access denied|insufficient_scope|authorization failed/i then registry_message
13
21
  when %r{no matching manifest for linux/(?:amd64|arm64)}i then architecture_message
22
+ when RSYNC_MISSING then rsync_message
14
23
  end
15
24
  end
16
25
 
17
26
  private
18
27
 
28
+ def rsync_message
29
+ <<~TEXT
30
+ `wip sync` needs rsync inside the image.
31
+
32
+ Install it in your Dockerfile:
33
+
34
+ RUN apt-get update && apt-get install -y rsync
35
+
36
+ Or point sync.command at a tool the image already has.
37
+ TEXT
38
+ end
39
+
19
40
  def registry_message
20
41
  <<~TEXT
21
42
  The container registry rejected the request.
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'pathname'
4
+
5
+ module Wip
6
+ # Validated access to the `sync:` block, which mirrors the host source tree
7
+ # into a named volume instead of bind-mounting it live.
8
+ #
9
+ # A bind-mounted app directory is shared into the container's VM over
10
+ # virtiofs, so every stat/open a boot-time directory scan makes is a round
11
+ # trip. Mirroring the tree into a named volume (native storage inside the VM)
12
+ # and re-running the mirror on demand keeps the edit-on-the-host workflow
13
+ # while leaving the running app on fast disk.
14
+ class SyncSettings
15
+ DEFAULT_MOUNT = '/host-src'
16
+ DEFAULT_TARGET = '/app'
17
+ DEFAULT_BINARY = 'rsync'
18
+ DEFAULT_INTERVAL = 2
19
+ # -a preserves modes and timestamps so file watchers and bundler see a
20
+ # faithful copy rather than a tree that looks freshly written every sync.
21
+ BASE_OPTIONS = %w[-a].freeze
22
+ # Trailing mount options wslc/docker accept after the container path.
23
+ VOLUME_MODES = %w[ro rw z Z cached delegated consistent].freeze
24
+
25
+ attr_reader :target, :mount, :volume, :exclude, :binary, :extra_options, :interval
26
+
27
+ def initialize(raw, base: nil, workdir: nil, container: nil)
28
+ raise ConfigError, 'sync must be a mapping' unless raw.is_a?(Hash)
29
+
30
+ @base = base
31
+ assign_paths(raw, workdir: workdir, container: container)
32
+ assign_mirror(raw)
33
+ validate!
34
+ end
35
+
36
+ def delete? = !!@delete
37
+
38
+ # Expanded against the wip.yml directory so the mirror covers the same tree
39
+ # no matter which subdirectory wip was invoked from.
40
+ def source
41
+ @source ||= @base ? Pathname(@base).join(@raw_source).expand_path.to_s : @raw_source
42
+ end
43
+
44
+ # What `-v` specs the main container needs: the source read-only, and the
45
+ # named volume where the app actually runs.
46
+ def volume_specs = ["#{source}:#{mount}:ro", "#{volume}:#{target}"]
47
+
48
+ # True for a configured volume that sync replaces, so `.:/app` in
49
+ # `defaults.volumes` quietly becomes the read-only mount plus the volume.
50
+ def replaces?(spec)
51
+ [target.chomp('/'), mount.chomp('/')].include?(container_path(spec))
52
+ end
53
+
54
+ # Trailing slashes matter to rsync: they copy the *contents* of the mount
55
+ # into the target rather than nesting it one directory deeper.
56
+ def mirror_command
57
+ command = [binary, *BASE_OPTIONS]
58
+ command << '--delete' if delete?
59
+ command.concat(exclude.map { |pattern| "--exclude=#{pattern}" })
60
+ command.concat(extra_options)
61
+ command.push("#{mount.chomp('/')}/", "#{target.chomp('/')}/")
62
+ end
63
+
64
+ def to_h
65
+ { 'source' => source, 'target' => target, 'mount' => mount, 'volume' => volume,
66
+ 'delete' => delete?, 'exclude' => exclude, 'command' => binary, 'options' => extra_options,
67
+ 'interval' => interval }
68
+ end
69
+
70
+ private
71
+
72
+ def assign_paths(raw, workdir:, container:)
73
+ @raw_source = presence(raw['source']) || '.'
74
+ @target = presence(raw['target']) || presence(workdir) || DEFAULT_TARGET
75
+ @mount = presence(raw['mount']) || DEFAULT_MOUNT
76
+ @volume = presence(raw['volume']) || "#{presence(container) || 'wip'}-src"
77
+ end
78
+
79
+ def assign_mirror(raw)
80
+ @delete = raw.fetch('delete', true)
81
+ @exclude = Array(raw['exclude']).map(&:to_s)
82
+ @binary = presence(raw['command']) || DEFAULT_BINARY
83
+ @extra_options = Array(raw['options']).map(&:to_s)
84
+ @interval = raw.key?('interval') ? raw['interval'] : DEFAULT_INTERVAL
85
+ end
86
+
87
+ def validate!
88
+ raise ConfigError, 'sync.target must be an absolute path' unless @target.start_with?('/')
89
+ raise ConfigError, 'sync.mount must be an absolute path' unless @mount.start_with?('/')
90
+ raise ConfigError, 'sync.mount must differ from sync.target' if @mount.chomp('/') == @target.chomp('/')
91
+ return if @interval.is_a?(Numeric) && @interval.positive?
92
+
93
+ raise ConfigError, 'sync.interval must be a positive number'
94
+ end
95
+
96
+ def container_path(spec)
97
+ parts = spec.to_s.split(':')
98
+ parts.pop if parts.size > 2 && VOLUME_MODES.include?(parts.last)
99
+ parts.last.to_s.chomp('/')
100
+ end
101
+
102
+ def presence(value) = value.to_s.empty? ? nil : value.to_s
103
+ end
104
+ 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.6.0'
4
+ VERSION = '0.7.0'
5
5
  end
data/lib/wip.rb CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative 'wip/version'
4
4
  require_relative 'wip/errors'
5
+ require_relative 'wip/sync_settings'
5
6
  require_relative 'wip/config'
6
7
  require_relative 'wip/config_loader'
7
8
  require_relative 'wip/environment'
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.6.0
4
+ version: 0.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Wip contributors
@@ -50,6 +50,7 @@ files:
50
50
  - lib/wip/error_interpreter.rb
51
51
  - lib/wip/errors.rb
52
52
  - lib/wip/resource_monitor.rb
53
+ - lib/wip/sync_settings.rb
53
54
  - lib/wip/version.rb
54
55
  homepage: https://wslc-wip.slidict.com/
55
56
  licenses: