CatOps: Functions Framework, Cloud Tasks, and my cat

Nick Opening the Door

I have a relatively simple problem, my cat, Nick, can open our front door and let himself out. Since he’s a strictly indoor cat, this is a problem. In true computer nerd fashion, I way over-engineered a solution to lock the doors automatically after a specified delay to prevent Nick from escaping. My solution involves the Ruby Functions Framework, a container, Google Cloud Run, and Google Cloud Tasks.

While this post is about CatOps, the solution is much more generalizable. If you want to call a webhook after a delay for a chatbot, send an email, or remind folks that they have items in their cart they haven’t purchased, you could do something similar to this CatOps project.

Architecture

The hardware and triggers side of this is project is a Rube Goldberg of smart home applications and IFTTT. But the essential thing to know is that my doors have a simple HTTP interface. They send an HTTP request when unlocked and will lock if they receive a specific HTTP request.

The interesting part of this project was writing some code that I could call when the door was unlocked, which would then lock it 10 minutes later. It sounds simple, but I ran into many challenges.

First, I didn’t want to set up a server or build a Rails or Sinatra app. I knew that this should only be a few lines of code, and that felt like overkill. I also wanted to use Functions as a Service, which is advertised as ideal for simple scripts. Google’s FaaS solution doesn’t support Ruby, but Google just released the Ruby Functions Framework. The framework allows you to build and run functions anywhere you can run a container. I had a few choices for where to run my container, but I was curious about Cloud Run, and it promised to be simple to set up.

The second issue I ran into was the 10-minute delay between opening the door and locking the door. My first attempt was to use sleep(600), but both IFTTT and the webhook interface for the door timed out waiting for a response. So I was forced to do something more elegant.

Cloud Tasks was the right solution for the time out problem for several reasons. First, the HTTP Target task type sends an HTTP request when the task executes. I set the HTTP target to the webhook URL, which saved me from needing to writing code to call the webhook myself. Second, Cloud Tasks lets you schedule a task to execute at a specific time, so I could schedule my lock request to run exactly 10 minutes after the door was unlocked.

The Code

The Ruby Functions Framework is available as a gem, and you can include it in your project in the standard way using Bundler and the Gemfile.

To write a function, you pass your code as a block to the http class method of the FunctionsFramework class.

1
2
3
4
5
6
7
require "functions_framework"

FunctionsFramework.http("my_function_name") do |request|

  Do Stuff Here

end

Cloud Tasks requires some setup on the server-side, which is explained well in the docs, so I won’t recreate it here. Once the initial setup is complete, creating tasks is straight forward. You create a hash that has the appropriate keys and call the create_task method.

1
2
3
4
5
6
7
8
9
10
11
require "google/cloud/tasks"

tasks_client = Google::Cloud::Tasks.new
PARENT = tasks_client.queue_path(PROJECT, COMPUTE_REGION, QUEUE_NAME)

task = {http_request: {http_method: "POST"}}
task[:schedule_time] = {seconds: (Time.now() + 600).to_i}
task[:http_request] = {url: URL_GOES_HERE}

response = tasks_client.create_task(PARENT, task)

The last thing I needed to do was add some configuration. I needed to know if the front door or the back door triggered the function, so I knew which door to lock after the delay. I set up an IFTTT trigger to send a parameter called “door” that could be either “front” or “back”. Since the request object is an instance of Rack::Request, you can access the parameters as usual, through the params method. Lastly, I extracted the URL that locks the doors into an environment variable for flexibility and privacy reasons.

Below is the body of the function. To see the setup, you can look at the file on GitHub.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
FunctionsFramework.http("lock_door") do |request|
  task = {http_request: {http_method: "POST"}}

  door = request.params["door"]

  if door == "back" then
    task[:schedule_time] = {seconds: (Time.now() + DELAY_BACK).to_i}
    task[:http_request] = {url: BACKDOOR}
  elsif door == "front" then
    task[:schedule_time] = {seconds: (Time.now() + DELAY_FRONT).to_i}
    task[:http_request] = {url: FRONTDOOR}
  end

  begin
    response = tasks_client.create_task(PARENT, task)
  rescue Exception => e
    FunctionsFramework.logger.error "Exception creating task"
  end

  FunctionsFramework.logger.info "Created task #{response.name}"
  "Created task #{response.name}"
end

Testing

The Functions Framework gem comes with testing support that I was able to use. Since my code also uses Cloud Tasks, I needed to create a test double for those calls to prevent my tests from hitting my production task queue.

Here’s the test double I created for Cloud Tasks Client.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
Response = Struct.new(:name) { }

class TasksClientStub
  attr_accessor :task_history, :project, :location, :queue

  def initialize
    @task_history = Hash.new { |h, k| h[k] = [] }
  end

  def create_task parent, task
    @task_history[parent] << task

    Response.new("/#{task_history.length}")
  end

  def queue_path project, location, queue
    @project = project
    @location = location
    @queue = queue

    "projects/#{project}/locations/#{location}/queues/#{queue}"
  end
end

The double includes the methods I call in my function, initialize, create_task, and queue_path. Internally it represents task queues as a hash of arrays, so I can see the tasks that my function enqueues and verify they are correct. I also created a struct called response as a test double for Rack::Response because I don’t need all the methods and fields in Rack.

With the doubles created, I could write my tests. My code has separate branches for the front and back doors, so I wrote a test for each. Here’s the test for the front door. The test for the back door case is similar.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
require "functions_framework/testing"

def test_function_creates_correct_task_for_front_door
  task_stub = TasksClientStub.new

  Google::Cloud::Tasks.stub :new, task_stub do

    load_temporary "locker.rb" do
      request = make_post_request "http://example.com:8080/", "door=front"

      response = nil

      _out, err = capture_subprocess_io do
        response = call_http "lock_door", request
      end

      assert_equal 200, response.status

      parent = task_stub.task_history.keys.first
      assert_equal "projects/thagomizer-home-automation/locations/us-central1/queues/door-locker", parent

      task = task_stub.task_history[parent].first

      assert_match /front/, task[:http_request][:url]
    end
  end
end

On line 6, I use Minitest’s stub method to make the function use the test double. Then on line 8, I load the function file. Inside the block, I use the function framework helper method make_post_request to build up a post request that includes the door=front parameter my function requires. To call the function, you use the call_http helper from the function framework testing package and capture_subprocess_io. Finally, the assertions verify that the function returned successfully, verify that the queue/parent is set correctly, and ultimately ensure that the task object created was correct for a front door request.

Deployment and Conclusions

The next post in this series will explain how to do the initial deployment to Google Cloud Run. It will also show you how to set up basic CI / CD on Cloud Build for any Ruby App. But if you are super excited to move forward, you can do an initial manual deploy by following the Readme for the Functions Framework.

I want to close this post by being clear that the entire premise of this post is ridiculous. A much more straightforward, and likely cheaper, solution to having a problematic cat would have been to buy new doorknobs that he can’t open. But, I took joy in the pure ridiculousness of this scenario. I got to learn some new technologies in context too. I usually find that when I’m solving a problem I care about, I learn much better than if I’m copying and pasting code from a tutorial. Even if my particular use case is ridiculous, the underlying scenario of one event triggering a count down to another event is something I’ve run into over and over. There are a lot of serious business problems that can be solved using code similar to my CatOps project.