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:
- I revisited the scraping project with Nikki and Tom. We had a good call to discuss data modeling, which gets interesting when your data comes from an unreliable or hard-to-parse source.
- I wrote some Go to “solve” protohackers #0. My solutions repo is here.
- I worked on deploying protohackers solutions with
doctl
.
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.