Recurse SP2'23 #7: Starting Protohackers

Sadly, after writing about blogging daily in #5, day #6 was mostly consumed by some health stuff. So there is no post #6.

As for today:

Smoke Test

This is problem #0 of Protohackers, which is just meant to get you up and running. This was kind of my side project for the day in addition to data modeling research.

Getting Cute with io.Copy

The smoke test was kind of fun to solve, in the sense that writing my own test client was more work than creating the server. In fact, the example for net.Listener is an acceptable solution to the problem!

Go’s networking and concurrency support don’t hurt, but the part that really makes it trivial is Go’s io.Copy(src, dst) function. Consider this function to handle a specific client’s connection:

func handle(conn net.Conn) {
	defer conn.Close()
	_, err := io.Copy(conn, conn)
	if err != nil {
		log.Printf("Error: %s", err)
	}
}

This works because io.Copy(a, b) either does a.WriteTo(b) or b.ReadFrom(a), depending on which method is available. In this case, conn is a net.TCPConn, which implements both the net.Conn interface and the ReadFrom method of the io.ReaderFrom interface. So, it can be passed as both inputs to io.Copy. I hope whoever came up with this example was at least as pleased with this result as I am.

Deploying with doctl

Protohackers works by giving you a challenge, which you solve, and then testing your server - which you need to host on a public IP address. My ISP doesn’t give me one, so I wanted a really fast and simple deployment. I’m solving the problems in Go, and I want deploying my code to be almost as simple as compiling it.

I’ve played with Ansible and Terraform for personal projects in the past, and I’ve used a lot of Salt and Docker at work. But I wanted to try something simple for relatively one-off projects like this that didn’t force me into using AWS. Digital Ocean’s doctl CLI delivered!

This was a good excuse to brush back up on my shell scripting, and the result generalizes to any time you want to go from “my program compiles” to “my program is listening on a public IP address” in short order. Let’s take a quick tour through the script, available in full here.

First, we create a new Droplet (what Digital Ocean calls a server) and get its id. Since Protohackers is hosted in London, we might as well deploy close by!

#!/bin/bash

NAME=protohackers-0
TARGET=smoketest
KEY=~/.ssh/id_rsa_do
# Just use existing droplet if it wasn't cleaned up somehow
# (presumably by exiting too early)
DROPLET=$(doctl compute droplet get $NAME --format=ID --no-header 2>/dev/null)
if [ -z "$DROPLET" ]; then
	if ! DROPLET=$(doctl compute droplet create \
		--region lon1 \
		--image debian-11-x64 \
		--size s-1vcpu-1gb \
		--ssh-keys 71:0b:6e:82:97:18:ef:cb:fc:27:85:ca:ce:14:bc:c3 \
		$NAME \
		--format=ID \
		--no-header); then
		echo "Couldn't create droplet ${NAME}. Exiting"
		exit 1
	fi
	echo "Created droplet ${NAME} with ID ${DROPLET}"
else
	echo "Found droplet ${NAME} with ID ${DROPLET}"
fi

Once the droplet exists, we ensure that it will be deleted if a SIGINT (Ctrl+C) is sent. This includes the case where we successfully run the server over SSH: if we didn’t catch SIGINT, it would kill the whole script without cleaning up.

# If we quit after this, delete the droplet.
# We don't want to catch Ctrl+C prior.
handle_interrupt() {
	echo "Tearing down droplet ${NAME} with ID ${DROPLET}"
	doctl compute droplet delete -f $DROPLET
}
trap handle_interrupt SIGINT

We also use a couple of simple tricks here: looping until the new droplet has an IP address, and checking once a second to see if port 22 (SSH) is accepting TCP connections.

echo "Waiting for droplet IP"
IP=$(doctl compute droplet get $DROPLET --format='PublicIPv4' --no-header 2>/dev/null)
while [ -z "$IP" ]; do
	sleep 5
	IP=$(doctl compute droplet get $DROPLET --format='PublicIPv4' --no-header)
done

echo "IP: ${IP}"

echo "Waiting for SSH up"
if ! nc -z $IP 22 ; then
	sleep 1
fi
# Can still have conn refused for a moment
sleep 2

Finally, we just compile our code, scp the binary to the new droplet, and kick off the server.

go build -o "$TARGET" main.go

echo "Copying binary"
# accept-new since we're just gonna TOFU the server's key
scp -i "$KEY" \
	-o StrictHostKeyChecking=accept-new \
	"./${TARGET}" "root@${IP}:/root/"


echo "Running binary. Ctrl+C to exit and clean up."
ssh -i "$KEY" "root@${IP}" "/root/${TARGET}"

And that’s it!

Obviously, this isn’t perfect. It’s not nearly as easy to extend as a Terraform config would be, it doesn’t ensure my server restarts if it goes down for some reason, etc. But that’s okay, since I specifically want to just run a server until I’m done testing my code, then tear it all down. This feels like a low overhead solution that should see me through the rest of the challenges.