A Little Crystal
tl;dr I port a script from Ruby to Crystal with minimal fuss.
I ran into a problem: I needed to run an operation that updated some secrets in Hashicorp’s Vault, and although I ostensibly had the requisite permissions, I was getting permission-denied errors.
I don’t want to get too bogged down in why,
mostly because I’m not certain,
but I think it comes down to path specificity.
One of my policies grants rights over all the things with a *
glob;
the other has much more restricted rights over some specific paths.
Vault’s documentation seems to confirm this:
Policy paths are matched using the most specific path match. This may be an exact match or the longest-prefix match of a glob. This means if you define a policy for"secret/foo*"
, the policy would also match"secret/foobar"
.
My organization is probably doing something wrong with Vault policies,
but since Vault is managed by another department,
I figured I’d just hack around the problem.
Vault provides the ability to make your own tokens,
restricted to the policies you’ve already got.
That is,
if I’ve only got access to the pleb
policy,
I can’t create a token that has the caesar
policy.
What I wanted is a wrapper around Vault’s CLI,
which would let me write a command like this:
vulcan desired_policy rest of command
That is,
rest of command
would get executed in an environment where a desired_policy
token was available.
(I’m calling it vulcan
’cause I mashed together Vault and token and got voken,
which kind of sounds like Vulcan?)
I whipped up a Ruby script.
#!/usr/bin/env ruby
require "json"
require "open3"
args = ARGV.dup
policy = args.shift
out, err, code = Open3.capture3 *%W[vault token create -policy #{policy} -ttl 24h -format json] ❶
unless code.success?
warn err
exit code.exitstatus
end
token = JSON(out).dig("auth", "client_token") rescue nil
exit 1 unless token
at_exit do
`vault token revoke #{token}` ❷
end
system({ "VAULT_TOKEN" => token }, *args) ❸
exit $?.exitstatus
At ❶,
I simply invoke the Vault CLI to create a token with the first argument to vulcan
as the policy.
If there’s a problem creating the token,
I dump the contents of stderr and exit.
At ❷,
I set up an at_exit
hook to revoke the token.
At ❸,
I invoke the remaining arguments to vulcan
as the command,
with the generated token set to the VAULT_TOKEN
environment variable,
and exit with the same status as the invoked command.
Mostly for kicks, I decided to port the script to Crystal. Not entirely for kicks, mind you: I liked the idea of having a standalone binary. I have a lot of Ruby versions on my system, so I can’t be sure about what version will run in every environment. I’m already avoiding anything that’s not in Ruby’s standard library, but who knows when I’ll want to use some language feature that’s not available in older versions or Ruby? (And for what value of “older”?) But it was mostly for kicks, because Crystal is super cool: a statically typed language with very Ruby-like syntax that requires very little effort from the programmer in terms of explicit typing. Once I built the executable, I could dump it in my path and forget about it. Here’s the Crystal version.
require "json"
args = ARGV.dup
policy = args.shift
output = IO::Memory.new ❶
error = IO::Memory.new
result = Process.run("vault", ["token", "create", "-policy", policy, "-ttl", "24h", "-format", "json"], output: output, error: error) ❷
unless result.success?
STDERR.puts(error)
exit result.exit_code
end
token = JSON.parse(output.to_s ❸).dig("auth", "client_token") rescue nil
exit 1 unless token
at_exit do
Process.run("vault", ["token", "revoke", token.to_s ❹])
end
command = args.shift
result = Process.run(command, args, env: { "VAULT_TOKEN" => token.to_s }, shell: true, output: STDOUT, error: STDERR) ❺
exit result.exit_code ❻
I copied and pasted the Ruby, then tweaked it until it worked; it’s amazing how straightforward it was. Here’s a rundown of some of the more interesting bits.
-
❶ I cargo-culted these
IO::Memory
objects from Stack Overflow. Crystal’s docs on the subject describe them as buffers in memory, which makes sense given the name. 😉 They do what I need ’em to do, which is collect whatever gets printed to them so’s I can use it for my own ends later. -
❷
Process
is Crystal’s version of Ruby’sOpen3
library. It proved a bit tricky to debug, mostly because I ran into compiler issues that told me there was no overload for how I was trying to callProcess.run
. I’ll detail one of those cases in ❹ below. -
❸ This is a case where I had to help Crystal’s type checker a bit.
In vanilla Ruby,
I was able to simply pass this through and it would do the right thing
(maybe because I already had a String).
Here,
I had to explicitly cast the
IO::Memory
buffer to a String with#to_s
. -
❹ Ended up being the trickiest part of the whole port,
because,
as mentioned in ❷,
I got overload errors on
Process.run
. I chalk this up to me being an utter Crystal noob. It took me a while to figure out thattoken
was not actually a string yet. Parsing JSON in statically typed languages is crazy. and what I actually had intoken
could have been anything. The type checker once again just needed a little prompting. -
❺ Just another invocation of
Process.run
, with one twist: theshell
parameter causes the command and its arguments in a shell invocation. -
❻ Weird portability note:
In Ruby,
exitstatus
did what I wanted. In Crystal, though there is an analogousexit_status
method,exit_code
was what I wanted here. There’s likely some Unix esoterica I’m not aware of here.
This was a fun experiment. As someone who’s already quite familiar with Ruby, it was a breeze getting up and running on Crystal. Though there were a couple places where I needed to cast some things to strings, I didn’t actually have to explicitly type anything. The Crystal language authors have done some amazing work in terms of developer ergonomics! I’ll probably look for some more excuses to get Crystal into my workflow.
Questions? Comments? Contact me!
Tools Used
- Crystal
- 0.32.1
- Ruby
- 2.6.3p62