rascal 0.3.9 → 0.4.1

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: ffca96c320440170db495b607004508d9f60aac4d6f387e803084aff3480344f
4
- data.tar.gz: ca02687246280f362e0035a697a9061eec1edf46de2ebccdc67fc8999f4902d5
3
+ metadata.gz: 8fa4e3608306dedc697f6da5f37017038e694e2c9182cee77fe79f7a448fdfa3
4
+ data.tar.gz: 38b938c35bbe6dd09df61fae8f292083cbb1177b954516f79e02d980591100ed
5
5
  SHA512:
6
- metadata.gz: 88d02bffc72f022deea6798ff1ff34561874367b4d30c796162eae4cbad6610018682ad9b7f1992e319d823a6745e5125c723aa0c97938b64ecf79695ca16652
7
- data.tar.gz: cf2c78e81c688d8529828b06f7544bc03887005fbd456a09b23c64ccde685a5eecfa6859427a748c8abfca6e3529a1fc351679cdaa4b9aefa261e5a5e6ffc3dc
6
+ metadata.gz: c8025fa47c2c66853a8ae0080c2230379f35d123346669864ec2f0d899be13a59f2ff9a8af0677784a49bc1b626dbfcad6e0c4985a4f65626861b2f59f8b8fe6
7
+ data.tar.gz: 931c0a18f53b506c3f8e3d72803505ec32db08c1c070b06b0c34d8d397068d18e37f1cce34ac9f4c944e9a679962d3b4dd1a1cc142cf23c3c10b74debc3a31d0
@@ -17,6 +17,8 @@ jobs:
17
17
  gemfile: Gemfile
18
18
  - ruby: 3.4.1
19
19
  gemfile: Gemfile
20
+ - ruby: 4.0.6
21
+ gemfile: Gemfile
20
22
  env:
21
23
  BUNDLE_GEMFILE: "${{ matrix.gemfile }}"
22
24
  steps:
data/CHANGELOG.md CHANGED
@@ -4,6 +4,20 @@ All notable changes to this project will be documented here.
4
4
 
5
5
  Rascal follows semantic versioning. This has little consequence pre 1.0, so expect breaking changes.
6
6
 
7
+ ## 0.4.1 (2026-07-30)
8
+
9
+ - Recreate a service container whose network no longer exists, instead of failing
10
+ to start it.
11
+
12
+
13
+ ## 0.4.0 (2026-07-30)
14
+
15
+ - Add `rascal run ENVIRONMENT -- COMMAND`, which runs a single command in an
16
+ environment without a TTY and exits with the command's status. Useful for
17
+ scripts, git hooks and AI agents, which cannot drive `rascal shell`.
18
+ - Add `rascal environments`, which lists available environment names on stdout.
19
+
20
+
7
21
  ## 0.3.9 (2026-06-19)
8
22
 
9
23
  - Support variables within service definitions.
data/Gemfile.common CHANGED
@@ -9,6 +9,7 @@ gem 'rake', '~> 13.0'
9
9
  gem 'rspec'
10
10
  gem 'cucumber'
11
11
  gem 'aruba'
12
+ gem 'logger'
12
13
 
13
14
  gem 'guard-rspec'
14
15
  gem 'guard-cucumber'
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- rascal (0.3.9)
4
+ rascal (0.4.0)
5
5
  thor (>= 1.0.0)
6
6
 
7
7
  GEM
@@ -18,7 +18,7 @@ GEM
18
18
  builder (3.3.0)
19
19
  childprocess (3.0.0)
20
20
  coderay (1.1.3)
21
- contracts (0.17)
21
+ contracts (0.17.3)
22
22
  cucumber (9.2.1)
23
23
  builder (~> 3.2)
24
24
  cucumber-ci-environment (> 9, < 11)
@@ -67,6 +67,7 @@ GEM
67
67
  listen (3.8.0)
68
68
  rb-fsevent (~> 0.10, >= 0.10.3)
69
69
  rb-inotify (~> 0.9, >= 0.9.10)
70
+ logger (1.7.0)
70
71
  lumberjack (1.2.8)
71
72
  method_source (1.0.0)
72
73
  mini_mime (1.1.5)
@@ -108,6 +109,7 @@ DEPENDENCIES
108
109
  cucumber
109
110
  guard-cucumber
110
111
  guard-rspec
112
+ logger
111
113
  rake (~> 13.0)
112
114
  rascal!
113
115
  rspec
data/README.md CHANGED
@@ -101,6 +101,37 @@ Start a docker container (plus required services) and open an interactive shell.
101
101
  Currently requires a "bash" to exist.
102
102
 
103
103
 
104
+ #### rascal run <job> -- <command>
105
+
106
+ Run a single command in a docker container (plus required services), then exit
107
+ with the command's exit status.
108
+
109
+ ```sh
110
+ rascal run rspec -- bundle exec rspec spec/models/user_spec.rb
111
+ ```
112
+
113
+ Unlike `rascal shell`, this allocates no TTY and needs no interaction, so it can
114
+ be used from scripts, git hooks, editors and AI agents. Everything after `--` is
115
+ passed through as a list of arguments, so arguments may contain spaces (`-n 'a
116
+ test name'`). To use shell syntax such as `&&` or `cd`, wrap it explicitly:
117
+
118
+ ```sh
119
+ rascal run rspec -- bash -c 'cd subproject && bundle exec rspec'
120
+ ```
121
+
122
+ `before_shell` and `after_shell` still run around the command, but do not affect
123
+ the exit status. Note that a failing `before_shell` command (e.g. `bundle check`
124
+ in an environment that was never bundled) does not abort the run.
125
+
126
+ Currently requires a "bash" to exist.
127
+
128
+
129
+ #### rascal environments
130
+
131
+ Print the names of all available (non-hidden) environments, one per line, so that
132
+ scripts can discover them without parsing an error message.
133
+
134
+
104
135
  #### rascal clean <job> | --all [--volumes]
105
136
 
106
137
  Stop and remove all created containers, services, networks for either the given or all jobs.
@@ -0,0 +1,15 @@
1
+ require 'rascal'
2
+
3
+ module Rascal
4
+ module CLI
5
+ class Environments < Base
6
+ include IOHelper
7
+
8
+ def run
9
+ environment_definition.available_environment_names.each do |name|
10
+ say name
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end
@@ -36,6 +36,24 @@ module Rascal
36
36
  end
37
37
  end
38
38
 
39
+ desc 'environments', 'List the names of all available environments'
40
+ def environments
41
+ handle_error do
42
+ Environments.new(self, options).run
43
+ end
44
+ end
45
+
46
+ # Thor::Actions already defines #run, hence the mapping.
47
+ map 'run' => '_run'
48
+ stop_on_unknown_option! :_run
49
+ desc 'run ENVIRONMENT COMMAND', 'Run a command in the given environment and exit with its status'
50
+ def _run(environment_name = nil, *command)
51
+ handle_error do
52
+ command = command.drop(1) if command.first == '--'
53
+ Run.new(self, options, environment_name, command).run
54
+ end
55
+ end
56
+
39
57
  desc 'clean ENVIRONMENT', 'Stop and remove docker containers for the given environment'
40
58
  method_option :volumes, type: :boolean, default: false, desc: 'Remove (cache) volumes'
41
59
  method_option :all, type: :boolean, default: false, desc: 'Clean all environments'
@@ -0,0 +1,29 @@
1
+ require 'rascal'
2
+
3
+ module Rascal
4
+ module CLI
5
+ class Run < Base
6
+ def initialize(thor, options, environment_name, command)
7
+ @environment_name = environment_name
8
+ @command = command
9
+ super(thor, options)
10
+ end
11
+
12
+ def run
13
+ if (environment = find_environment(@environment_name))
14
+ fail_with_error('Missing command. Example: rascal run job -- bundle exec rake') if @command.empty?
15
+ status = environment.run_command(*@command)
16
+ exit(exit_code_for(status))
17
+ end
18
+ end
19
+
20
+ private
21
+
22
+ # A command killed by a signal has no exit status. Report it the way a
23
+ # shell would, so callers still see a non-zero status.
24
+ def exit_code_for(status)
25
+ status.exitstatus || (status.termsig ? 128 + status.termsig : 1)
26
+ end
27
+ end
28
+ end
29
+ end
data/lib/rascal/cli.rb CHANGED
@@ -2,10 +2,12 @@ require 'thor'
2
2
 
3
3
  module Rascal
4
4
  module CLI
5
- autoload :Base, 'rascal/cli/base'
6
- autoload :Clean, 'rascal/cli/clean'
7
- autoload :Main, 'rascal/cli/main'
8
- autoload :Shell, 'rascal/cli/shell'
9
- autoload :Update, 'rascal/cli/update'
5
+ autoload :Base, 'rascal/cli/base'
6
+ autoload :Clean, 'rascal/cli/clean'
7
+ autoload :Environments, 'rascal/cli/environments'
8
+ autoload :Main, 'rascal/cli/main'
9
+ autoload :Run, 'rascal/cli/run'
10
+ autoload :Shell, 'rascal/cli/shell'
11
+ autoload :Update, 'rascal/cli/update'
10
12
  end
11
13
  end
@@ -23,23 +23,35 @@ module Rascal
23
23
  end
24
24
 
25
25
  def running?
26
- if id
27
- container_info = Docker.interface.run(
28
- 'container',
29
- 'inspect',
30
- id,
31
- output: :json,
32
- ).first
33
- !!container_info.dig('State', 'Running')
34
- else
35
- false
36
- end
26
+ !!container_info&.dig('State', 'Running')
37
27
  end
38
28
 
39
29
  def exists?
40
30
  !!id
41
31
  end
42
32
 
33
+ # Docker resolves a container's network by the id it recorded when the
34
+ # container was created, and refuses to start it once that network is
35
+ # gone:
36
+ #
37
+ # failed to set up container networking: network 36243f82141a... not found
38
+ #
39
+ # `docker network prune` (and `docker system prune`) leaves exactly that
40
+ # behind, because it removes networks whose containers are all stopped —
41
+ # the normal state of an environment between runs. Reconnecting does not
42
+ # help, since docker resolves the recorded id before it looks at the
43
+ # container's current endpoints, so the container has to go. Nothing in a
44
+ # service container is worth preserving; #start creates a new one.
45
+ def remove_if_network_missing(network)
46
+ return unless exists?
47
+ attached = attached_network_ids
48
+ return if attached.empty? || attached.include?(network.id)
49
+
50
+ say "Removing container for #{@name}, its network no longer exists"
51
+ remove_container
52
+ @id = nil
53
+ end
54
+
43
55
  def start(network: nil, network_alias: nil, volumes: [], env: {}, command: [])
44
56
  say "Starting container for #{@name}"
45
57
  create(network: network, network_alias: network_alias, volumes: volumes, env: env, command: command) unless exists?
@@ -65,16 +77,14 @@ module Rascal
65
77
  )
66
78
  end
67
79
 
68
- def run_and_attach(*command, env: {}, network: nil, volumes: [], working_dir: nil, allow_failure: false)
80
+ def run_and_attach(*command, env: {}, network: nil, volumes: [], working_dir: nil, allow_failure: false, tty: true)
69
81
  Docker.interface.run_and_attach(
70
82
  'container',
71
83
  'run',
72
84
  '--rm',
73
85
  '-a', 'STDOUT',
74
86
  '-a', 'STDERR',
75
- '-a', 'STDIN',
76
- '--interactive',
77
- '--tty',
87
+ *(['-a', 'STDIN', '--interactive', '--tty'] if tty),
78
88
  *(['-w', working_dir] if working_dir),
79
89
  *(volumes.flat_map { |v| ['-v', v.to_param] }),
80
90
  *env_args(env),
@@ -125,6 +135,24 @@ module Rascal
125
135
 
126
136
  private
127
137
 
138
+ # A fresh `docker container inspect`, or nil if there is no container.
139
+ def container_info
140
+ return unless id
141
+ Docker.interface.run(
142
+ 'container',
143
+ 'inspect',
144
+ id,
145
+ output: :json,
146
+ ).first
147
+ end
148
+
149
+ # Ids of the networks the container is attached to. Empty for a container
150
+ # that was created but never started.
151
+ def attached_network_ids
152
+ networks = container_info&.dig('NetworkSettings', 'Networks') || {}
153
+ networks.each_value.filter_map { |n| n['NetworkID'] }.reject(&:empty?).uniq
154
+ end
155
+
128
156
  def image_exists?
129
157
  Docker.interface.run(
130
158
  'image',
@@ -30,6 +30,7 @@ module Rascal
30
30
  unless allow_failure || exit_status.success?
31
31
  raise Error, "docker container run failed"
32
32
  end
33
+ exit_status
33
34
  end
34
35
 
35
36
  private
@@ -39,11 +39,15 @@ module Rascal
39
39
  end
40
40
  end
41
41
 
42
+ # The full id, not the short one docker prints by default: a container
43
+ # records the network it is attached to by full id, and
44
+ # Container#remove_if_network_missing compares the two.
42
45
  def id
43
46
  @id ||= Docker.interface.run(
44
47
  'network',
45
48
  'ls',
46
49
  '--quiet',
50
+ '--no-trunc',
47
51
  '--filter', "name=^#{@prefixed_name}$",
48
52
  output: :id,
49
53
  )
@@ -1,3 +1,5 @@
1
+ require 'shellwords'
2
+
1
3
  module Rascal
2
4
  class Environment
3
5
  attr_reader :name, :network, :container, :env_variables, :services, :volumes, :working_dir, :before_shell
@@ -27,6 +29,27 @@ module Rascal
27
29
  )
28
30
  end
29
31
 
32
+ # Runs a single command in the environment, without a TTY, and returns its
33
+ # Process::Status. Unlike #run_shell this is suitable for non-interactive
34
+ # use (scripts, CI-like runs, AI agents), which need the command's real
35
+ # exit status.
36
+ #
37
+ # The command is a list of arguments, which is escaped before being handed
38
+ # to bash, so arguments may contain spaces and shell metacharacters. Use
39
+ # e.g. `bash -c "foo && bar"` to run something the shell must interpret.
40
+ def run_command(*command)
41
+ download_missing
42
+ start_services
43
+ @container.run_and_attach('bash', '-c', command_script(Shellwords.join(command)),
44
+ env: @env_variables,
45
+ network: @network,
46
+ volumes: @volumes,
47
+ working_dir: @working_dir,
48
+ allow_failure: true,
49
+ tty: false
50
+ )
51
+ end
52
+
30
53
  def clean(clean_volumes: false)
31
54
  @services.each(&:clean)
32
55
  @network.clean
@@ -42,6 +65,19 @@ module Rascal
42
65
 
43
66
  private
44
67
 
68
+ # Runs before_shell/after_shell around the given command, but exits with the
69
+ # command's status. Without saving it, an `after_shell` entry would determine
70
+ # the exit status, and failures would go unnoticed.
71
+ def command_script(command)
72
+ [
73
+ *@before_shell,
74
+ command,
75
+ '__rascal_status=$?',
76
+ *@after_shell,
77
+ 'exit $__rascal_status',
78
+ ].join(';')
79
+ end
80
+
45
81
  def download_missing
46
82
  @container.download_missing
47
83
  @services.each(&:download_missing)
@@ -17,6 +17,7 @@ module Rascal
17
17
 
18
18
  def start_if_stopped(network: nil)
19
19
  unless @container.running?
20
+ @container.remove_if_network_missing(network)
20
21
  if (container_id = @container.id)
21
22
  network.disconnect(container_id)
22
23
  end
@@ -1,3 +1,3 @@
1
1
  module Rascal
2
- VERSION = "0.3.9"
2
+ VERSION = "0.4.1"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rascal
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.9
4
+ version: 0.4.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tobias Kraze
@@ -54,7 +54,9 @@ files:
54
54
  - lib/rascal/cli.rb
55
55
  - lib/rascal/cli/base.rb
56
56
  - lib/rascal/cli/clean.rb
57
+ - lib/rascal/cli/environments.rb
57
58
  - lib/rascal/cli/main.rb
59
+ - lib/rascal/cli/run.rb
58
60
  - lib/rascal/cli/shell.rb
59
61
  - lib/rascal/cli/update.rb
60
62
  - lib/rascal/docker.rb