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 +4 -4
- data/README.md +68 -11
- data/lib/wip/cli.rb +61 -0
- data/lib/wip/command_builder.rb +38 -3
- data/lib/wip/config.rb +19 -0
- data/lib/wip/doctor.rb +16 -3
- data/lib/wip/error_interpreter.rb +21 -0
- data/lib/wip/sync_settings.rb +104 -0
- data/lib/wip/version.rb +1 -1
- data/lib/wip.rb +1 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b0fdc63759c329dc9cbc9a60a0ef184db31087d454070871bc3f39a1982d7426
|
|
4
|
+
data.tar.gz: 447d4f3206b3662def93d376b950de99564f3efbfc85e8607bbaad6fb432545d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
265
|
-
bind-mounting the source live and
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
(
|
|
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).
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
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])
|
data/lib/wip/command_builder.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
data/lib/wip.rb
CHANGED
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
|
+
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:
|