wslc-wip 0.11.0 → 0.13.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: a32d5a9003a7197fd47fa4b81b7ca764b495ea7955ccc523cda29330f0e4e5b2
4
- data.tar.gz: 9d47155b5a53497dc9e5ad16cbd48f402ee0ea9d3865cfd932f577d6a1f4a746
3
+ metadata.gz: 00dbc091bf03ff619a8d2096e87a7c9fea05194829a5378448b0547d1b3133a9
4
+ data.tar.gz: 4a44f1a28d3dc37a4f58f4b171e5034c714c83fe123b4870ac30862fbe0a10d4
5
5
  SHA512:
6
- metadata.gz: 167b4aa726f99b28e2ce78087c07e10c122382a82e61db43820f43f7a28ca3bb8cfc24ab4aabb27d2577018f586257aec4a8bd3c18ae13dfcabc70be53ce2108
7
- data.tar.gz: c4c9c0a6766fa93fcf5b40365d22526b40ff7d9cf22615484f030d5043b4b851878e9e36cfc70e1402943296592ed69074b1b813ea372332e4a3a2002cc13bc9
6
+ metadata.gz: f9d78416a789d166c2db523d2b4f6e32e54e3e099f6f6cb369b5d7e1c46b8bc95c3a2088cb211add014a9fc59313985bdbef9f573de70cd86b41f0b936df4cbd
7
+ data.tar.gz: 7131cba0e0935a53bb7966ddd5ce2254374d78d08093b7ef0ccd987848184a2be834938bee169aaad78cfaeb77b9317af94df65c9127fe05559a8f926c51fef2
data/README.md CHANGED
@@ -50,7 +50,8 @@ mode: container # default. This example is container mode end-to-end — see "Co
50
50
  # compose: block instead; the two don't mix within one wip.yml.
51
51
  wslc:
52
52
  command: auto # tries wslc.exe, wslc, then System32; an absolute path also works
53
- container: app # optional; which dependencies: entry `up`/`exec`/`run`/`build`/`commands:` target (default: app)
53
+ container: app # required once dependencies: has entries; which one `up`/`exec`/`run`/`build`/`commands:`
54
+ # target. No default — a project must say which entry is the primary one explicitly.
54
55
  network: app-tier # optional; shared by every dependencies: entry so containers can resolve each other by name
55
56
  dependencies:
56
57
  app: # container: points here — the one container wip execs into and runs commands in
@@ -129,10 +130,11 @@ directly with no copying.
129
130
 
130
131
  ### Dependency containers
131
132
 
132
- `dependencies:` holds every container uniformly — the primary one `container:` points at (`app`
133
- by default) and any sidecar services (a database, Redis, ...) alongside it. Each entry accepts
134
- `image` (required), `command`, `env`, `ports`, `volumes`, and `workdir`; there's no separate,
135
- differently-shaped block for "the one you exec into."
133
+ `dependencies:` holds every container uniformly — the primary one `container:` points at and any
134
+ sidecar services (a database, Redis, ...) alongside it. Each entry accepts `image` (required),
135
+ `command`, `env`, `ports`, `volumes`, and `workdir`; there's no separate, differently-shaped block
136
+ for "the one you exec into." `container:` has no default; once `dependencies:` has any entries,
137
+ wip needs to be told explicitly which one is primary rather than guessing a name.
136
138
 
137
139
  What sets the primary entry apart is operational, not structural: `wip up` brings up every other
138
140
  entry by name first (creating `network:` beforehand if it doesn't exist and set), then boots or
@@ -167,9 +169,16 @@ sync:
167
169
  interval: 2 # seconds between syncs for `wip sync --watch` (default: 2)
168
170
  mode: exec # exec (mirror inside the running container) or run (a throwaway one);
169
171
  # default: exec for `mode: container`, run for `mode: compose`
170
- image: null # image for the mirror container; required under mode: compose (there's no
171
- # dependencies: entry to borrow one from there), unused under mode: container
172
- # unless set (falls back to the primary container's own image)
172
+ image: null # image for the mirror container; unused under mode: container unless set
173
+ # (falls back to the primary container's own image). Under mode: compose,
174
+ # one of image or build is required (there's no dependencies: entry to fall
175
+ # back to)
176
+ build: # optional; has wip build the mirror image itself instead of requiring one to
177
+ # already exist. build.tag wins over image if both are set.
178
+ dockerfile: |
179
+ FROM alpine:latest
180
+ RUN apk add --no-cache rsync
181
+ tag: null # optional (default: "wip-sync-<container>:latest")
173
182
  ```
174
183
 
175
184
  Everything below `sync:` is optional — `sync: {}` alone already works. With it in place:
@@ -187,6 +196,8 @@ Everything below `sync:` is optional — `sync: {}` alone already works. With it
187
196
  - `wip sync --watch [--interval N]` keeps re-syncing until Ctrl-C, so host edits reach the
188
197
  container with a short delay. Run it in a second terminal alongside `wip up -d`.
189
198
  - `wip doctor` reports the resolved source, volume, and target, and fails if the source is missing.
199
+ - With `sync.build` configured, `wip build`s that image once per `wip up`/`wip sync` invocation
200
+ (including once before a `--watch` loop starts, not on every tick) before mirroring with it.
190
201
 
191
202
  Like every built-in command, `wip sync` takes precedence over a `commands:` entry of the same
192
203
  name; wip says so and points at `wip dispatch sync`, which still runs yours.
@@ -215,14 +226,33 @@ or set `delete: false`.
215
226
  the compose service directly.
216
227
  - `sync.mode` defaults to `run` and can't be set to `exec` (only a container wip itself booted is
217
228
  guaranteed to have the read-only source mount attached, which compose services never do), and
218
- `sync.image` becomes required, since that disposable container needs an image from somewhere —
219
- under `mode: container` it borrows the primary `dependencies:` entry's image, but compose mode
220
- has no such entry to borrow from.
229
+ `sync.image` or `sync.build` becomes required, since that disposable container needs an image
230
+ from somewhere — under `mode: container` it borrows the primary `dependencies:` entry's image,
231
+ but compose mode has no such entry to borrow from.
221
232
 
222
233
  `wip up`'s pre-boot mirror (and the `--no-sync` flag that skips it) works the same way under
223
234
  `mode: compose` as it does otherwise: the source is mirrored into the volume before
224
235
  `compose up` starts the service that mounts it.
225
236
 
237
+ Since `sync.mode: run` boots a fresh container on every mirror, reusing your app's full image here
238
+ just adds startup overhead for something that only ever runs `rsync`. A dedicated, minimal image is
239
+ worth it — `wip` doesn't publish or default to one itself (same reasoning as `compose.command`:
240
+ picking a specific third-party image for you isn't its call to make), but `sync.build` covers it
241
+ without needing to manage a separate image yourself:
242
+
243
+ ```yaml
244
+ sync:
245
+ build:
246
+ dockerfile: |
247
+ FROM alpine:latest
248
+ RUN apk add --no-cache rsync
249
+ ```
250
+
251
+ wip builds this once per `wip up`/`wip sync` invocation (not on every `--watch` tick — see above)
252
+ and uses the result, tagged `wip-sync-<container>:latest` by default (`build.tag` overrides it).
253
+ Prefer managing the image yourself instead? Build and tag it however you like, then set `sync.image`
254
+ to that tag directly — `sync.build`'s tag wins if both are set, so don't configure both at once.
255
+
226
256
  ### Compose mode
227
257
 
228
258
  If your project already has a real `compose.yml`, don't duplicate it in `dependencies:` — point
@@ -274,7 +304,7 @@ found, its version, and which compose file `wip` resolved.
274
304
  | `wip doctor` | Diagnose WSL2, interop, WSLC, config, architecture, and Git |
275
305
  | `wip config` | Print the effective configuration (secrets masked) |
276
306
  | `wip build -- --no-cache` | Build the image from the `build` definition |
277
- | `wip up [-d] [--no-sync]` | Start the primary `dependencies:` entry (`container:`, default `app`) and its sidecars (creating any that are missing, on `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` |
307
+ | `wip up [-d] [--no-sync]` | Start the primary `dependencies:` entry (`container:` names which one) and its sidecars (creating any that are missing, on `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` |
278
308
  | `wip down` | Stop and remove the primary container and its sidecar `dependencies:` |
279
309
  | `wip exec [--no-interactive] COMMAND...` | Run a command in the existing container |
280
310
  | `wip run [--no-interactive] COMMAND...` | Run a command in a new `--rm` container (compose mode: `exec`s into `compose.service` instead) |
data/lib/wip/cli.rb CHANGED
@@ -5,6 +5,7 @@ require 'yaml'
5
5
  require 'json'
6
6
  require 'stringio'
7
7
  require 'shellwords'
8
+ require 'tmpdir'
8
9
 
9
10
  module Wip
10
11
  # Thor-based command-line interface for wip.
@@ -93,6 +94,7 @@ module Wip
93
94
  def sync
94
95
  settings = sync_settings!
95
96
  warn_shadowed_command('sync')
97
+ ensure_sync_image(settings)
96
98
  return run_sync unless options[:watch]
97
99
 
98
100
  interval = watch_interval(settings)
@@ -270,6 +272,18 @@ module Wip
270
272
  load_config.sync || raise(ConfigError, '`wip sync` needs a sync: block in wip.yml')
271
273
  end
272
274
 
275
+ # Builds sync.build's image once per invocation (not per --watch tick, which
276
+ # would pay Docker build-cache-lookup overhead on every mirror for no reason).
277
+ # A no-op unless sync.build is configured.
278
+ def ensure_sync_image(settings)
279
+ return unless settings.build
280
+
281
+ Dir.mktmpdir('wip-sync-build-') do |dir|
282
+ File.write(File.join(dir, 'Dockerfile'), settings.build['dockerfile'])
283
+ execute(builder.sync_build(dir))
284
+ end
285
+ end
286
+
273
287
  # `sync.interval` is validated when the config loads; --interval isn't, and
274
288
  # a negative one would only surface as an ArgumentError from `sleep`.
275
289
  def watch_interval(settings)
@@ -300,6 +314,7 @@ module Wip
300
314
  settings = load_config.sync
301
315
  return unless settings
302
316
 
317
+ ensure_sync_image(settings)
303
318
  warn "wip: syncing #{settings.source} -> #{settings.volume}:#{settings.target}"
304
319
  execute(builder.sync_run)
305
320
  warn "wip: run `wip sync --watch` in another terminal to keep #{settings.target} up to date"
@@ -16,7 +16,7 @@ module Wip
16
16
  # dependencies: entries don't carry their own name (it's the hash key), so the
17
17
  # exec target defaults to @config.container; a commands: entry can still
18
18
  # redirect it by setting its own `container:`.
19
- values = primary_values.merge('container' => @config.container).merge(settings)
19
+ values = primary_values.merge('container' => required_container).merge(settings)
20
20
  command = [@wslc, 'exec']
21
21
  command << '-it' if tty?(interactive)
22
22
  command.concat(options(values, include_container: true, include_publish: false)).concat(arguments)
@@ -32,7 +32,7 @@ module Wip
32
32
 
33
33
  def up(detach: false)
34
34
  values = primary_values
35
- command = [@wslc, 'run', '--name', @config.container]
35
+ command = [@wslc, 'run', '--name', required_container]
36
36
  command.push('--network', @config.network) if @config.network
37
37
  command << '-d' if detach
38
38
  command << '-it' if !detach && tty?(true)
@@ -42,25 +42,38 @@ module Wip
42
42
  end
43
43
 
44
44
  def start(detach: false)
45
- command = [@wslc, 'start', @config.container]
45
+ command = [@wslc, 'start', required_container]
46
46
  command.push('-a', '-i') unless detach
47
47
  command
48
48
  end
49
49
 
50
50
  def find
51
- [@wslc, 'list', '--all', '--filter', "name=#{@config.container}", '--format', 'json']
51
+ [@wslc, 'list', '--all', '--filter', "name=#{required_container}", '--format', 'json']
52
52
  end
53
53
 
54
54
  # Mirrors into the volume from a throwaway container. Used for sync.mode:
55
55
  # run (compose's default, and mode: container's fallback for when the app
56
- # container isn't running yet — e.g. just before `up` boots it). sync.image
57
- # overrides the mirror container's image; compose mode requires it, since
58
- # there's no dependencies: entry to fall back to (compose owns those).
56
+ # container isn't running yet — e.g. just before `up` boots it). The image
57
+ # comes from sync.build's tag (once built via sync_build), else sync.image,
58
+ # else the primary dependencies: entry; compose mode requires one of the
59
+ # first two, since it has no dependencies: entry to fall back to.
59
60
  def sync_run
60
61
  sync = required_sync
61
62
  command = [@wslc, 'run', '--rm']
62
63
  sync.volume_specs.each { |spec| command.push('-v', spec) }
63
- command.push(sync.image || required(primary_values, 'image')).concat(sync.mirror_command)
64
+ image = sync.build&.fetch('tag') || sync.image || required(primary_values, 'image')
65
+ command.push(image).concat(sync.mirror_command)
66
+ end
67
+
68
+ # Builds sync.build's image from a Dockerfile staged in `context` (a caller-
69
+ # managed directory, since the build only reads it once `wslc build` runs).
70
+ # Doesn't touch dependencies: at all, so it works the same under compose
71
+ # mode as it does under container mode.
72
+ def sync_build(context)
73
+ sync = required_sync
74
+ raise ConfigError, 'No sync.build configured in wip.yml' unless sync.build
75
+
76
+ [@wslc, 'build', '-t', sync.build['tag'], context]
64
77
  end
65
78
 
66
79
  # Mirrors from inside the already-running container. Only valid for
@@ -68,15 +81,15 @@ module Wip
68
81
  # itself booted is guaranteed to have both the read-only source mount and
69
82
  # the volume attached.
70
83
  def sync_exec
71
- [@wslc, 'exec', @config.container, *required_sync.mirror_command]
84
+ [@wslc, 'exec', required_container, *required_sync.mirror_command]
72
85
  end
73
86
 
74
87
  def down
75
- [@wslc, 'stop', @config.container]
88
+ [@wslc, 'stop', required_container]
76
89
  end
77
90
 
78
91
  def remove
79
- [@wslc, 'remove', '-f', @config.container]
92
+ [@wslc, 'remove', '-f', required_container]
80
93
  end
81
94
 
82
95
  def network_create
@@ -183,9 +196,17 @@ module Wip
183
196
  @config.dependency(name) || raise(ConfigError, "Unknown dependency: #{name}")
184
197
  end
185
198
 
199
+ def required_container
200
+ container = @config.container
201
+ raise ConfigError, 'container: must be set in wip.yml' if container.to_s.empty?
202
+
203
+ container
204
+ end
205
+
186
206
  def primary_values
187
- @config.primary || raise(ConfigError, "No dependencies.#{@config.container} entry " \
188
- '(set container: to name a different one)')
207
+ container = required_container
208
+ @config.dependency(container) || raise(ConfigError, "No dependencies.#{container} entry " \
209
+ '(check container: in wip.yml)')
189
210
  end
190
211
  end
191
212
  end
data/lib/wip/config.rb CHANGED
@@ -25,8 +25,11 @@ module Wip
25
25
  def dependencies = @raw['dependencies'] || {}
26
26
  # Which dependencies: entry `up`/`down`/`exec`/`run`/`build`/`commands:` target
27
27
  # by default — the one container wip itself considers "the app." Everything
28
- # else in dependencies: is a sidecar wip only starts and stops.
29
- def container = presence(@raw['container']) || 'app'
28
+ # else in dependencies: is a sidecar wip only starts and stops. No default:
29
+ # guessing a name here (the old default was "app") either matches by luck or
30
+ # fails in a way that doesn't point at the real problem (a differently-named
31
+ # entry), so a project with any dependencies: must say which one explicitly.
32
+ def container = presence(@raw['container'])
30
33
  def network = @raw['network']
31
34
  def mode = @raw['mode'] || 'container'
32
35
  def compose = @raw['compose']
@@ -107,6 +110,13 @@ module Wip
107
110
 
108
111
  def validate_dependencies!
109
112
  raise ConfigError, 'dependencies must be a mapping' unless dependencies.is_a?(Hash)
113
+ # Under compose mode, populated dependencies: is already an error on its own
114
+ # (validate_compose!, below) — this only needs to fire for the mode: container
115
+ # case, where dependencies: having entries but no container: is otherwise valid.
116
+ if dependencies.any? && !container && !compose?
117
+ raise ConfigError,
118
+ 'container: must be set when dependencies: has entries'
119
+ end
110
120
 
111
121
  dependencies.each { |name, entry| validate_dependency!(name, entry) }
112
122
  end
@@ -11,9 +11,10 @@ module Wip
11
11
  CONTAINER_TEMPLATE = <<~YAML
12
12
  version: 1
13
13
  mode: container
14
+ container: app # TODO: rename freely, as long as it matches a key under dependencies: below
14
15
 
15
16
  dependencies:
16
- app: # TODO: this is the container wip creates, execs into, and runs commands in
17
+ app: # this is the container wip creates, execs into, and runs commands in
17
18
  image: your/image:tag # TODO: image to run
18
19
  workdir: /app # TODO: adjust to match your image, or delete this line
19
20
 
@@ -40,6 +41,8 @@ module Wip
40
41
  <<~YAML
41
42
  version: 1
42
43
  mode: compose
44
+ container: app # TODO: rename freely; only used to name sync's volume ("<container>-src"),
45
+ # since compose.yml — not dependencies: — owns this service's own definition
43
46
 
44
47
  compose:
45
48
  service: app # TODO: which service in #{compose_file} wip run/exec/NAME target
@@ -49,9 +52,9 @@ module Wip
49
52
  YAML
50
53
  end
51
54
 
52
- # sync: needs sync.image (mode: compose has no dependencies: entry to borrow one from) and a
53
- # named volume the compose service mounts, matching sync.volume ("app-src" by default). Checked
54
- # against the detected compose file so the hint doesn't repeat what's already set up.
55
+ # sync: needs sync.image or sync.build (mode: compose has no dependencies: entry to borrow an
56
+ # image from) and a named volume the compose service mounts, matching sync.volume ("app-src" by
57
+ # default). Checked against the detected compose file so the hint doesn't repeat what's set up.
55
58
  def sync_hint
56
59
  return sync_hint_configured if compose_volume_mounted?
57
60
 
@@ -61,14 +64,17 @@ module Wip
61
64
  def sync_hint_configured
62
65
  <<~COMMENT.chomp
63
66
  # #{compose_file} already mounts a volume matching sync.volume's default (app-src).
64
- # sync: # add sync.image too (required under mode: compose) — see README
67
+ # sync: # add sync.build or sync.image too (one's required under mode: compose) — see README
65
68
  COMMENT
66
69
  end
67
70
 
68
71
  def sync_hint_todo
69
72
  <<~COMMENT.chomp
70
73
  # sync: # optional; mirrors the source into a named volume instead of bind-mounting it live
71
- # image: your/image:tag # required under mode: compose (no dependencies: entry to borrow one from)
74
+ # build: # required under mode: compose (no dependencies: entry to borrow an image from)
75
+ # dockerfile: |
76
+ # FROM alpine:latest
77
+ # RUN apk add --no-cache rsync
72
78
  # # the service above must also mount a volume named "app-src" (sync.volume's default) at the
73
79
  # # path your app expects — add to #{compose_file}:
74
80
  # # volumes:
@@ -32,7 +32,7 @@ module Wip
32
32
  # compose owns its own services' mounts and never guarantees that shape.
33
33
  SYNC_MODES = %w[exec run].freeze
34
34
 
35
- attr_reader :target, :mount, :volume, :exclude, :binary, :extra_options, :interval, :mode, :image
35
+ attr_reader :target, :mount, :volume, :exclude, :binary, :extra_options, :interval, :mode, :image, :build
36
36
 
37
37
  def initialize(raw, base: nil, workdir: nil, container: nil, compose: false)
38
38
  raise ConfigError, 'sync must be a mapping' unless raw.is_a?(Hash)
@@ -40,6 +40,7 @@ module Wip
40
40
  @base = base
41
41
  assign_paths(raw, workdir: workdir, container: container)
42
42
  assign_mirror(raw)
43
+ assign_build(raw)
43
44
  assign_mode(raw, compose: compose)
44
45
  validate!
45
46
  end
@@ -76,7 +77,7 @@ module Wip
76
77
  def to_h
77
78
  { 'source' => source, 'target' => target, 'mount' => mount, 'volume' => volume,
78
79
  'delete' => delete?, 'exclude' => exclude, 'command' => binary, 'options' => extra_options,
79
- 'interval' => interval, 'mode' => mode, 'image' => image }
80
+ 'interval' => interval, 'mode' => mode, 'image' => image, 'build' => build }
80
81
  end
81
82
 
82
83
  private
@@ -85,7 +86,8 @@ module Wip
85
86
  @raw_source = presence(raw['source']) || '.'
86
87
  @target = presence(raw['target']) || presence(workdir) || DEFAULT_TARGET
87
88
  @mount = presence(raw['mount']) || DEFAULT_MOUNT
88
- @volume = presence(raw['volume']) || "#{presence(container) || 'wip'}-src"
89
+ @container_name = presence(container) || 'wip'
90
+ @volume = presence(raw['volume']) || "#{@container_name}-src"
89
91
  end
90
92
 
91
93
  def assign_mirror(raw)
@@ -96,12 +98,29 @@ module Wip
96
98
  @interval = raw.key?('interval') ? raw['interval'] : DEFAULT_INTERVAL
97
99
  end
98
100
 
101
+ # sync.build lets wip build a small, dedicated mirror image itself (e.g. alpine
102
+ # + rsync) instead of requiring one to already exist — handy since sync.mode:
103
+ # run boots a fresh container per mirror and reusing the app's full image just
104
+ # adds startup overhead for something that only ever runs rsync.
105
+ def assign_build(raw)
106
+ build = raw['build']
107
+ @build = nil
108
+ return unless build
109
+
110
+ raise ConfigError, 'sync.build must be a mapping' unless build.is_a?(Hash)
111
+
112
+ dockerfile = presence(build['dockerfile'])
113
+ raise ConfigError, 'sync.build.dockerfile must not be empty' unless dockerfile
114
+
115
+ @build = { 'dockerfile' => dockerfile, 'tag' => presence(build['tag']) || "wip-sync-#{@container_name}:latest" }
116
+ end
117
+
99
118
  def assign_mode(raw, compose:)
100
119
  @mode = presence(raw['mode']) || (compose ? 'run' : 'exec')
101
120
  @image = presence(raw['image'])
102
121
  raise ConfigError, "sync.mode must be one of #{SYNC_MODES.join(', ')}" unless SYNC_MODES.include?(@mode)
103
122
 
104
- validate_image!(compose)
123
+ validate_image_source!(compose)
105
124
  return unless compose && exec?
106
125
 
107
126
  raise ConfigError, 'sync.mode: exec needs mode: container (compose owns its services’ mounts, ' \
@@ -109,12 +128,13 @@ module Wip
109
128
  end
110
129
 
111
130
  # compose mode has no dependencies: entry to fall back to for the mirror
112
- # container's image, so sync.image can't be left implicit there.
113
- def validate_image!(compose)
114
- return if !compose || @image
131
+ # container's image, so it can't be left implicit there — sync.image or
132
+ # sync.build must name one explicitly.
133
+ def validate_image_source!(compose)
134
+ return if !compose || @image || @build
115
135
 
116
- raise ConfigError, 'sync.image is required under mode: compose (there’s no dependencies: entry ' \
117
- 'to borrow the mirror container’s image from)'
136
+ raise ConfigError, 'sync.image or sync.build is required under mode: compose (there’s no ' \
137
+ 'dependencies: entry to borrow the mirror container’s image from)'
118
138
  end
119
139
 
120
140
  def validate!
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.11.0'
4
+ VERSION = '0.13.0'
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: wslc-wip
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.11.0
4
+ version: 0.13.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Wip contributors