Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ jobs:
gemfile: Gemfile
- ruby: 3.4.1
gemfile: Gemfile
- ruby: 4.0.6
gemfile: Gemfile
env:
BUNDLE_GEMFILE: "${{ matrix.gemfile }}"
steps:
Expand Down
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,14 @@ All notable changes to this project will be documented here.

Rascal follows semantic versioning. This has little consequence pre 1.0, so expect breaking changes.

## 0.4.0 (2026-07-30)

- Add `rascal run ENVIRONMENT -- COMMAND`, which runs a single command in an
environment without a TTY and exits with the command's status. Useful for
scripts, git hooks and AI agents, which cannot drive `rascal shell`.
- Add `rascal environments`, which lists available environment names on stdout.


## 0.3.9 (2026-06-19)

- Support variables within service definitions.
Expand Down
1 change: 1 addition & 0 deletions Gemfile.common
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ gem 'rake', '~> 13.0'
gem 'rspec'
gem 'cucumber'
gem 'aruba'
gem 'logger'

gem 'guard-rspec'
gem 'guard-cucumber'
6 changes: 4 additions & 2 deletions Gemfile.lock
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
PATH
remote: .
specs:
rascal (0.3.9)
rascal (0.4.0)
thor (>= 1.0.0)

GEM
Expand All @@ -18,7 +18,7 @@ GEM
builder (3.3.0)
childprocess (3.0.0)
coderay (1.1.3)
contracts (0.17)
contracts (0.17.3)
cucumber (9.2.1)
builder (~> 3.2)
cucumber-ci-environment (> 9, < 11)
Expand Down Expand Up @@ -67,6 +67,7 @@ GEM
listen (3.8.0)
rb-fsevent (~> 0.10, >= 0.10.3)
rb-inotify (~> 0.9, >= 0.9.10)
logger (1.7.0)
lumberjack (1.2.8)
method_source (1.0.0)
mini_mime (1.1.5)
Expand Down Expand Up @@ -108,6 +109,7 @@ DEPENDENCIES
cucumber
guard-cucumber
guard-rspec
logger
rake (~> 13.0)
rascal!
rspec
Expand Down
31 changes: 31 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,37 @@ Start a docker container (plus required services) and open an interactive shell.
Currently requires a "bash" to exist.


#### rascal run <job> -- <command>

Run a single command in a docker container (plus required services), then exit
with the command's exit status.

```sh
rascal run rspec -- bundle exec rspec spec/models/user_spec.rb
```

Unlike `rascal shell`, this allocates no TTY and needs no interaction, so it can
be used from scripts, git hooks, editors and AI agents. Everything after `--` is
passed through as a list of arguments, so arguments may contain spaces (`-n 'a
test name'`). To use shell syntax such as `&&` or `cd`, wrap it explicitly:

```sh
rascal run rspec -- bash -c 'cd subproject && bundle exec rspec'
```

`before_shell` and `after_shell` still run around the command, but do not affect
the exit status. Note that a failing `before_shell` command (e.g. `bundle check`
in an environment that was never bundled) does not abort the run.

Currently requires a "bash" to exist.


#### rascal environments

Print the names of all available (non-hidden) environments, one per line, so that
scripts can discover them without parsing an error message.


#### rascal clean <job> | --all [--volumes]

Stop and remove all created containers, services, networks for either the given or all jobs.
Expand Down
31 changes: 31 additions & 0 deletions features/environments.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
Feature: Run "environments"

Scenario: List available environments, one per line
Given the following gitlab-ci config:
"""
.rascal:
jobs:
job-2:
name: renamed-job
hidden-job:
hide: true
job-1:
image: job-image:latest
job-2:
image: job-image:latest
hidden-job:
image: job-image:latest
"""

When I successfully run `rascal environments`
Then the output should contain exactly:
"""
job-1
renamed-job
"""


Scenario: Complain when there is no environment definition
When I run `rascal environments`
Then the exit status should not be 0
And stderr should contain "Could not find an environment definition"
112 changes: 112 additions & 0 deletions features/run.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
Feature: Run "run"

Scenario: Run a command in the main container, without a TTY
Given the following gitlab-ci config:
"""
job:
variables:
foo: bar
image: job-image:latest
"""
And the docker image "job-image:latest" exists

When I successfully run `rascal run job -- bundle exec rake`
Then docker /container run --rm -a STDOUT -a STDERR -w \/repo -v .*:\/repo -v rascal-aruba-job-builds:\/builds -e foo=bar --network deadbeef job-image:latest bash -c bundle exec rake;/ should have been called
And docker /--tty/ should not have been called


Scenario: Start required services
Given the following gitlab-ci config:
"""
job:
image: job-image:latest
services:
- name: service-1-image:latest
alias: service-1
"""
And the docker image "job-image:latest" exists
And the docker image "service-1-image:latest" exists

When I successfully run `rascal run job -- rake`
Then docker /container create --name rascal-aruba-job_service-1/ should have been called


Scenario: Wrap the command in before_shell and after_shell
Given the following gitlab-ci config:
"""
.rascal:
before_shell:
- bundle check
after_shell:
- echo bye
job:
image: job-image:latest
"""
And the docker image "job-image:latest" exists

When I successfully run `rascal run job -- rake`
Then docker /container run .* bash -c bundle check;rake;__rascal_status=\$\?;echo bye;exit \$__rascal_status/ should have been called


Scenario: Keep arguments containing spaces and shell metacharacters intact
Given the following gitlab-ci config:
"""
job:
image: job-image:latest
"""
And the docker image "job-image:latest" exists

When I successfully run `rascal run job -- bundle exec ruby test/foo_test.rb -n '/a test name/'`
Then docker /container run .* bash -c bundle exec ruby test\/foo_test.rb -n \/a\\ test\\ name\// should have been called


Scenario: Exit with the status of the command
Given the following gitlab-ci config:
"""
job:
image: job-image:latest
"""
And the docker image "job-image:latest" exists
And the container command will exit with 3

When I run `rascal run job -- rake`
Then the exit status should be 3


Scenario: Exit with zero when the command succeeds
Given the following gitlab-ci config:
"""
job:
image: job-image:latest
"""
And the docker image "job-image:latest" exists

When I run `rascal run job -- rake`
Then the exit status should be 0


Scenario: Complain about a missing command
Given the following gitlab-ci config:
"""
job:
image: job-image:latest
"""
And the docker image "job-image:latest" exists

When I run `rascal run job`
Then the exit status should not be 0
And stderr should contain "Missing command"
And docker /container run/ should not have been called


Scenario: Complain about an unknown environment
Given the following gitlab-ci config:
"""
job:
image: job-image:latest
"""

When I run `rascal run nope -- rake`
Then the exit status should not be 0
And stderr should contain "Unknown environment nope. Available: job."
And docker /container run/ should not have been called
21 changes: 18 additions & 3 deletions features/step_definitions/docker_steps.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@ class DockerMockInterface < Rascal::Docker::Interface
include RSpec::Mocks::ExampleMethods

attr_reader :history, :images, :containers, :volumes
attr_accessor :attached_exit_code

def initialize(*)
@attached_exit_code = 0
@history = []
@images = Hash.new { |h, k| h[k] = {} }
@containers = Hash.new { |h, k| h[k] = {} }
Expand All @@ -22,7 +24,7 @@ def flat_history

def spawn(*command)
@history << command
exit_status
exit_status(@attached_exit_code.zero?, @attached_exit_code)
end

def popen3(*command, &block)
Expand Down Expand Up @@ -68,8 +70,12 @@ def output_for(*command)
end
end

def exit_status(success = true)
instance_double(Process::Status, success?: success)
def exit_status(success = true, code = nil)
instance_double(Process::Status,
success?: success,
exitstatus: code || (success ? 0 : 1),
termsig: nil,
)
end
end

Expand All @@ -93,6 +99,15 @@ def exit_status(success = true)
expect(Rascal::Docker.interface.flat_history).not_to include("docker #{command}")
end

Then("docker {regexp} should not have been called") do |regexp|
history = Rascal::Docker.interface.flat_history.join("\n")
expect(history).not_to match(/docker #{regexp}/)
end


Given("the container command will exit with {int}") do |code|
Rascal::Docker.interface.attached_exit_code = code
end

Given("the docker image {string} exists") do |name|
Rascal::Docker.interface.images[name] = { id: Digest::SHA1.hexdigest("image-#{name}") }
Expand Down
2 changes: 1 addition & 1 deletion features/support/aruba.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
require 'aruba/cucumber'
require 'aruba/in_process'
require 'aruba/processes/in_process'
require 'rascal/cli'

# https://github.com/erikhuda/thor/wiki/Integrating-with-Aruba-In-Process-Runs
Expand Down
12 changes: 7 additions & 5 deletions lib/rascal/cli.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@

module Rascal
module CLI
autoload :Base, 'rascal/cli/base'
autoload :Clean, 'rascal/cli/clean'
autoload :Main, 'rascal/cli/main'
autoload :Shell, 'rascal/cli/shell'
autoload :Update, 'rascal/cli/update'
autoload :Base, 'rascal/cli/base'
autoload :Clean, 'rascal/cli/clean'
autoload :Environments, 'rascal/cli/environments'
autoload :Main, 'rascal/cli/main'
autoload :Run, 'rascal/cli/run'
autoload :Shell, 'rascal/cli/shell'
autoload :Update, 'rascal/cli/update'
end
end
15 changes: 15 additions & 0 deletions lib/rascal/cli/environments.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
require 'rascal'

module Rascal
module CLI
class Environments < Base
include IOHelper

def run
environment_definition.available_environment_names.each do |name|
say name
end
end
end
end
end
18 changes: 18 additions & 0 deletions lib/rascal/cli/main.rb
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,24 @@ def _shell(environment_name = nil)
end
end

desc 'environments', 'List the names of all available environments'
def environments
handle_error do
Environments.new(self, options).run
end
end

# Thor::Actions already defines #run, hence the mapping.
map 'run' => '_run'
stop_on_unknown_option! :_run
desc 'run ENVIRONMENT COMMAND', 'Run a command in the given environment and exit with its status'
def _run(environment_name = nil, *command)
handle_error do
command = command.drop(1) if command.first == '--'
Run.new(self, options, environment_name, command).run
end
end

desc 'clean ENVIRONMENT', 'Stop and remove docker containers for the given environment'
method_option :volumes, type: :boolean, default: false, desc: 'Remove (cache) volumes'
method_option :all, type: :boolean, default: false, desc: 'Clean all environments'
Expand Down
29 changes: 29 additions & 0 deletions lib/rascal/cli/run.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
require 'rascal'

module Rascal
module CLI
class Run < Base
def initialize(thor, options, environment_name, command)
@environment_name = environment_name
@command = command
super(thor, options)
end

def run
if (environment = find_environment(@environment_name))
fail_with_error('Missing command. Example: rascal run job -- bundle exec rake') if @command.empty?
status = environment.run_command(*@command)
exit(exit_code_for(status))
end
end

private

# A command killed by a signal has no exit status. Report it the way a
# shell would, so callers still see a non-zero status.
def exit_code_for(status)
status.exitstatus || (status.termsig ? 128 + status.termsig : 1)
end
end
end
end
Loading
Loading