wslc-wip 0.5.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 +154 -6
- data/lib/wip/cli.rb +117 -14
- data/lib/wip/command_builder.rb +38 -3
- data/lib/wip/command_resolver.rb +17 -10
- data/lib/wip/compose_bridge.rb +70 -0
- data/lib/wip/config.rb +36 -1
- data/lib/wip/doctor.rb +52 -8
- 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 +2 -0
- metadata +3 -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,97 @@ 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
|
+
|
|
187
|
+
### Compose mode
|
|
188
|
+
|
|
189
|
+
If your project already has a real `compose.yml`, don't duplicate it in `dependencies:` — point
|
|
190
|
+
`wip` at it instead:
|
|
191
|
+
|
|
192
|
+
```yaml
|
|
193
|
+
version: 1
|
|
194
|
+
compose:
|
|
195
|
+
service: app # required: which compose service wip run/exec/NAME target
|
|
196
|
+
command: wslc-compose # required: the compose-for-wslc binary/path you have installed
|
|
197
|
+
file: compose.yml # optional; auto-detected next to wip.yml otherwise
|
|
198
|
+
project: myapp # optional; omitted lets the compose tool pick its own default
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
`compose:` is mutually exclusive with `dependencies:`/`defaults.network` — pick one orchestration
|
|
202
|
+
path per project. In compose mode, `wip` becomes a thin bridge to an external compose-for-`wslc`
|
|
203
|
+
CLI rather than reimplementing Compose itself.
|
|
204
|
+
|
|
205
|
+
`wslc` itself has no native Compose support yet (tracked upstream in
|
|
206
|
+
[microsoft/WSL#40948](https://github.com/microsoft/WSL/issues/40948)), and until it does,
|
|
207
|
+
independent third-party tools fill the gap — for example
|
|
208
|
+
[bacarndiaye/wslc-compose](https://github.com/bacarndiaye/wslc-compose) (Python) and
|
|
209
|
+
[inuyume/wslc-compose](https://github.com/inuyume/wslc-compose) (Go), among others. `wslc` is new
|
|
210
|
+
and still evolving, so expect more of these to show up (and existing ones to change) over time.
|
|
211
|
+
`wip` deliberately doesn't pick a winner or default to any of them (unlike `wslc.command`, which
|
|
212
|
+
defaults to `auto` and searches for `wslc.exe`/`wslc`): `compose.command` is required and treats
|
|
213
|
+
every implementation equally — set it to whichever binary name or absolute path you've installed.
|
|
214
|
+
Whichever one you use needs to understand `-f FILE [-p PROJECT] up|down|exec|logs`, the subset of
|
|
215
|
+
the Compose CLI vocabulary `wip` drives. `wip doctor` reports whether the configured command is
|
|
216
|
+
found, its version, and which compose file `wip` resolved.
|
|
217
|
+
|
|
218
|
+
- `wip up`/`wip down` delegate straight to `<compose command> up -d`/`down`.
|
|
219
|
+
- `wip exec`/`wip NAME` (custom `commands:`) run inside `compose.service`.
|
|
220
|
+
- `wip shell` also goes through the bridge: unless `commands.shell` is defined in `wip.yml`, it
|
|
221
|
+
`exec`s `bash` against `compose.service`, falling back to `sh`.
|
|
222
|
+
- `wip logs [-f] [SERVICE...]` is only available in compose mode.
|
|
223
|
+
- `wip run` has no ephemeral-container equivalent in this exec-only vocabulary, so it falls back
|
|
224
|
+
to `exec` against the already-running `compose.service` (wip warns when this happens).
|
|
225
|
+
- `commands:` entries with `type: run`/`type: build` aren't supported in compose mode — use your
|
|
226
|
+
compose tool's own `build`/`up --build` directly; compose owns builds for its own services.
|
|
227
|
+
|
|
132
228
|
## Commands
|
|
133
229
|
|
|
134
230
|
| Command | Description |
|
|
@@ -137,11 +233,13 @@ dependencies down (the network itself is left in place). Each dependency entry a
|
|
|
137
233
|
| `wip doctor` | Diagnose WSL2, interop, WSLC, config, architecture, and Git |
|
|
138
234
|
| `wip config` | Print the effective configuration (secrets masked) |
|
|
139
235
|
| `wip build -- --no-cache` | Build the image from the `build` definition |
|
|
140
|
-
| `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` |
|
|
141
237
|
| `wip down` | Stop and remove `defaults.container` and its `dependencies` |
|
|
142
238
|
| `wip exec [--no-interactive] COMMAND...` | Run a command in the existing container |
|
|
143
|
-
| `wip run [--no-interactive] COMMAND...` | Run a command in a new `--rm` container |
|
|
239
|
+
| `wip run [--no-interactive] COMMAND...` | Run a command in a new `--rm` container (compose mode: `exec`s into `compose.service` instead) |
|
|
144
240
|
| `wip shell` | Open the configured shell, falling back to `bash` then `sh` |
|
|
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:`) |
|
|
145
243
|
| `wip NAME ARGS...` | Run `commands.NAME`, appending any extra arguments |
|
|
146
244
|
|
|
147
245
|
TTY allocation is decided by combining the command's config, the CLI option, and whether both
|
|
@@ -209,6 +307,22 @@ When `pull access denied` (or similar) is detected, wip suggests how to log in:
|
|
|
209
307
|
wslc registry login -u <username> docker.io
|
|
210
308
|
```
|
|
211
309
|
|
|
310
|
+
### Slow boot when the app directory is bind-mounted
|
|
311
|
+
|
|
312
|
+
wslc containers run in their own VM, so a bind-mounted host directory (`.:/app`) is always shared
|
|
313
|
+
in over virtiofs, even when the host path is already on WSL's native filesystem. Frameworks that
|
|
314
|
+
scan large directory trees at startup (Ruby's Zeitwerk autoloader, for example) issue many small
|
|
315
|
+
per-file stat/open calls, and each one is a round trip through that layer — CPU on the Windows
|
|
316
|
+
side can look busy while almost no data is actually transferred, and the process can appear hung
|
|
317
|
+
for minutes with barely any resource usage to show for it.
|
|
318
|
+
|
|
319
|
+
If a debug log shows a boot-time command "stuck" with low CPU/mem/IO in `resource_monitor`'s
|
|
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).
|
|
325
|
+
|
|
212
326
|
### CPU architecture mismatch
|
|
213
327
|
|
|
214
328
|
Check the image and publish a multi-arch (amd64/arm64) image:
|
|
@@ -243,10 +357,44 @@ checklist.
|
|
|
243
357
|
|
|
244
358
|
## Not in the initial release
|
|
245
359
|
|
|
246
|
-
Full Compose compatibility
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
360
|
+
Full Compose compatibility isn't reimplemented in `wip` itself, but is available by delegating to
|
|
361
|
+
a third-party compose-for-`wslc` tool — see [Compose mode](#compose-mode). A resident/daemon
|
|
362
|
+
process, a GUI, PowerShell-specific tuning, direct registry API/manifest parsing, self-update, and
|
|
363
|
+
plugins are all unimplemented.
|
|
364
|
+
|
|
365
|
+
## Roadmap
|
|
366
|
+
|
|
367
|
+
`wip` already covers most of what [`dip`](https://github.com/bibendi/dip) adds on top of Compose —
|
|
368
|
+
named commands (`commands:`), `run`/`exec` hidden behind a single verb, `.env` passthrough, and
|
|
369
|
+
sidecar services via `dependencies:` + `defaults.network`. Fuller Compose semantics
|
|
370
|
+
(`depends_on` ordering/health checks, log aggregation, named volumes, profiles, scaling) are now
|
|
371
|
+
handled by delegating to a separate compose-for-`wslc` tool in [compose mode](#compose-mode)
|
|
372
|
+
rather than being reimplemented in `wip` itself. Which of those actually work is entirely up to
|
|
373
|
+
the external tool you point `compose.command` at — `wip` only forwards
|
|
374
|
+
`-f FILE [-p PROJECT] up|down|exec|logs`, so treat that list as what Compose offers, not as
|
|
375
|
+
something `wip` guarantees. See that section for what compose mode covers
|
|
376
|
+
and its current limitations (`run`, and `commands:` of type `run`/`build`). What's still planned
|
|
377
|
+
for `wip`, roughly in priority order:
|
|
378
|
+
|
|
379
|
+
1. **`wip provision`** — a dip-style one-shot bootstrap hook (build → up deps → install deps →
|
|
380
|
+
create/migrate/seed DB) so a new contributor can go from `git clone` to a working environment
|
|
381
|
+
in two commands (`wip provision && wip up`).
|
|
382
|
+
2. **Config file merging** — `--config` currently accepts one file; support layering
|
|
383
|
+
(`wip.yml` + `wip.override.yml`, or a `WIP_CONFIG` list) for dev/CI/debug variants without
|
|
384
|
+
duplicating the whole file, plus a `wip config --resolved` view of the merged result.
|
|
385
|
+
3. **Bind-mount boot time (`rails c`, `bundle`, ...)** — commands like `wip rails c` still start
|
|
386
|
+
noticeably slower than the equivalent under `docker compose`, mostly from WSL2 bind-mounted
|
|
387
|
+
(`.:/app`-style) volumes doing many small reads for gems/`node_modules` (use `--debug` to
|
|
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.
|
|
394
|
+
|
|
395
|
+
Each of these should stay additive to the existing `wip.yml` shape — no breaking changes to
|
|
396
|
+
`defaults`, `commands`, `dependencies`, or `compose` are planned. A resident daemon and a GUI
|
|
397
|
+
remain out of scope; see "Not in the initial release" above.
|
|
250
398
|
|
|
251
399
|
## License
|
|
252
400
|
|
data/lib/wip/cli.rb
CHANGED
|
@@ -4,6 +4,7 @@ require 'thor'
|
|
|
4
4
|
require 'yaml'
|
|
5
5
|
require 'json'
|
|
6
6
|
require 'stringio'
|
|
7
|
+
require 'shellwords'
|
|
7
8
|
|
|
8
9
|
module Wip
|
|
9
10
|
# Thor-based command-line interface for wip.
|
|
@@ -62,14 +63,41 @@ module Wip
|
|
|
62
63
|
|
|
63
64
|
desc 'up', 'Start the configured container and its dependencies, creating them if necessary'
|
|
64
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)'
|
|
65
67
|
def up
|
|
68
|
+
if load_config.compose?
|
|
69
|
+
return execute(compose_bridge.up(detach: options[:detach]), interactive: tty?(!options[:detach]))
|
|
70
|
+
end
|
|
71
|
+
|
|
66
72
|
ensure_network
|
|
67
73
|
load_config.dependencies.each_key { |name| ensure_dependency(name) }
|
|
74
|
+
sync_before_boot if options[:sync]
|
|
68
75
|
ensure_container
|
|
69
76
|
end
|
|
70
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
|
+
|
|
71
97
|
desc 'down', 'Stop and remove the configured container and its dependencies'
|
|
72
98
|
def down
|
|
99
|
+
return execute(compose_bridge.down, exit_on_failure: false) if load_config.compose?
|
|
100
|
+
|
|
73
101
|
execute(builder.down, exit_on_failure: false)
|
|
74
102
|
execute(builder.remove, exit_on_failure: false)
|
|
75
103
|
load_config.dependencies.each_key do |name|
|
|
@@ -81,41 +109,51 @@ module Wip
|
|
|
81
109
|
desc 'exec COMMAND...', 'Execute a command in the running container'
|
|
82
110
|
option :interactive, type: :boolean, default: true
|
|
83
111
|
def exec(*command)
|
|
84
|
-
|
|
85
|
-
execute(builder.exec(command, interactive: options[:interactive]), interactive: tty)
|
|
112
|
+
execute(exec_target(command, interactive: options[:interactive]), interactive: tty?(options[:interactive]))
|
|
86
113
|
end
|
|
87
114
|
|
|
88
115
|
desc 'run COMMAND...', 'Run a command in a new container'
|
|
89
116
|
option :interactive, type: :boolean, default: true
|
|
90
117
|
def run_command(*command)
|
|
91
|
-
|
|
92
|
-
|
|
118
|
+
if load_config.compose?
|
|
119
|
+
warn "wip: compose mode has no ephemeral 'run'; executing in the running " \
|
|
120
|
+
"'#{load_config.compose_service}' service instead"
|
|
121
|
+
return execute(exec_target(command, interactive: options[:interactive]),
|
|
122
|
+
interactive: tty?(options[:interactive]))
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
execute(builder.run(command, interactive: options[:interactive]), interactive: tty?(options[:interactive]))
|
|
93
126
|
end
|
|
94
127
|
map 'run' => :run_command
|
|
95
128
|
|
|
96
129
|
desc 'shell', 'Open a shell in the configured container'
|
|
97
130
|
def shell_command
|
|
98
131
|
configured = load_config.command('shell')
|
|
99
|
-
if configured
|
|
100
|
-
tty = builder.tty?(configured.fetch('interactive', false))
|
|
101
|
-
return execute(builder.custom('shell', []), interactive: tty)
|
|
102
|
-
end
|
|
132
|
+
return dispatch('shell') if configured
|
|
103
133
|
|
|
104
|
-
|
|
105
|
-
code = execute(builder.exec(['bash'], settings: { 'interactive' => true }, interactive: true),
|
|
106
|
-
interactive: tty, exit_on_failure: false)
|
|
134
|
+
code = execute(exec_target(['bash'], interactive: true), interactive: tty?(true), exit_on_failure: false)
|
|
107
135
|
return if code.zero?
|
|
108
136
|
|
|
109
|
-
execute(
|
|
137
|
+
execute(exec_target(['sh'], interactive: true), interactive: tty?(true))
|
|
110
138
|
end
|
|
111
139
|
map 'shell' => :shell_command
|
|
112
140
|
|
|
141
|
+
desc 'logs [SERVICE...]', 'Follow logs from compose services (compose mode only)'
|
|
142
|
+
option :follow, type: :boolean, default: true, aliases: '-f'
|
|
143
|
+
def logs(*services)
|
|
144
|
+
raise ConfigError, '`wip logs` is only available in compose mode' unless load_config.compose?
|
|
145
|
+
|
|
146
|
+
execute(compose_bridge.logs(services: services, follow: options[:follow]), interactive: true)
|
|
147
|
+
end
|
|
148
|
+
|
|
113
149
|
desc 'dispatch COMMAND [ARGS...]', 'Run a command defined in wip.yml'
|
|
114
150
|
def dispatch(name = nil, *arguments)
|
|
115
151
|
raise ConfigError, 'A command is required' unless name
|
|
116
152
|
|
|
117
153
|
values = load_config.command(name) || raise(ConfigError, "Unknown command: #{name}")
|
|
118
|
-
|
|
154
|
+
return dispatch_compose(name, values, arguments) if load_config.compose?
|
|
155
|
+
|
|
156
|
+
execute(builder.custom(name, arguments), interactive: tty?(values.fetch('interactive', false)))
|
|
119
157
|
end
|
|
120
158
|
|
|
121
159
|
private
|
|
@@ -134,6 +172,31 @@ module Wip
|
|
|
134
172
|
CommandBuilder.new(wslc: resolver.resolve(load_config.wslc_command), config: load_config, dotenv: dotenv)
|
|
135
173
|
end
|
|
136
174
|
|
|
175
|
+
def compose_bridge = @compose_bridge ||= ComposeBridge.for(load_config)
|
|
176
|
+
|
|
177
|
+
def tty?(requested) = requested && Environment.new.interactive?
|
|
178
|
+
|
|
179
|
+
def exec_target(arguments, interactive:)
|
|
180
|
+
if load_config.compose?
|
|
181
|
+
return compose_bridge.exec(load_config.compose_service, arguments,
|
|
182
|
+
interactive: interactive)
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
builder.exec(arguments, interactive: interactive)
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def dispatch_compose(name, values, arguments)
|
|
189
|
+
type = values['type'] || 'exec'
|
|
190
|
+
unless type == 'exec'
|
|
191
|
+
raise ConfigError, "commands.#{name}: type '#{type}' is not supported in compose mode " \
|
|
192
|
+
'(use `wslc-compose build`/`up --build` directly)'
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
command = Shellwords.split(values['command'].to_s) + arguments
|
|
196
|
+
interactive = values.fetch('interactive', false)
|
|
197
|
+
execute(exec_target(command, interactive: interactive), interactive: tty?(interactive))
|
|
198
|
+
end
|
|
199
|
+
|
|
137
200
|
def execute(command, interactive: false, exit_on_failure: true)
|
|
138
201
|
runner = CommandRunner.new(debug: debug?)
|
|
139
202
|
code = reporter.step("running: #{CommandDisplay.for_debug(command)}", live: !interactive) do
|
|
@@ -186,9 +249,49 @@ module Wip
|
|
|
186
249
|
end
|
|
187
250
|
end
|
|
188
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
|
+
|
|
189
292
|
def ensure_container
|
|
190
293
|
container = load_config.defaults['container']
|
|
191
|
-
interactive =
|
|
294
|
+
interactive = tty?(!options[:detach])
|
|
192
295
|
if resource_exists?(builder.find)
|
|
193
296
|
warn "wip: starting existing container '#{container}'"
|
|
194
297
|
execute(builder.start(detach: options[:detach]), interactive: interactive)
|
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/command_resolver.rb
CHANGED
|
@@ -1,20 +1,29 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Wip
|
|
4
|
-
# Locates
|
|
4
|
+
# Locates an executable on the current system, trying a list of candidates.
|
|
5
5
|
class CommandResolver
|
|
6
6
|
CANDIDATES = ['wslc.exe', 'wslc', '/mnt/c/Windows/System32/wslc.exe'].freeze
|
|
7
|
+
DEFAULT_INSTALL_HINT = <<~HINT.chomp
|
|
8
|
+
Install or update the WSL container tooling, then run:
|
|
7
9
|
|
|
8
|
-
|
|
10
|
+
wip doctor
|
|
11
|
+
HINT
|
|
12
|
+
|
|
13
|
+
def initialize(path: ENV.fetch('PATH', ''), executable: nil, candidates: CANDIDATES, label: 'WSLC',
|
|
14
|
+
install_hint: DEFAULT_INSTALL_HINT)
|
|
9
15
|
@path = path
|
|
10
16
|
@executable = executable || method(:executable?)
|
|
17
|
+
@candidates = candidates
|
|
18
|
+
@label = label
|
|
19
|
+
@install_hint = install_hint
|
|
11
20
|
end
|
|
12
21
|
|
|
13
22
|
def resolve(configured = 'auto')
|
|
14
23
|
return configured if configured != 'auto' && @executable.call(configured)
|
|
15
|
-
return raise_not_found if configured != 'auto'
|
|
24
|
+
return raise_not_found([configured]) if configured != 'auto'
|
|
16
25
|
|
|
17
|
-
|
|
26
|
+
@candidates.find { |candidate| @executable.call(candidate) } || raise_not_found(@candidates)
|
|
18
27
|
end
|
|
19
28
|
|
|
20
29
|
private
|
|
@@ -25,16 +34,14 @@ module Wip
|
|
|
25
34
|
@path.split(File::PATH_SEPARATOR).any? { |directory| File.executable?(File.join(directory, command)) }
|
|
26
35
|
end
|
|
27
36
|
|
|
28
|
-
def raise_not_found
|
|
37
|
+
def raise_not_found(attempted)
|
|
29
38
|
raise CommandNotFoundError, <<~MESSAGE
|
|
30
|
-
|
|
39
|
+
#{@label} was not found.
|
|
31
40
|
|
|
32
41
|
Checked:
|
|
33
|
-
#{
|
|
34
|
-
|
|
35
|
-
Install or update the WSL container tooling, then run:
|
|
42
|
+
#{attempted.join("\n ")}
|
|
36
43
|
|
|
37
|
-
|
|
44
|
+
#{@install_hint}
|
|
38
45
|
MESSAGE
|
|
39
46
|
end
|
|
40
47
|
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Wip
|
|
4
|
+
# Builds argument arrays for wslc-compose invocations, delegating orchestration
|
|
5
|
+
# to a real compose.yml instead of wip's own dependencies:/network handling.
|
|
6
|
+
class ComposeBridge
|
|
7
|
+
FILENAMES = %w[compose.yml compose.yaml docker-compose.yml docker-compose.yaml].freeze
|
|
8
|
+
# No default candidates: wip doesn't favor any one compose-for-wslc implementation.
|
|
9
|
+
# compose.command must name the one you've installed.
|
|
10
|
+
INSTALL_HINT = <<~HINT.chomp
|
|
11
|
+
wip doesn't bundle or pin a compose-for-wslc implementation — install one and set
|
|
12
|
+
compose.command in wip.yml to its binary name or path, e.g.:
|
|
13
|
+
|
|
14
|
+
https://github.com/bacarndiaye/wslc-compose
|
|
15
|
+
https://github.com/inuyume/wslc-compose
|
|
16
|
+
HINT
|
|
17
|
+
|
|
18
|
+
def self.for(config, resolver: CommandResolver.new(candidates: [], label: 'compose command',
|
|
19
|
+
install_hint: INSTALL_HINT))
|
|
20
|
+
new(compose_command: resolver.resolve(config.compose_command), file: file_path(config),
|
|
21
|
+
project: config.compose_project)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def self.file_path(config)
|
|
25
|
+
base = Pathname(config.path).dirname
|
|
26
|
+
configured = config.compose_file
|
|
27
|
+
# Relative compose.file is resolved against wip.yml, not the current directory,
|
|
28
|
+
# so `wip` behaves the same from any subdirectory (matching auto-detection below).
|
|
29
|
+
return base.join(configured).expand_path if configured
|
|
30
|
+
|
|
31
|
+
FILENAMES.map { |name| base.join(name) }.find(&:file?) ||
|
|
32
|
+
raise(ConfigError, "compose mode: no compose file found next to #{config.path} " \
|
|
33
|
+
"(looked for #{FILENAMES.join(', ')})")
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def initialize(compose_command:, file:, project: nil)
|
|
37
|
+
@compose_command = compose_command
|
|
38
|
+
@file = file
|
|
39
|
+
@project = project
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def up(detach: true)
|
|
43
|
+
command = base.push('up')
|
|
44
|
+
command << '-d' if detach
|
|
45
|
+
command
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def down = base.push('down')
|
|
49
|
+
|
|
50
|
+
def exec(service, arguments, interactive: true)
|
|
51
|
+
command = base.push('exec')
|
|
52
|
+
command << '-T' unless interactive
|
|
53
|
+
command.push(service.to_s).concat(arguments)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def logs(services: [], follow: true)
|
|
57
|
+
command = base.push('logs')
|
|
58
|
+
command << '-f' if follow
|
|
59
|
+
command.concat(services.map(&:to_s))
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
def base
|
|
65
|
+
command = [@compose_command, '-f', @file.to_s]
|
|
66
|
+
command.push('-p', @project) if @project
|
|
67
|
+
command
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
data/lib/wip/config.rb
CHANGED
|
@@ -20,6 +20,19 @@ module Wip
|
|
|
20
20
|
def up_command = @raw.dig('up', 'command')
|
|
21
21
|
def dependencies = @raw['dependencies'] || {}
|
|
22
22
|
def network = defaults['network']
|
|
23
|
+
def compose = @raw['compose']
|
|
24
|
+
def compose? = !!compose
|
|
25
|
+
def compose_service = compose && compose['service']
|
|
26
|
+
def compose_file = compose && compose['file']
|
|
27
|
+
def compose_project = compose && compose['project']
|
|
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
|
|
23
36
|
|
|
24
37
|
def command(name)
|
|
25
38
|
entry = commands[name.to_s]
|
|
@@ -37,7 +50,8 @@ module Wip
|
|
|
37
50
|
|
|
38
51
|
def to_h(redact: true)
|
|
39
52
|
value = { 'version' => 1, 'wslc' => { 'command' => wslc_command }, 'defaults' => defaults,
|
|
40
|
-
'up' => { 'command' => up_command }, 'dependencies' => dependencies,
|
|
53
|
+
'up' => { 'command' => up_command }, 'dependencies' => dependencies, 'compose' => compose,
|
|
54
|
+
'sync' => sync&.to_h,
|
|
41
55
|
'commands' => commands.transform_values { |entry| defaults.merge('type' => 'exec').merge(entry) } }
|
|
42
56
|
redact ? redact_secrets(value) : value
|
|
43
57
|
end
|
|
@@ -50,6 +64,18 @@ module Wip
|
|
|
50
64
|
|
|
51
65
|
validate_commands!
|
|
52
66
|
validate_dependencies!
|
|
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?
|
|
53
79
|
end
|
|
54
80
|
|
|
55
81
|
def validate_commands!
|
|
@@ -64,6 +90,15 @@ module Wip
|
|
|
64
90
|
dependencies.each { |name, entry| validate_dependency!(name, entry) }
|
|
65
91
|
end
|
|
66
92
|
|
|
93
|
+
def validate_compose!
|
|
94
|
+
return unless @raw.key?('compose')
|
|
95
|
+
raise ConfigError, 'compose must be a mapping' unless compose.is_a?(Hash)
|
|
96
|
+
raise ConfigError, 'compose.service must not be empty' if compose_service.to_s.empty?
|
|
97
|
+
raise ConfigError, 'compose.command must not be empty' if compose_command.to_s.empty?
|
|
98
|
+
raise ConfigError, 'compose is mutually exclusive with dependencies' if dependencies.any?
|
|
99
|
+
raise ConfigError, 'compose is mutually exclusive with defaults.network' if network
|
|
100
|
+
end
|
|
101
|
+
|
|
67
102
|
def validate_command!(name, entry)
|
|
68
103
|
raise ConfigError, "commands.#{name} must be a mapping" unless entry.is_a?(Hash)
|
|
69
104
|
|
data/lib/wip/doctor.rb
CHANGED
|
@@ -7,10 +7,13 @@ module Wip
|
|
|
7
7
|
class Doctor
|
|
8
8
|
Result = Data.define(:level, :message)
|
|
9
9
|
|
|
10
|
-
def initialize(loader:, resolver: CommandResolver.new, environment: Environment.new
|
|
10
|
+
def initialize(loader:, resolver: CommandResolver.new, environment: Environment.new,
|
|
11
|
+
compose_resolver: CommandResolver.new(candidates: [], label: 'compose command',
|
|
12
|
+
install_hint: ComposeBridge::INSTALL_HINT))
|
|
11
13
|
@loader = loader
|
|
12
14
|
@resolver = resolver
|
|
13
15
|
@environment = environment
|
|
16
|
+
@compose_resolver = compose_resolver
|
|
14
17
|
end
|
|
15
18
|
|
|
16
19
|
def call
|
|
@@ -18,9 +21,7 @@ module Wip
|
|
|
18
21
|
results << result(@environment.wsl2? ? :ok : :fail, *wsl2_messages)
|
|
19
22
|
results << interop_result unless @environment.windows?
|
|
20
23
|
results << Result.new(:ok, "Architecture: #{@environment.architecture}")
|
|
21
|
-
|
|
22
|
-
command = resolve(config, results) if config
|
|
23
|
-
check_version(command, results) if command
|
|
24
|
+
check_config(load_config(results), results)
|
|
24
25
|
results << result(command_available?('git') ? :ok : :warn, 'Git is available',
|
|
25
26
|
'Git is not available to the WSLC build environment')
|
|
26
27
|
results
|
|
@@ -41,7 +42,7 @@ module Wip
|
|
|
41
42
|
|
|
42
43
|
def load_config(results)
|
|
43
44
|
config = @loader.load
|
|
44
|
-
invalid = %w[container image].select { |key| config.defaults[key].to_s.empty? }
|
|
45
|
+
invalid = config.compose? ? [] : %w[container image].select { |key| config.defaults[key].to_s.empty? }
|
|
45
46
|
results << Result.new(invalid.empty? ? :ok : :fail,
|
|
46
47
|
invalid.empty? ? 'Loaded wip.yml' : "Empty defaults: #{invalid.join(', ')}")
|
|
47
48
|
config
|
|
@@ -50,6 +51,19 @@ module Wip
|
|
|
50
51
|
nil
|
|
51
52
|
end
|
|
52
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
|
+
|
|
62
|
+
def check_wslc(config, results)
|
|
63
|
+
command = resolve(config, results)
|
|
64
|
+
check_version(command, results) if command
|
|
65
|
+
end
|
|
66
|
+
|
|
53
67
|
def resolve(config, results)
|
|
54
68
|
command = @resolver.resolve(config.wslc_command)
|
|
55
69
|
results << Result.new(:ok, "Found #{command}")
|
|
@@ -59,12 +73,42 @@ module Wip
|
|
|
59
73
|
nil
|
|
60
74
|
end
|
|
61
75
|
|
|
62
|
-
def check_version(command, results)
|
|
76
|
+
def check_version(command, results, label: 'WSLC')
|
|
63
77
|
_output, status = Open3.capture2e(command, 'version')
|
|
64
78
|
results << Result.new(status.success? ? :ok : :fail,
|
|
65
|
-
status.success? ?
|
|
79
|
+
status.success? ? "#{label} is available" : "#{label} version failed")
|
|
66
80
|
rescue Errno::ENOENT
|
|
67
|
-
results << Result.new(:fail,
|
|
81
|
+
results << Result.new(:fail, "#{label} version failed")
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def check_compose(config, results)
|
|
85
|
+
command = resolve_compose(config, results)
|
|
86
|
+
check_version(command, results, label: 'compose command') if command
|
|
87
|
+
check_compose_file(config, results)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def resolve_compose(config, results)
|
|
91
|
+
command = @compose_resolver.resolve(config.compose_command)
|
|
92
|
+
results << Result.new(:ok, "Found #{command}")
|
|
93
|
+
command
|
|
94
|
+
rescue CommandNotFoundError => e
|
|
95
|
+
results << Result.new(:fail, e.message)
|
|
96
|
+
nil
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def check_compose_file(config, results)
|
|
100
|
+
path = ComposeBridge.file_path(config)
|
|
101
|
+
results << result(path.file? ? :ok : :fail, "Found compose file #{path}",
|
|
102
|
+
"Compose file not found: #{path}")
|
|
103
|
+
rescue ConfigError => e
|
|
104
|
+
results << Result.new(:fail, e.message)
|
|
105
|
+
end
|
|
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}")
|
|
68
112
|
end
|
|
69
113
|
|
|
70
114
|
def result(condition, 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
|
@@ -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'
|
|
@@ -11,6 +12,7 @@ require_relative 'wip/build_context'
|
|
|
11
12
|
require_relative 'wip/command_resolver'
|
|
12
13
|
require_relative 'wip/error_interpreter'
|
|
13
14
|
require_relative 'wip/command_builder'
|
|
15
|
+
require_relative 'wip/compose_bridge'
|
|
14
16
|
require_relative 'wip/command_display'
|
|
15
17
|
require_relative 'wip/command_runner'
|
|
16
18
|
require_relative 'wip/resource_monitor'
|
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
|
|
@@ -39,6 +39,7 @@ files:
|
|
|
39
39
|
- lib/wip/command_display.rb
|
|
40
40
|
- lib/wip/command_resolver.rb
|
|
41
41
|
- lib/wip/command_runner.rb
|
|
42
|
+
- lib/wip/compose_bridge.rb
|
|
42
43
|
- lib/wip/config.rb
|
|
43
44
|
- lib/wip/config_loader.rb
|
|
44
45
|
- lib/wip/debug_reporter.rb
|
|
@@ -49,6 +50,7 @@ files:
|
|
|
49
50
|
- lib/wip/error_interpreter.rb
|
|
50
51
|
- lib/wip/errors.rb
|
|
51
52
|
- lib/wip/resource_monitor.rb
|
|
53
|
+
- lib/wip/sync_settings.rb
|
|
52
54
|
- lib/wip/version.rb
|
|
53
55
|
homepage: https://wslc-wip.slidict.com/
|
|
54
56
|
licenses:
|