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.

  1. ❶ 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.
  2. Process is Crystal’s version of Ruby’s Open3 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 call Process.run. I’ll detail one of those cases in ❹ below.
  3. ❸ 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.
  4. ❹ 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 that token was not actually a string yet. Parsing JSON in statically typed languages is crazy. and what I actually had in token could have been anything. The type checker once again just needed a little prompting.
  5. ❺ Just another invocation of Process.run, with one twist: the shell parameter causes the command and its arguments in a shell invocation.
  6. ❻ Weird portability note: In Ruby, exitstatus did what I wanted. In Crystal, though there is an analogous exit_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