wslc-wip 0.13.1 → 0.14.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: 5d7c6ca103234cf197e8ac46015705762a93febd37b57fd2b20fef283bfbf91c
4
- data.tar.gz: 127f768a68ed87d54a72dcf8ea5ef9d6daeae5cecc0a6a2475b683f3bf2309c6
3
+ metadata.gz: 0c96178bbfe0be6dd82a52cc8cc7d3334688c97b981aaa7c35cfad5c0429c82b
4
+ data.tar.gz: f8574971724779ec668a41d9c1509aeab624222953dba34a609defd81b5c6589
5
5
  SHA512:
6
- metadata.gz: 9261bab775ca93d1509886c2b94a19c5b2caeb422ca23a20652f9e23e49b247a05a9267043c163a26c9fffb50497cfbcbfde7acba7186222549414f95c1670cf
7
- data.tar.gz: 6584d50a4d7199522a58f7dceb9b72d103b34b2b6d1a87c8a106ae85d4cefdf79c6870889936f521f7a6654afcf8e49d7a5262a47c7080ee919160fceffec56c
6
+ metadata.gz: 938472e7df5f8f97e5c5160ad299a1a83a1f1026c6320a00183944e3386ca80b323104aa0a101d27ec7694f752de7b502d963e38970d32e2b4040d0388acc1bf
7
+ data.tar.gz: d33a1ff1ff31dce4d8d6d6c9114ce3a0fa2da8ac29a2f89757114ab351d0b1de8a331071f50ebb2456443b38ebb4b315c88c635efd12d5982eb879d267c48434
data/README.md CHANGED
@@ -295,11 +295,53 @@ found, its version, and which compose file `wip` resolved.
295
295
  - `commands:` entries with `type: run`/`type: build` aren't supported in compose mode — use your
296
296
  compose tool's own `build`/`up --build` directly; compose owns builds for its own services.
297
297
 
298
+ ### Compose mode (native)
299
+
300
+ Don't want to install a third-party compose-for-`wslc` tool at all? `mode: compose-native` parses
301
+ `compose.yml` itself and drives `wslc` directly, the same way `mode: container` +`dependencies:`
302
+ already does — no external binary, and `wip run` gets a real ephemeral `wslc run --rm` instead of
303
+ the `exec` fallback above:
304
+
305
+ ```yaml
306
+ version: 1
307
+ mode: compose-native
308
+
309
+ compose:
310
+ service: app # required: which compose service wip run/exec/NAME target
311
+ file: compose.yml # optional; auto-detected next to wip.yml otherwise
312
+ project: myapp # optional; also names wip's own project network (defaults to the wip.yml
313
+ # directory's name) so services can reach each other by name
314
+ ```
315
+
316
+ There's no `compose.command` here (no external binary to name), and no top-level `container:` —
317
+ `compose.service` already names it.
318
+
319
+ This is explicitly a stopgap for as long as `wslc` itself has no native Compose support (tracked
320
+ upstream in [microsoft/WSL#40948](https://github.com/microsoft/WSL/issues/40948)) and third-party
321
+ compose-for-`wslc` tools stay incomplete. It only understands a minimal subset of the Compose spec.
322
+ Within `services.<name>:`, anything outside that subset is a load-time `ConfigError` naming the
323
+ offending key, rather than silently ignored — but everything *outside* `services:` at the document's
324
+ top level (`networks:`, `volumes:`, `configs:`, `secrets:`, ...) is the one exception: it's read by
325
+ real Compose tools, not by `wip`, so `wip` silently ignores it rather than rejecting an otherwise
326
+ valid compose.yml over sections it doesn't need to look at:
327
+
328
+ - Per service: `image`, `build` (string or `{context:, dockerfile:}`; resolved relative to
329
+ `compose.yml`, not wherever `wip` is invoked from), `command` (shell or exec form), `environment`
330
+ (mapping or `KEY=VALUE` array — a mapping value must not be null; host environment pass-through
331
+ isn't supported), `ports`/`volumes` (short syntax only — `"host:container"` strings, not
332
+ long-syntax mappings), `working_dir`, `depends_on` (ordering only — a `condition:` other than
333
+ `service_started` is rejected, since there's no health-check support).
334
+ - `wip logs` takes at most one `SERVICE` (defaulting to `compose.service`) — `wslc logs`, like
335
+ `docker logs`, follows a single container, unlike a real compose tool's multi-service view.
336
+ - `sync:` behaves exactly like `mode: container`'s (falls back to the primary service's own image,
337
+ defaults to `sync.mode: exec`) — none of the external bridge's `sync.image`/`sync.build`
338
+ requirement applies, since wip itself boots every container here.
339
+
298
340
  ## Commands
299
341
 
300
342
  | Command | Description |
301
343
  |---|---|
302
- | `wip init [--force]` | Write a starter `wip.yml`: `mode: compose` if a `compose.yml`/`docker-compose.yml` is found next to it, `mode: container` otherwise. Refuses to overwrite an existing `wip.yml` unless `--force` |
344
+ | `wip init [--force]` | Write a starter `wip.yml`: `mode: compose-native` if a `compose.yml`/`docker-compose.yml` is found next to it, `mode: container` otherwise. Refuses to overwrite an existing `wip.yml` unless `--force` |
303
345
  | `wip version` | wip's version, plus WSLC's if it can be detected |
304
346
  | `wip doctor` | Diagnose WSL2, interop, WSLC, config, architecture, and Git |
305
347
  | `wip config` | Print the effective configuration (secrets masked) |
@@ -307,9 +349,9 @@ found, its version, and which compose file `wip` resolved.
307
349
  | `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` |
308
350
  | `wip down` | Stop and remove the primary container and its sidecar `dependencies:` |
309
351
  | `wip exec [--no-interactive] COMMAND...` | Run a command in the existing container |
310
- | `wip run [--no-interactive] COMMAND...` | Run a command in a new `--rm` container (compose mode: `exec`s into `compose.service` instead) |
352
+ | `wip run [--no-interactive] COMMAND...` | Run a command in a new `--rm` container (mode: compose `exec`s into `compose.service` instead — see "Compose mode" above) |
311
353
  | `wip shell` | Open the configured shell, falling back to `bash` then `sh` |
312
- | `wip logs [-f] [SERVICE...]` | Follow compose service logs (compose mode only) |
354
+ | `wip logs [-f] [SERVICE...]` | Follow compose service logs (compose modes only; mode: compose-native takes at most one `SERVICE`) |
313
355
  | `wip sync [-w] [--interval N]` | Mirror the source into the sync volume once, or keep re-syncing with `--watch` (needs `sync:`) |
314
356
  | `wip NAME ARGS...` | Run `commands.NAME`, appending any extra arguments |
315
357
 
@@ -428,10 +470,11 @@ checklist.
428
470
 
429
471
  ## Not in the initial release
430
472
 
431
- Full Compose compatibility isn't reimplemented in `wip` itself, but is available by delegating to
432
- a third-party compose-for-`wslc` tool — see [Compose mode](#compose-mode). A resident/daemon
433
- process, a GUI, PowerShell-specific tuning, direct registry API/manifest parsing, self-update, and
434
- plugins are all unimplemented.
473
+ Full Compose compatibility isn't reimplemented in `wip` itself `mode: compose-native` only
474
+ covers a minimal subset (see [Compose mode (native)](#compose-mode-native)), and full parity is
475
+ otherwise available by delegating to a third-party compose-for-`wslc` tool (see
476
+ [Compose mode](#compose-mode)). A resident/daemon process, a GUI, PowerShell-specific tuning,
477
+ direct registry API/manifest parsing, self-update, and plugins are all unimplemented.
435
478
 
436
479
  ## Roadmap
437
480
 
data/lib/wip/cli.rb CHANGED
@@ -48,7 +48,7 @@ module Wip
48
48
 
49
49
  initializer = Initializer.new(dir: path.dirname)
50
50
  path.write(initializer.call)
51
- warn "wip: wrote #{path} (mode: #{initializer.compose? ? 'compose' : 'container'})"
51
+ warn "wip: wrote #{path} (mode: #{initializer.compose? ? 'compose-native' : 'container'})"
52
52
  end
53
53
 
54
54
  desc 'doctor', 'Diagnose the development environment'
@@ -82,6 +82,7 @@ module Wip
82
82
  return execute(compose_bridge.up(detach: options[:detach]), interactive: tty?(!options[:detach]))
83
83
  end
84
84
 
85
+ ensure_compose_images
85
86
  ensure_network
86
87
  sidecar_names.each { |name| ensure_dependency(name) }
87
88
  sync_before_boot if options[:sync]
@@ -155,9 +156,22 @@ module Wip
155
156
  desc 'logs [SERVICE...]', 'Follow logs from compose services (compose mode only)'
156
157
  option :follow, type: :boolean, default: true, aliases: '-f'
157
158
  def logs(*services)
158
- raise ConfigError, '`wip logs` is only available in compose mode' unless load_config.compose?
159
+ unless load_config.compose? || load_config.compose_native?
160
+ raise ConfigError, '`wip logs` is only available in compose mode'
161
+ end
162
+ if load_config.compose?
163
+ return execute(compose_bridge.logs(services: services, follow: options[:follow]),
164
+ interactive: true)
165
+ end
166
+
167
+ # wslc has no compose-style multi-service log aggregation (see CommandBuilder#logs) —
168
+ # exactly one SERVICE is required, defaulting to compose.service when none is given.
169
+ if services.size > 1
170
+ raise ConfigError, '`wip logs` under mode: compose-native takes at most one SERVICE ' \
171
+ '(wslc logs, unlike a real compose tool, only follows one container at a time)'
172
+ end
159
173
 
160
- execute(compose_bridge.logs(services: services, follow: options[:follow]), interactive: true)
174
+ execute(builder.logs(services.first || load_config.compose_service, follow: options[:follow]), interactive: true)
161
175
  end
162
176
 
163
177
  desc 'dispatch COMMAND [ARGS...]', 'Run a command defined in wip.yml'
@@ -272,6 +286,16 @@ module Wip
272
286
  load_config.sync || raise(ConfigError, '`wip sync` needs a sync: block in wip.yml')
273
287
  end
274
288
 
289
+ # Builds every compose-native service with a build: (instead of image:) once per
290
+ # `wip up` invocation, mirroring ensure_sync_image's "build once, not every tick"
291
+ # approach. A no-op for mode: container/compose and for build:-less services.
292
+ def ensure_compose_images
293
+ load_config.compose_build_specs.each_value do |spec|
294
+ extra = spec['dockerfile'] ? ['-f', spec['dockerfile']] : []
295
+ execute(builder.build(settings: { 'context' => spec['context'], 'tag' => spec['tag'] }, extra: extra))
296
+ end
297
+ end
298
+
275
299
  # Builds sync.build's image once per invocation (not per --watch tick, which
276
300
  # would pay Docker build-cache-lookup overhead on every mirror for no reason).
277
301
  # A no-op unless sync.build is configured.
@@ -88,6 +88,16 @@ module Wip
88
88
  [@wslc, 'stop', required_container]
89
89
  end
90
90
 
91
+ # Only reachable under mode: compose-native — mode: compose delegates `wip logs`
92
+ # to the external compose command's own `logs` (ComposeBridge#logs) instead.
93
+ # Single container only, mirroring `wslc`/docker's own `logs`: there's no
94
+ # multi-service log aggregation the way a real compose tool provides.
95
+ def logs(name, follow: true)
96
+ command = [@wslc, 'logs']
97
+ command << '-f' if follow
98
+ command.push(name.to_s)
99
+ end
100
+
91
101
  def remove
92
102
  [@wslc, 'remove', '-f', required_container]
93
103
  end
@@ -0,0 +1,217 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yaml'
4
+ require 'pathname'
5
+ require 'shellwords'
6
+
7
+ module Wip
8
+ # Parses compose.yml/docker-compose.yml into the shape mode: compose-native
9
+ # needs to drive `wslc` directly, in place of an external compose-for-wslc
10
+ # binary (see ComposeBridge/mode: compose).
11
+ #
12
+ # This exists only because `wslc` has no native Compose support yet
13
+ # (tracked upstream in microsoft/WSL#40948). Delete this file — and its
14
+ # hooks in config.rb, cli.rb, doctor.rb and initializer.rb (see README
15
+ # "Compose mode (native)") — once wslc ships that support, or a
16
+ # compose-for-wslc tool reliably supports `run`.
17
+ class ComposeFile
18
+ Service = Struct.new(:image, :build, :command, :env, :ports, :volumes, :workdir, :depends_on, keyword_init: true)
19
+
20
+ SERVICE_KEYS = %w[image build command environment ports volumes working_dir depends_on].freeze
21
+ BUILD_KEYS = %w[context dockerfile].freeze
22
+ SUPPORTED_CONDITIONS = %w[service_started].freeze
23
+
24
+ def self.load(path)
25
+ raw = YAML.safe_load_file(path, aliases: true)
26
+ raise ConfigError, "#{path}: services: must be a mapping" unless raw.is_a?(Hash) && raw['services'].is_a?(Hash)
27
+
28
+ new(raw['services'], path: path)
29
+ rescue Psych::Exception => e
30
+ raise ConfigError, "Could not parse #{path}: #{e.message}"
31
+ rescue Errno::ENOENT
32
+ raise ConfigError, "Compose file not found: #{path}"
33
+ end
34
+
35
+ def initialize(services, path:)
36
+ @path = path
37
+ @services = services.to_h { |name, entry| [name.to_s, build_service(name.to_s, entry)] }
38
+ validate_depends_on!
39
+ @order = topological_order
40
+ end
41
+
42
+ def service_names_in_dependency_order = @order.dup
43
+
44
+ # name => {context:, dockerfile:, tag:} for every service with a build:.
45
+ def build_specs
46
+ @order.filter_map do |name|
47
+ build = @services.fetch(name).build
48
+ next unless build
49
+
50
+ [name, build.merge('tag' => image_tag(name))]
51
+ end.to_h
52
+ end
53
+
54
+ # Shaped like Config::DEPENDENCY_DEFAULTS expects: image/command/env/ports/volumes/workdir,
55
+ # in dependency order so callers iterating sidecars start them before their dependents.
56
+ def to_dependencies_hash
57
+ @order.to_h do |name|
58
+ service = @services.fetch(name)
59
+ [name, { 'image' => service.build ? image_tag(name) : service.image, 'command' => service.command,
60
+ 'env' => service.env, 'ports' => service.ports, 'volumes' => service.volumes,
61
+ 'workdir' => service.workdir }]
62
+ end
63
+ end
64
+
65
+ private
66
+
67
+ def image_tag(name) = "wip-compose-#{name}:latest"
68
+
69
+ def build_service(name, entry)
70
+ raise ConfigError, "#{@path}: services.#{name} must be a mapping" unless entry.is_a?(Hash)
71
+
72
+ unknown = entry.keys.map(&:to_s) - SERVICE_KEYS
73
+ raise ConfigError, "#{@path}: services.#{name} has unsupported key(s): #{unknown.join(', ')}" if unknown.any?
74
+
75
+ image, build = image_or_build(name, entry)
76
+ Service.new(image: image, build: build, command: normalize_command(entry['command']),
77
+ env: normalize_env(name, entry['environment']),
78
+ ports: normalize_list(name, entry['ports'], 'ports'),
79
+ volumes: normalize_list(name, entry['volumes'], 'volumes'),
80
+ workdir: presence(entry['working_dir']), depends_on: normalize_depends_on(name, entry['depends_on']))
81
+ end
82
+
83
+ def image_or_build(name, entry)
84
+ image = presence(entry['image'])
85
+ build = normalize_build(name, entry['build'])
86
+ raise ConfigError, "#{@path}: services.#{name} must set image or build" unless image || build
87
+ raise ConfigError, "#{@path}: services.#{name} must not set both image and build" if image && build
88
+
89
+ [image, build]
90
+ end
91
+
92
+ # Compose allows both shell form ("bin/rails s") and exec form (["bin/rails", "s"]);
93
+ # CommandBuilder re-splits whatever's here with Shellwords, so join exec form back into
94
+ # one string instead of letting Array#to_s (via presence) turn it into a literal
95
+ # "[\"bin/rails\", \"s\"]" that Shellwords would then split wrong.
96
+ def normalize_command(value)
97
+ return presence(value) unless value.is_a?(Array)
98
+
99
+ presence(Shellwords.join(value.map(&:to_s)))
100
+ end
101
+
102
+ def normalize_build(name, value)
103
+ return nil unless value
104
+
105
+ case value
106
+ when String then { 'context' => resolve_context(value) }
107
+ when Hash then normalize_build_mapping(name, value)
108
+ else raise ConfigError, "#{@path}: services.#{name}.build must be a string or mapping"
109
+ end
110
+ end
111
+
112
+ def normalize_build_mapping(name, value)
113
+ unknown = value.keys.map(&:to_s) - BUILD_KEYS
114
+ if unknown.any?
115
+ raise ConfigError,
116
+ "#{@path}: services.#{name}.build has unsupported key(s): #{unknown.join(', ')}"
117
+ end
118
+
119
+ context = resolve_context(presence(value['context']) || '.')
120
+ dockerfile = presence(value['dockerfile'])
121
+ { 'context' => context, 'dockerfile' => dockerfile && Pathname(context).join(dockerfile).to_s }.compact
122
+ end
123
+
124
+ # build.context is relative to compose.yml's own directory (Compose's own rule),
125
+ # not wherever `wip` happens to be invoked from.
126
+ def resolve_context(raw) = Pathname(@path).expand_path.dirname.join(raw).to_s
127
+
128
+ def normalize_env(name, value)
129
+ return {} unless value
130
+
131
+ case value
132
+ when Hash then normalize_env_mapping(name, value)
133
+ when Array then normalize_env_array(name, value)
134
+ else raise ConfigError, "#{@path}: services.#{name}.environment must be a mapping or an array of KEY=VALUE"
135
+ end
136
+ end
137
+
138
+ def normalize_env_mapping(name, value)
139
+ value.to_h do |key, val|
140
+ if val.nil?
141
+ raise ConfigError, "#{@path}: services.#{name}.environment.#{key} must have a value " \
142
+ '(host environment pass-through is not supported)'
143
+ end
144
+
145
+ [key.to_s, val.to_s]
146
+ end
147
+ end
148
+
149
+ def normalize_env_array(name, value)
150
+ value.to_h do |item|
151
+ key, val = item.to_s.split('=', 2)
152
+ raise ConfigError, "#{@path}: services.#{name}.environment entries must be KEY=VALUE" unless val
153
+
154
+ [key, val]
155
+ end
156
+ end
157
+
158
+ def normalize_list(name, value, key)
159
+ return [] unless value
160
+ raise ConfigError, "#{@path}: services.#{name}.#{key} must be an array" unless value.is_a?(Array)
161
+ if value.any?(Hash)
162
+ raise ConfigError, "#{@path}: services.#{name}.#{key} only supports short syntax (\"host:container\"), " \
163
+ 'not long-syntax mappings'
164
+ end
165
+
166
+ value.map(&:to_s)
167
+ end
168
+
169
+ def normalize_depends_on(name, value)
170
+ return [] unless value
171
+
172
+ case value
173
+ when Array then value.map(&:to_s)
174
+ when Hash then value.map { |dep, opts| depends_on_entry(name, dep, opts) }
175
+ else raise ConfigError, "#{@path}: services.#{name}.depends_on must be an array or a mapping"
176
+ end
177
+ end
178
+
179
+ def depends_on_entry(name, dep, opts)
180
+ condition = opts.is_a?(Hash) ? opts['condition'] : nil
181
+ if condition && !SUPPORTED_CONDITIONS.include?(condition)
182
+ raise ConfigError, "#{@path}: services.#{name}.depends_on.#{dep}: condition '#{condition}' is not " \
183
+ "supported (only #{SUPPORTED_CONDITIONS.join(', ')} — no health checks)"
184
+ end
185
+ dep.to_s
186
+ end
187
+
188
+ def validate_depends_on!
189
+ @services.each do |name, service|
190
+ service.depends_on.each do |dep|
191
+ raise ConfigError, "#{@path}: services.#{name} depends_on unknown service '#{dep}'" unless @services.key?(dep)
192
+ end
193
+ end
194
+ end
195
+
196
+ def topological_order
197
+ visited = {}
198
+ visiting = {}
199
+ order = []
200
+ @services.each_key { |name| visit(name, visited, visiting, order) }
201
+ order
202
+ end
203
+
204
+ def visit(name, visited, visiting, order)
205
+ return if visited[name]
206
+ raise ConfigError, "#{@path}: services.#{name} is part of a depends_on cycle" if visiting[name]
207
+
208
+ visiting[name] = true
209
+ @services.fetch(name).depends_on.each { |dep| visit(dep, visited, visiting, order) }
210
+ visiting.delete(name)
211
+ visited[name] = true
212
+ order << name
213
+ end
214
+
215
+ def presence(value) = value.to_s.empty? ? nil : value.to_s
216
+ end
217
+ end
data/lib/wip/config.rb CHANGED
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'pathname'
4
+
3
5
  module Wip
4
6
  # Validated, defaulted access to a parsed wip.yml document.
5
7
  class Config
@@ -11,7 +13,10 @@ module Wip
11
13
  # Which orchestration path `up`/`down`/`sync`/etc. take. Explicit rather than
12
14
  # inferred from a `compose:` block's presence, so a config reader doesn't have
13
15
  # to know that rule to predict which mode wip runs in.
14
- MODES = %w[container compose].freeze
16
+ # compose bridges to an external compose-for-wslc binary (ComposeBridge);
17
+ # compose-native parses compose.yml itself and drives wslc directly (ComposeFile) —
18
+ # a stopgap for as long as those external tools stay incomplete (missing `run`, etc.).
19
+ MODES = %w[container compose compose-native].freeze
15
20
  attr_reader :path
16
21
 
17
22
  def initialize(raw, path = nil)
@@ -22,22 +27,37 @@ module Wip
22
27
 
23
28
  def wslc_command = @raw.dig('wslc', 'command') || 'auto'
24
29
  def commands = @raw['commands'] || {}
25
- def dependencies = @raw['dependencies'] || {}
30
+ # Raw dependencies: block as written in wip.yml. Under compose-native mode this stays
31
+ # empty by construction (validate_compose! forbids combining the two) — #dependencies
32
+ # below is what callers actually want, since it's synthesized from compose.yml there.
33
+ def raw_dependencies = @raw['dependencies'] || {}
34
+ def dependencies = compose_native? ? parsed_compose_file.to_dependencies_hash : raw_dependencies
26
35
  # Which dependencies: entry `up`/`down`/`exec`/`run`/`build`/`commands:` target
27
36
  # by default — the one container wip itself considers "the app." Everything
28
37
  # else in dependencies: is a sidecar wip only starts and stops. No default:
29
38
  # guessing a name here (the old default was "app") either matches by luck or
30
39
  # fails in a way that doesn't point at the real problem (a differently-named
31
40
  # entry), so a project with any dependencies: must say which one explicitly.
32
- def container = presence(@raw['container'])
33
- def network = @raw['network']
41
+ # Under compose-native mode, container: is compose.service — compose.yml already
42
+ # names the service, so there's no separate container: key to set in wip.yml.
43
+ def container = compose_native? ? compose_service : presence(@raw['container'])
44
+ # Under compose-native mode, wip creates its own project network (compose.project,
45
+ # or the wip.yml directory's name) so services can reach each other by name — the
46
+ # same guarantee real Compose's per-project network gives.
47
+ def network = compose_native? ? (compose_project || default_compose_network) : @raw['network']
34
48
  def mode = @raw['mode'] || 'container'
35
49
  def compose = @raw['compose']
36
50
  def compose? = mode == 'compose'
51
+ def compose_native? = mode == 'compose-native'
52
+ def compose_mode? = compose? || compose_native?
37
53
  def compose_service = compose && compose['service']
38
54
  def compose_file = compose && compose['file']
39
55
  def compose_project = compose && compose['project']
40
56
  def compose_command = compose && compose['command']
57
+ # name => {context:, dockerfile:, tag:} for compose-native services with a build:
58
+ # instead of an image: — empty otherwise. Consumed by `wip up` to build each one
59
+ # before starting it (see CommandBuilder#build).
60
+ def compose_build_specs = compose_native? ? parsed_compose_file.build_specs : {}
41
61
  def sync? = !!sync
42
62
 
43
63
  def sync
@@ -92,8 +112,10 @@ module Wip
92
112
 
93
113
  def validate_mode!
94
114
  raise ConfigError, "mode must be one of #{MODES.join(', ')}" unless MODES.include?(mode)
95
- raise ConfigError, 'mode: compose requires a compose: block' if compose? && !@raw.key?('compose')
96
- raise ConfigError, 'a compose: block requires mode: compose' if @raw.key?('compose') && !compose?
115
+ raise ConfigError, "mode: #{mode} requires a compose: block" if compose_mode? && !@raw.key?('compose')
116
+ return unless @raw.key?('compose') && !compose_mode?
117
+
118
+ raise ConfigError, 'a compose: block requires mode: compose or compose-native'
97
119
  end
98
120
 
99
121
  # Forces SyncSettings to build (and so run its own validation, including
@@ -109,25 +131,41 @@ module Wip
109
131
  end
110
132
 
111
133
  def validate_dependencies!
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
134
+ raise ConfigError, 'dependencies must be a mapping' unless raw_dependencies.is_a?(Hash)
135
+ # Under either compose mode, populated dependencies: is already an error on its
136
+ # own (validate_compose!, below) — this only needs to fire for the mode: container
115
137
  # case, where dependencies: having entries but no container: is otherwise valid.
116
- if dependencies.any? && !container && !compose?
138
+ if raw_dependencies.any? && !container && !compose_mode?
117
139
  raise ConfigError,
118
140
  'container: must be set when dependencies: has entries'
119
141
  end
120
142
 
121
- dependencies.each { |name, entry| validate_dependency!(name, entry) }
143
+ raw_dependencies.each { |name, entry| validate_dependency!(name, entry) }
122
144
  end
123
145
 
124
146
  def validate_compose!
125
147
  return unless @raw.key?('compose')
126
148
  raise ConfigError, 'compose must be a mapping' unless compose.is_a?(Hash)
127
149
  raise ConfigError, 'compose.service must not be empty' if compose_service.to_s.empty?
128
- raise ConfigError, 'compose.command must not be empty' if compose_command.to_s.empty?
129
- raise ConfigError, 'compose is mutually exclusive with dependencies' if dependencies.any?
130
- raise ConfigError, 'compose is mutually exclusive with network' if network
150
+ raise ConfigError, 'compose is mutually exclusive with dependencies' if raw_dependencies.any?
151
+ raise ConfigError, 'compose is mutually exclusive with network' if @raw['network']
152
+
153
+ validate_compose_command!
154
+ end
155
+
156
+ # compose.command has opposite requiredness depending on which compose mode this
157
+ # is: mode: compose has no default (every external compose-for-wslc implementation
158
+ # must be named explicitly), while mode: compose-native drives wslc directly and so
159
+ # has no external binary to name at all.
160
+ def validate_compose_command!
161
+ if compose_native?
162
+ if compose_command
163
+ raise ConfigError, 'compose.command is not used under mode: compose-native (wip drives wslc ' \
164
+ 'directly — there’s no external compose binary to name)'
165
+ end
166
+ elsif compose_command.to_s.empty?
167
+ raise ConfigError, 'compose.command must not be empty'
168
+ end
131
169
  end
132
170
 
133
171
  def validate_command!(name, entry)
@@ -162,5 +200,11 @@ module Wip
162
200
  end
163
201
 
164
202
  def presence(value) = value.to_s.empty? ? nil : value.to_s
203
+
204
+ def parsed_compose_file
205
+ @parsed_compose_file ||= ComposeFile.load(ComposeBridge.file_path(self))
206
+ end
207
+
208
+ def default_compose_network = path && Pathname(path).dirname.basename.to_s
165
209
  end
166
210
  end
data/lib/wip/doctor.rb CHANGED
@@ -51,9 +51,15 @@ module Wip
51
51
 
52
52
  # dependencies.<container> having an empty image is already a load-time
53
53
  # ConfigError (validate_dependency!), so the only way to reach here with a
54
- # broken primary container is `container:` naming an entry that isn't defined.
54
+ # broken primary container is `container:` naming an entry that isn't defined
55
+ # — or, under compose-native, compose.service naming a service compose.yml
56
+ # doesn't actually define.
55
57
  def container_result(config)
56
58
  return [:ok, 'Loaded wip.yml'] if config.compose? || config.primary
59
+ if config.compose_native?
60
+ return [:fail,
61
+ "compose.service '#{config.container}' has no matching service in compose.yml"]
62
+ end
57
63
 
58
64
  [:fail, "No dependencies.#{config.container} entry"]
59
65
  end
@@ -63,6 +69,7 @@ module Wip
63
69
 
64
70
  check_wslc(config, results)
65
71
  check_compose(config, results) if config.compose?
72
+ check_compose_native(config, results) if config.compose_native?
66
73
  check_sync(config, results) if config.sync?
67
74
  end
68
75
 
@@ -103,10 +110,25 @@ module Wip
103
110
  nil
104
111
  end
105
112
 
113
+ # Returns whether the file was found, so check_compose_native can skip
114
+ # attempting to parse a file that's already been reported missing.
106
115
  def check_compose_file(config, results)
107
116
  path = ComposeBridge.file_path(config)
108
- results << result(path.file? ? :ok : :fail, "Found compose file #{path}",
109
- "Compose file not found: #{path}")
117
+ found = path.file?
118
+ results << result(found ? :ok : :fail, "Found compose file #{path}", "Compose file not found: #{path}")
119
+ found
120
+ rescue ConfigError => e
121
+ results << Result.new(:fail, e.message)
122
+ false
123
+ end
124
+
125
+ # mode: compose-native has no external binary to check (check_wslc, above, already
126
+ # covers the one binary it drives) — just that compose.yml exists and parses.
127
+ def check_compose_native(config, results)
128
+ return unless check_compose_file(config, results)
129
+
130
+ ComposeFile.load(ComposeBridge.file_path(config))
131
+ results << Result.new(:ok, 'Parsed compose file')
110
132
  rescue ConfigError => e
111
133
  results << Result.new(:fail, e.message)
112
134
  end
@@ -1,12 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'yaml'
4
-
5
3
  module Wip
6
4
  # Builds a starter wip.yml. Detects an existing compose file next to the
7
5
  # target (the same filenames ComposeBridge auto-detects) to decide between
8
- # mode: compose and mode: container, since that's the one decision `wip
9
- # init` can't leave to a placeholder.
6
+ # mode: compose-native and mode: container, since that's the one decision
7
+ # `wip init` can't leave to a placeholder.
10
8
  class Initializer
11
9
  CONTAINER_TEMPLATE = <<~YAML
12
10
  version: 1
@@ -40,56 +38,15 @@ module Wip
40
38
  def compose_template
41
39
  <<~YAML
42
40
  version: 1
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
41
+ mode: compose-native # wip parses #{compose_file} itself and drives wslc directly — no external
42
+ # compose-for-wslc binary needed. Prefer a real compose tool instead? Use
43
+ # mode: compose (see README "Compose mode") and set compose.command.
46
44
 
47
45
  compose:
48
46
  service: app # TODO: which service in #{compose_file} wip run/exec/NAME target
49
- command: wslc-compose # TODO: the compose-for-wslc binary/path you have installed
50
47
 
51
- #{sync_hint}
48
+ sync: {} # optional; mirrors the source into a named volume instead of bind-mounting it live
52
49
  YAML
53
50
  end
54
-
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.
58
- def sync_hint
59
- return sync_hint_configured if compose_volume_mounted?
60
-
61
- sync_hint_todo
62
- end
63
-
64
- def sync_hint_configured
65
- <<~COMMENT.chomp
66
- # #{compose_file} already mounts a volume matching sync.volume's default (app-src).
67
- # sync: # add sync.build or sync.image too (one's required under mode: compose) — see README
68
- COMMENT
69
- end
70
-
71
- def sync_hint_todo
72
- <<~COMMENT.chomp
73
- # sync: # optional; mirrors the source into a named volume instead of bind-mounting it live
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
78
- # # the service above must also mount a volume named "app-src" (sync.volume's default) at the
79
- # # path your app expects — add to #{compose_file}:
80
- # # volumes:
81
- # # - app-src:/app
82
- # # and, alongside services:
83
- # # volumes:
84
- # # app-src:
85
- COMMENT
86
- end
87
-
88
- def compose_volume_mounted?
89
- services = YAML.safe_load_file(File.join(@dir, compose_file), aliases: true)&.fetch('services', nil) || {}
90
- services.values.any? { |service| Array(service['volumes']).any? { |v| v.to_s.start_with?('app-src:') } }
91
- rescue StandardError
92
- false
93
- end
94
51
  end
95
52
  end
data/lib/wip/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Wip
4
- VERSION = '0.13.1'
4
+ VERSION = '0.14.0'
5
5
  end
data/lib/wip.rb CHANGED
@@ -13,6 +13,7 @@ require_relative 'wip/command_resolver'
13
13
  require_relative 'wip/error_interpreter'
14
14
  require_relative 'wip/command_builder'
15
15
  require_relative 'wip/compose_bridge'
16
+ require_relative 'wip/compose_file'
16
17
  require_relative 'wip/initializer'
17
18
  require_relative 'wip/command_display'
18
19
  require_relative 'wip/command_runner'
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.13.1
4
+ version: 0.14.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Wip contributors
@@ -40,6 +40,7 @@ files:
40
40
  - lib/wip/command_resolver.rb
41
41
  - lib/wip/command_runner.rb
42
42
  - lib/wip/compose_bridge.rb
43
+ - lib/wip/compose_file.rb
43
44
  - lib/wip/config.rb
44
45
  - lib/wip/config_loader.rb
45
46
  - lib/wip/debug_reporter.rb