Advent of Code 2020 - Day 2

AoC 2020 Day 2

Day 2 of the Advent of Code evaluates a string against a rule for validity.

Reading the input

read_input(File) ->
    {'ok', Lines} = file:read_file(File),
    [line_to_pw(Line) || Line <- binary:split(Lines, <<"\n">>, ['global']), Line =/= <<>>].

line_to_pw(Line) ->
    {'match', [Min, Max, Char, PW]} = re:run(Line, <<"(\\d+)-(\\d+)\s+(\\w):\s+(.+)$">>, [{'capture', 'all_but_first', 'binary'}]),
    {binary_to_integer(Min, 10), binary_to_integer(Max, 10), Char, PW}.

Here we utilize a regular expression to capture the Min and Max numbers (\d+ - 1 or more digits), the character the limits apply to (\w - just one character), and the password to validate ((.+) everything after the space until the end of the string).

Evaluating passwords

main(_) ->
    PasswordDB = read_input("p2.txt"),
    Valid = lists:foldl(fun count_valid_pw/2, 0, PasswordDB),
    io:format('user', "valid: ~p~n", [Valid]).

count_valid_pw({Min, Max, Char, PW}, ValidCount) ->
    case is_valid_pw(Min, Max, Char, PW) of
	'true' -> ValidCount + 1;
	'false' -> ValidCount
    end.

is_valid_pw(Min, Max, Char, PW) ->
    Count = length(binary:split(PW, Char, ['global']))-1,
    Count >= Min andalso Count =< Max.

After loading the passwords and rules into PasswordDB, we fold over the list and count only those passwords that are valid.

To validate the password, I chose to split the password on the Char value, get the length of the list and decrement by one. For instance:

binary:split(<<"aaa">>, <<"a">>, ['global']).
[<<>>,<<>>,<<>>,<<>>]
Count = 3

binary:split(<<"cdefg">>, <<"b">>, ['global']).
[<<"cdefg">>]
Count = 0

binary:split(<<"ccccccccc">>, <<"c">>, ['global']).
[<<>>,<<>>,<<>>,<<>>,<<>>,<<>>,<<>>,<<>>,<<>>,<<>>]
Count = 9

Then it is a simple check on Min <= Count <= Max.

Part 2

The big change for part 2 is the is_valid_pw/4 function:

is_valid_pw(FirstPos, SecondPos, Char, PW) ->
    case {char_at(FirstPos, PW), char_at(SecondPos, PW)} of
	{Char, Char} -> 'false';
	{Char, _} -> 'true';
	{_, Char} -> 'true';
	{_, _} -> 'false'
    end.

char_at(Pos, PW) ->
    erlang:binary_part(PW, Pos-1, 1).

Min and Max are re-interpreted as FirstPosition and SecondPosition. We get the character at each position and compare to Char. I explicitly show the truth table via pattern matching on Char but it is effectively an xor operation. See for example:

33> (<<"c">> =:= <<"c">>) xor (<<"c">> =:= <<"c">>).
false
34> (<<"b">> =:= <<"c">>) xor (<<"c">> =:= <<"c">>).
true
35> (<<"c">> =:= <<"c">>) xor (<<"c">> =:= <<"b">>).
true
36> (<<"b">> =:= <<"c">>) xor (<<"c">> =:= <<"b">>).
false

Wrap-up

Not much trouble on this one. Getting the regexp right made the rest mostly took care of itself. Execution time for both parts remains ~0.180s.

Advent of Code 2020 - Day 1

AoC 2020 Day 1

Day 1 of the Advent of Code brings us a search problem - find two entries whose sum is 2020 and answer with the product.

Reading the input file

I like to get the data in from the file and into a workable internal format. For this problem its pretty simple - a list of integers will suffice.

read_input(File) ->
    {'ok', Lines} = file:read_file(File),
    [binary_to_integer(Line, 10) || Line <- binary:split(Lines, <<"\n">>, ['global']), Line =/= <<>>].

Were the test file larger, file:read_line/1 could be used instead; as-is, let's read the whole file into a binary.

Next, I used binary:split/3 to break the binary into a list of binaries broken on the end line. Sometimes files have an extra end line so filter any Line that is an empty binary.

Since we know each Line should contain an integer, use binary_to_integer(Line, 10) to explicitly convert from a base-10 encoded integer (vs <<"1F">> which would necessitate using 16 instead).

Processing the list

Now that we have a list of integers, we need to find the two entries whose sum is 2020. Naively, we can take the head of the list and iterate over the tail of the list looking for the winning sum.

main(_) ->
    [H|Input] = read_input("p1.txt"),
    {X, Y} = find_2020(Input, H),
    io:format("answer: ~p~n", [X*Y]).

find_2020(Input, H) ->
    case foldl(Input, H) of
	{X, Y} -> {X, Y};
	'undefined' -> find_2020(tl(Input), hd(Input))
    end.

foldl([], _) -> 'undefined';
foldl([Y|_], X) when X+Y =:= 2020 -> {X, Y};
foldl([_|T], X) -> foldl(T, X).

I decided to manually roll a fold that terminates early when the match is found.

First we pop the head of the list ([H|Input] = read_input(...)). Next we fold over the tail of the list (Input) looking for an item in the list that sums to 2020 with our H. If we exhaust the list (first clause of foldl/2) we return the atom undefined and recursively call find2020/2 with the head and tail of the list.

To get an idea of what this looks like in practice, suppose a list of [1, 2, 3]; find_2020/2 would evaluate like:

[1 | [2, 3]] = read_input(...),
find_2020([2, 3], 1).

find_2020([2, 3], 1) ->
  foldl([2, 3], 1).

foldl([2 | [3]], 1) -> foldl([3], 1).
foldl([3 | []], 1) -> foldl([], 1).
foldl([], 1) -> 'undefined'.

find_2020([3], 2).

foldl([3 | []], 2) -> foldl([], 2).
foldl([], 2) -> 'undefined'.

find_2020([], 3).

foldl([], 3) -> 'undefined'.

This effectively searches every combination until the solution is found. Solutions of O(n^2) complexity are typically not what one might aspire to use when solving problems, but in this case, with a 200-line input file, we're only looking at 40,000 comparisons (at most).

Run time of the script is real 0m0.177s user 0m0.198s sys 0m0.043s.

Part 2

Part 2 extends part 1 to find 3 entries that sum to 2020 instead.

Reading the input doesn't change in this one so let's look at the search:

main(_) ->
    Entries = read_input("p1.txt"),
    {X, Y, Z} = find_triple(Entries),
    io:format("answer: ~p: ~p~n", [{X, Y, Z}, X * Y * Z]).

find_triple([X | Entries]) ->
    case find_double(2020-X, Entries) of
	'undefined' -> find_triple(Entries);
	{Y, Z} -> {X, Y, Z}
    end.

find_double(_Total, []) -> 'undefined';
find_double(Total, _) when Total < 0 -> 'undefined';
find_double(Total, [Y | Entries]) when Y >= Total ->
    find_double(Total, Entries);
find_double(Total, [Y | Entries]) ->
    case [Z || Z <- Entries, Y+Z =:= Total] of
	[] -> find_double(Total, Entries);
	[Z] -> {Y, Z}
    end.

Similar in concept to part 1, we decompose the problem. Given the head of the list, X, search the tail of the list for two entries that sum to (2020 - X). If none is found, recurse into find_triple/1 with the tail of the list.

Within find_double/2 we take the head of the list, Y, and iterate through the tail of the list looking for a Z that will satisfy Y + Z = 2020 - X. If none is found, recurse using the tail of the list.

We're pushing into O(n^3) territory which still only puts us around 8,000,000 comparisons which is pretty trivial for CPUs. Indeed, run time is indistinguishable from part 1: real 0m0.175s user 0m0.179s sys 0m0.067s.

Wrap up

Day 1 whet the whistle. Took slightly different approaches to iteration and recursion in each part for fun. Dataset is small enough to not be overly concerned with efficiency of approach, instead focusing on (hopefully) solving the problem in an easy-to-read way.

Advent Of Code 2020

I'll be participating, slowly, in the Advent Of Code and writing about how I used Erlang to solve the problems.

The goal of the code is, hopefully, to be easy to read and reason about.

Not-goals include being the shortest, fastest, or "cleverest". Nothing about the code is "best"; it will represent whatever chunk of time I'm able to offer it.

Subsequent posts will cover a particular day's two-part challenge. Both parts will be their own escripts. As with past years, as the problems get harder and build on previous work, I'll introduce some compiled modules and direct the escript to use them as well.

Posts will follow some basic thought patterns I have, the "final" code, and an annotated walkthrough of the code.

I hope it will be more signal than noise for you!

Problem Writeups

Why the Confederate flag should not be actively flown

From an AP US History Teacher: If you are confused as to why so many Americans are defending the confederate flag, monuments, and statues right now, I put together a quick Q&A, with questions from a hypothetical person with misconceptions and answers from my perspective as an AP U.S. History Teacher:

Q: What did the Confederacy stand for?

A: Rather than interpreting, let's go directly to the words of the Confederacy's Vice President, Alexander Stephens. In his "Cornerstone Speech" on March 21, 1861, he stated "The Constitution… rested upon the equality of races. This was an error. Our new government is founded upon exactly the opposite idea; its foundations are laid, its corner-stone rests, upon the great truth that the negro is not equal to the white man; that slavery subordination to the superior race is his natural and normal condition. This, our new government, is the first, in the history of the world, based upon this great physical, philosophical, and moral truth."

Q: But people keep saying heritage, not hate! They think the purpose of the flags and monuments are to honor confederate soldiers, right?

A: The vast majority of confederate flags flying over government buildings in the south were first put up in the 1960's during the Civil Rights Movement. So for the first hundred years after the Civil War ended, while relatives of those who fought in it were still alive, the confederate flag wasn't much of a symbol at all. But when Martin Luther King, Jr. and John Lewis were marching on Washington to get the Civil Rights Act (1964) and Voting Rights Act (1965) passed, leaders in the south felt compelled to fly confederate flags and put up monuments to honor people who had no living family members and had fought in a war that ended a century ago. Their purpose in doing this was to exhibit their displeasure with black people fighting for basic human rights that were guaranteed to them in the 14th and 15th Amendments but being withheld by racist policies and practices.

Q: But if we take down confederate statues and monuments, how will we teach about and remember the past?

A: Monuments and statues pose little educational relevance, whereas museums, the rightful place for Confederate paraphernalia, can provide more educational opportunities for citizens to learn about our country's history. The Civil War is important to learn about, and will always loom large in social studies curriculum. Removing monuments from public places and putting them in museums also allows us to avoid celebrating and honoring people who believed that tens of millions of black Americans should be legal property.

Q: But my uncle posted a meme that said the Civil War/Confederacy was about state's rights and not slavery?

A: "A state's right to what?" - John Green

Q: Everyone is offended about everything these days. Should we take everything down that offends anyone?

A: The Confederacy literally existed to go against the Constitution, the Declaration of Independence, and the idea that black people are human beings that deserve to live freely. If that doesn't upset or offend you, you are un-American.

Q: Taking these down goes against the First Amendment and freedom of speech, right?

A: No. Anyone can do whatever they want on their private property, on their social media, etc. Taking these down in public, or having private corporations like NASCAR ban them on their properties, has literally nothing to do with the Bill of Rights.

Q: How can people claim to be patriotic while supporting a flag that stood for a group of insurgent failures who tried to permanently destroy America and killed 300,000 Americans in the process?

A: No clue.

Q: So if I made a confederate flag my profile picture, or put a confederate bumper sticker on my car, what am I declaring to my friends, family, and the world?

A: That you support the Confederacy. To recap, the Confederacy stands for: slavery, white supremacy, treason, failure, and a desire to permanently destroy Selective history as it supports white supremacy.

It’s no accident that:

  • You learned about Helen Keller instead of W.E.B, DuBois
  • You learned about the Watts and L.A. Riots, but not Tulsa or Wilmington.
  • You learned that George Washington’s dentures were made from wood, rather than the teeth from slaves.
  • You learned about black ghettos, but not about Black Wall Street.
  • You learned about the New Deal, but not “red lining.”
  • You learned about Tommie Smith’s fist in the air at the 1968 Olympics, but not that he was sent home the next day and stripped of his medals.
  • You learned about “black crime,” but white criminals were never lumped together and discussed in terms of their race.
  • You learned about “states rights” as the cause of the Civil War, but not that slavery was mentioned 80 times in the articles of secession.

Privilege is having history rewritten so that you don’t have to acknowledge uncomfortable facts.

Racism is perpetuated by people who refuse to learn or acknowledge this reality.

You have a choice. - Jim Golden

Setting up call forwarding in KAZOO

With the transition to working from home in full swing, let's look at the ways KAZOO makes it easy to bring your office with you!

From Scratch

Let's say you are a solo business owner who needs to forward your business phone calls to your personal mobile phone. Assuming the business phone number is provisioned for the account already, here's the basic steps to set that up!

Setup the callflow

A callflow in KAZOO is what is executed when a configured phone number, extension, or regexp matches the incoming call. The flow portion will then be a series of actions (like ringing devices, going to voicemail, sending to the carrier, etc) chained together to control how the caller progresses through the call.

Since you want to forward calls from your business number to your mobile, you'll need to use the resources action. The callflow data object would look like:

{
  "flow": {
    "data": {
      "to_did": "{MOBILE_NUMBER}"
    },
    "module": "resources"
  },
  "name": "forward business to mobile",
  "numbers": [
    "{BUSINESS_NUMBER}"
  ]
}

Simply stated, the resource action's to_did will override the normal action to call the to number with to_did instead. The power here is that the same callflow can be used for many numbers (say multiple businesses you might accept calls for).

The basic API call then becomes:

curl -H "X-Auth-Token: {API_AUTH_TOKEN}" \
     -X PUT \
     -d '{"data":{"name":"forward business to mobile","numbers":["{BUSINESS_NUMBER}"],"flow":{"module":"resources","data":{"to_did":"{MOBILE_NUMBER}"}}}}'
     https://api.kazoo.domain/v2/accounts/{ACCOUNT_ID}/callflows

Voicemail considerations

With calls forwarded to the carriers in the manner above, your personal voicemail will answer the call if you are unable to at the time. If this is not desirable, you will need to slightly modify how you make this happen.

What you need is to require the callee (you) to press 1 to accept the call before KAZOO will let connect the caller to your mobile phone. If your personal voicemail tries to pick up the call, KAZOO will not continue and will return to the callflow and try the child of the resources action. Since there is no child, the call will be hung up - not ideal!

To setup the required keypress, you'll need to create a call-forwarded device.

Call-Forwarded Device

KAZOO devices can be configured to represent anything from SIP devices to call-forwarded devices, WebRTC, and other types. The other nice benefit is that you can assign the device, via the owner_id property, to your KAZOO user and get calls to ring any of your devices!

To create the call-forwarded device, you only need to setup the call_forward object:

{
  "call_forward": {
    "enabled": true,
    "number": "{MOBILE_NUMBER}",
    "require_keypress": true
  },
  "name": "WFH device",
  "owner_id": "{USER_ID}"
}

Not strictly required to include owner_id (especially if this is a temporary setup until the office re-opens).

The API command then becomes:

curl -H "X-Auth-Token: {API_AUTH_TOKEN}" \
     -X PUT \
     -d '{"data":{"name":"WFH device","call_forward":{"enabled":true,"number":"{MOBILE_NUMBER}","require_keypress":true},"owner_id":"{USER_ID}"}}'
     https://api.kazoo.domain/v2/accounts/{ACCOUNT_ID}/devices

The callflow action is then changed to:

"flow":{
  "module":"device"
  ,"data":{"id":"{WFH_DEVICE_ID}"}
}

Now when your personal voicemail picks up, KAZOO will not connect the caller and will instead go to the child (which currently is missing!). Let's add your business voicemail box.

Call-forwarding plus Voicemail

If you haven't already created a KAZOO voicemail box its a straight-forward process similar to device and callflow creation via API.

Once you have the voicemail box ID you can modify the callflow's flow to look like:

{
  "flow": {
    "children": {
      "_": {
	"data": {
	  "id": "{VOICEMAIL_BOX_ID}"
	},
	"module": "voicemail"
      }
    },
    "data": {
      "id": "{WFH_DEVICE_ID}"
    },
    "module": "device"
  }
}

Of course now that you have KAZOO voicemail, you can enable voicemail-to-email, transcription (if your cluster supports it), auto-delete from the voicemail box after successfully sending the email, and more.

Configure using the UI

You can also use two of 2600Hz's UI apps, SmartPBX or Advanced Callflows, to achieve the same configurations as the direct API examples above.

Take a walk-through of SmartPBX to see more or jump to the call-forwarding setup.

Customize routing decisions

Sometimes KAZOO callflow actions can't quite encode the flow you would like to achieve, or KAZOO doesn't have access to data sources needed to make routing decisions. Fortunately KAZOO has Pivot to help you take control of call routing when you need more dynamic handling.

The callflow is simple:

{
  "flow": {
    "children": {
      "_": {
	"data": {
	  "id": "{WFH_CALLFLOW_ID}"
	},
	"module": "callflow"
      }
    },
    "data": {
      "method": "POST",
      "req_format": "kazoo",
      "voice_url": "https://your.http.server/pivot/whatever/language/you/like.ext"
    },
    "module": "pivot"
  },
  "name": "dynamic call control",
  "numbers": [
    "{BUSINESS_NUMBER}"
  ]
}

Now any call to {BUSINESS_NUMBER} will issue an HTTP request to your.http.server. If your server fails to respond, the callflow will continue and run the callflow from above (you would need to remove the {BUSINESS_NUMBER} from the numbers array on that callflow so they don't conflict with the Pivot callflow here).

Your server, in your language of choice, will process the request data, consult any data sources, make any business logic decisions, then send a response with the appropriate callflow 'flow' object. For instance, to have callflow mirror the routing to the call-forwarded device, your PHP script might look like:

<?php
header('content-type: application/json');

/* Business logic
 * Database lookups
 * Whatever shenanigans
 */
?>
{
  "module":"device"
  ,"data":{"id":"{WFH_DEVICE_ID}"}
}

Wrap-up

In this post we've walked through what API calls and JSON objects would be needed to setup call-forwarding of a phone number (like the business' phone number) to another phone number (like the owner's personal mobile number). Depending on what voicemail box you'd like to answer if the callee is unavailable will determine which path, device or resource, you use to route the caller.

We also briefly introduced the idea of using Pivot to encode custom logic when routing callers to the call-forwarded device (and provide a child action if the Pivot request fails for any reason).

The building blocks exposed in KAZOO's callflow actions are many (~75 at last count) and run the gamut from low-level - collecting touch tones (DTMF) or playing text (TTS) to the caller, or time of day routing - up to more conceptual actions like leaving and checking voicemails, ringing a user's devices, user directories, and so much more.

Have questions? Join our forum, hop on IRC (Freenode #2600hz) to chat, and start an all-in-one install to test things out.

If you have a business and want to engage on using our hosted platform or other offerings, we at 2600Hz are ready to chat (sales@2600hz.com) and help you build it!

Sponsor Me!

Recently learned that I was accepted to Github's beta sponsorship program which allows me to define sponsorship tiers!

https://github.com/sponsors/jamesaimonetti

My hope is that folks that have appreciated my work with KAZOO and help in the forums, documentation, and IRC, might throw a bone or two of thanks my way. If there's a better tier that would get you on board, let me know.

Logging emacs-slack conversations to file

At work, we've been coerced for a few years now to use Slack. At first it wasn't too bad; Slack provided an IRC gateway so irssi remained viable. Then in 2018 they shut down the gateways in the name of "security".

Enter emacs-slack!

As part of my personal initiative to move more of my computing life into Emacs, the IRC gateway closure actually provided me the necessary kick in the pants to move chat from irssi to Emacs.

I'm a simple user and emacs-slack has just worked. I have a few elisp customizations pulled from various sites but for the most part have left emacs-slack to the defaults.

The only thing I missed from irssi was logged chats to my local disk.

Logging chats to disk

My feature was simple - per-team, per-channel, dated files. Basically appending each message received to log/{TEAM}/{CHANNEL}/{YYYY-MM-DD}.log.

I took inspiration from this issue to create a custom message handler (stored in slack-message-custom-notifier) that would log the incoming message to disk:

(setq slack-log-directory (concat (expand-file-name slack-file-dir)))

(defun mc_/handle-message (message room team)
  (let* ((team-name (slack-team-name team))
	 (room-name (slack-room-name room team))
	 (text (slack-message-to-string message team)))
    (mc_/chat-log team-name room-name text)))

(defun mc_/chat-log (team-name room-name text)
  "Write to log/{TEAM}/{ROOM}/{DATE}.log"
  (let* ((dir slack-log-directory)
	 (today (format-time-string "%Y-%m-%d"))
	 (filename (format "%s%s/%s/%s.log" dir team-name room-name today)))
    (when (not (file-exists-p filename))
      (make-directory (file-name-directory filename) t)
      (write-region "" nil filename)
      )
    (write-region (concat (format-time-string "%H:%M:%S") ": " text "\n") nil filename 'append)))

(setq slack-message-custom-notifier #'mc_/handle-message)

I've posted to /r/emacs with the above to solicit feedback. Hopefully this isn't too far off the mark though! Updates if/when the code is revised.

Thank You Joe

On April 20th, Francesco let the world know that Joe Armstrong had passed.

The tributes and stories have been flowing steadily since:

On various social media:

Having spoken several times at Erlang Factory / CodeBEAM, I had the pleasure of being in Joe's proximity many times, and chatted with him and my buddy Mark Diaz at one after-party. A recurring feature of stories around Joe was his laughter and exuberance and I was glad to be witness to it many times.

My Erlang story

I started learning about Erlang around 2006 after reading an article Joe had written comparing Apache (which I was very familiar with) and YAWS, an Erlang HTTP server. Various modes of Apache fell over around 10K concurrent connections while YAWS happily continued until 70K or so. Intrigued, I started down the rabbit hole of learning about Erlang, distributed programming, functional programming, and something just…clicked. It felt right to my mind.

I ordered Joe's book and started down the path. I started building a backend for some custom software to help manage an insurance agency, started learning about AMQP and RabbitMQ, CouchDB, and some other Erlang-based code, and started connecting a lot of dots from my computer science degree to a language born in industry. The best part, though, was rekindling the fun of programming again.

Lucky me, in 2010 a job posting went up on Craigslist with Erlang, RabbitMQ, and CouchDB listed in the nice-to-haves, for a startup telecom company. I knew nothing about VoIP or telecom really (other than having read some books about the 70s-90s-era phreakers like the Masters of Deception). Lucky for 2600Hz, they didn't know Erlang other than they wanted to use it but knew telecom.

We took a chance on each other and here we are, 9+ years later, all because Joe wrote a blog article a decade prior about a small experiment he had run. Butterfly wings and hurricanes!

So thank you Joe, for unknowingly being the catalyst of a career transformation, and helping me find my home in the Erlang community and at 2600Hz.

Stateless Property-based testing with Erlang and PropEr

In preparation for my talk at CodeBEAM, I thought I'd give an introductory example of some stateless property-based tests as an introduction to property-based testing.

Fine reading and viewing options

First things first, get yourself a copy of the Property-Based Testing book.

Second, there are a number of blog posts and videos online that talk about property-based testing that are far better resources than I would write. In no particular order:

  1. An introduction to property-based testing (using F#) - I like the approach of an adversarial relationship between the code writer and the test writer. How can the test writer ensure the code writer is properly implementing a function without cheating on the implementation just to get tests to pass?
  2. Code checking automation - John Hughes breaks down testing some SMS processing code using Quickcheck and finding an inconsistency in the spec.
  3. Don't write tests! - John Hughes talks about why testing is hard and how property-based testing can make it easier (and maybe more fun!).
  4. Testing web services with QuickCheck - Using QuickCheck (and stateful property testing) to test web services.
  5. The Hitchhiker's Guide to the Unexpected - Fred Hebert talks about using property testing to test his supervisor hierarchy and how it reacts to failure (see here specifically).

Basically any video where John Hughes or Thomas Arts talk QuickCheck or property-based testing is worth a watch!

PropEr

We use the open source, QuickCheck-inspired, testing tool PropEr to develop our tests. The homepage for the project has links to tutorials and other helpful information specifically related to using PropEr.

The Code

Briefly, we want to test the higher-level properties of a function instead of explicitly enumerating the inputs and outputs we expect.

Let us consider the function camel_to_snake/1 which takes a Camel case string (such as ThisIsCamelCase) and converts it to Snake case (such as this_is_snake_case).

camel_to_snake(<<First, Bin/binary>>) ->
    iolist_to_binary([to_lower_char(First)
		     ,[maybe_camel_to_snake(Char) || <<Char>> <= Bin]
		     ]).

maybe_camel_to_snake(Char) ->
    case is_upper_char(Char) of
	'false' -> Char;
	'true' ->
	    [$_, to_lower_char(Char)]
    end.

is_upper_char(Char) ->
    Char >= $A andalso Char =< $Z.

to_lower_char(Char) when is_integer(Char), $A =< Char, Char =< $Z -> Char + 32;
to_lower_char(Char) -> Char.

Pretty simple implementation - if the character is uppercase, convert it to an underscore and the lowercase version of the character.

Now, if we were doing unit tests alone, we might write:

camel_to_snake_test_() ->
    Tests = [{<<"Test">>, <<"test">>}
	    ,{<<"TestKey">>, <<"test_key">>}
	    ,{<<"testKey">>, <<"test_key">>}
	    ,{<<"TestKeySetting">>, <<"test_key_setting">>}
	    ,{<<"testKeySetting">>, <<"test_key_setting">>}
	    ,{<<"TEST">>, <<"t_e_s_t">>}
	    ],
    [?_assertEqual(To, camel_to_snake(From))
     || {From, To} <- Tests
    ].

And for a relatively trivial function like this, we're reasonably confident those tests are adequate:

make eunit
...
  module camel_tests'
    camel_tests:40: camel_to_snake_test_...ok
    camel_tests:40: camel_to_snake_test_...ok
    camel_tests:40: camel_to_snake_test_...ok
    camel_tests:40: camel_to_snake_test_...ok
    camel_tests:40: camel_to_snake_test_...ok
    camel_tests:40: camel_to_snake_test_...ok
    [done in 0.018 s]
...

Let's see if we can get some more coverage of the input space with property tests!

Properties to test

We have a pretty basic property here - take a string and convert uppercase characters to _{lowercase}.

So how can we generate inputs and outputs that will help test the conversion?

Well, we can generate the CamelCase string character by character and build the snakecase version as we go.

First, the high level property test:

prop_morph() ->
    ?FORALL({Camel, Snake}
	   ,camel_and_snake()
	   ,?WHENFAIL(io:format("~nfailed to morph '~s' to '~s'~n", [Camel, Snake])
		     ,Snake =:= camel_to_snake(Camel)
		     )
	   ).

So `camelandsnake/0` is a generator that will return a 2-tuple with the built strings, will compare the call to `cameltosnake/1` against the generated Snake, and if the property fails (evals to false), we output the failing pair for analysis.

camel_and_snake() ->
    camel_and_snake(30). % we don't want overly huge strings so cap them to 30 characters for now

camel_and_snake(Length) ->
    ?LET(CamelAndSnake
	,camel_and_snake(Length, [])
	,begin
	     %% we create a list of [{CamelChar, SnakeChar}]; unzipping results in {CamelChars, SnakeChars} as iolist() data
	     {Camel, Snake} = lists:unzip(lists:reverse(CamelAndSnake)),
	     {list_to_binary(Camel), list_to_binary(Snake)}
	 end
	).

%% Create a list of [{CamelChar, SnakeChar}]
camel_and_snake(0, CamelAndSnake) ->
    CamelAndSnake;
camel_and_snake(Length, CamelAndSnake) ->
    CandS = oneof([upper_char()
		  ,lower_char()
		  ]),

    camel_and_snake(Length-1
		   ,[CandS | CamelAndSnake]
		   ).

%% Lower chars are easy - whatever the camel gets the snake gets too
lower_char() ->
    ?LET(Lower
	 ,choose($a,$z)
	 ,{Lower, Lower}
	).

%% Uppercase just requires a little math
upper_char() ->
    ?LET(Upper
	,choose($A,$Z)
	,{Upper, [$_, Upper+32]}
	).

Running it the first time we see:

(prop_morph)......!
Failed: After 4 test(s).
{<<70,74,101,101,81,90,81,72,112,117,84,74,106,100,100,67,90,98,98,122,80,107,81,77,99,79,113,82,84,99>>,<<95,102,95,106,101,101,95,113,95,122,95,113,95,104,112,117,95,116,95,106,106,100,100,95,99,95,122,98,98,122,95,112,107,95,113,95,109,99,95,111,113,95,114,95,116,99>>}

failed to morph 'FJeeQZQHpuTJjddCZbbzPkQMcOqRTc' to '_f_jee_q_z_q_hpu_t_jjdd_c_zbbz_pk_q_mc_oq_r_tc'

Shrinking ............................................(44 time(s))
{<<65,65,65,65,65,65,65,65,65,65,65,65,65,65,65,65,65,65,65,65,65,65,65,65,65,65,65,65,65,65>>,<<95,97,95,97,95,97,95,97,95,97,95,97,95,97,95,97,95,97,95,97,95,97,95,97,95,97,95,97,95,97,95,97,95,97,95,97,95,97,95,97,95,97,95,97,95,97,95,97,95,97,95,97,95,97,95,97,95,97,95,97>>}

failed to morph 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' to '_a_a_a_a_a_a_a_a_a_a_a_a_a_a_a_a_a_a_a_a_a_a_a_a_a_a_a_a_a_a'

Ah, right, if the first character is uppercase, we don't want to introduce the underscore. Let's fix the generator to account for the first character situation:

%% First, we need to choose a different start
camel_and_snake(0, CamelAndSnake) ->
    CamelAndSnake;
camel_and_snake(Length, []) ->
    CandS = oneof([first_upper_char()
		  ,lower_char()
		  ]),
    camel_and_snake(Length-1, [CandS]);
camel_and_snake(Length, CamelAndSnake) ->
    CandS = oneof([upper_char()
		  ,lower_char()
		  ]),

    camel_and_snake(Length-1
		   ,[CandS | CamelAndSnake]
		   ).

%% We define first_upper_char to not include the underscore for the snake version
upper_char() ->
    ?LET(Upper
	,choose($A,$Z)
	,{Upper, [Upper+32]}
	).

Running this through PropEr gives us:

proper_test_ (prop_morph) .......................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................
OK: Passed 500 test(s).

Ship it!

Not quite ready

One of the joys of KAZOO is getting to learn about how telecom works in the wider world. This forces Unicode front and center when dealing with user input (on the plus side, we can use emojis for phone extensions!).

Looking at our code, we see we've naively handled just the Latin-based alphabet. For inspiration, we open up string.erl to see how it handles upper/lowercase conversions:

%% ISO/IEC 8859-1 (latin1) letters are converted, others are ignored
%%

to_lower_char(C) when is_integer(C), $A =< C, C =< $Z ->
    C + 32;
to_lower_char(C) when is_integer(C), 16#C0 =< C, C =< 16#D6 ->
    C + 32;
to_lower_char(C) when is_integer(C), 16#D8 =< C, C =< 16#DE ->
    C + 32;
to_lower_char(C) ->
    C.

to_upper_char(C) when is_integer(C), $a =< C, C =< $z ->
    C - 32;
to_upper_char(C) when is_integer(C), 16#E0 =< C, C =< 16#F6 ->
    C - 32;
to_upper_char(C) when is_integer(C), 16#F8 =< C, C =< 16#FE ->
    C - 32;
to_upper_char(C) ->
    C.

Let's adjust our generators first to see failing cases:

lower_char() ->
    ?LET(Lower
	,union([choose($a,$z)
	       ,choose(16#E0,16#F6)
	       ,choose(16#F8,16#FE)
	       ])
	,{Lower, [Lower]}
	).

first_upper_char() ->
    ?LET(Upper
	,union([choose($A,$Z)
	       ,choose(16#C0,16#D6)
	       ,choose(16#D8,16#DE)
	       ])
	,{Upper, [Upper+32]}
	).

upper_char() ->
    ?LET(Upper
	,union([choose($A,$Z)
	       ,choose(16#C0,16#D6)
	       ,choose(16#D8,16#DE)
	       ])
	,{Upper, [$_, Upper+32]}
	).

Running this, we get some nice failures:

 proper_test_ (prop_morph)...!
Failed: After 1 test(s).
{<<222,240,240,198,253,220,75,212,233,76,248,110,77,83,229,99,195,88,216,250,246,67,227,237,103,240,217,253,220,221>>,<<254,240,240,95,230,253,95,252,95,107,95,244,233,95,108,248,110,95,109,95,115,229,99,95,227,95,120,95,248,250,246,95,99,227,237,103,240,95,249,253,95,252,95,253>>}
failed to morph 'ÞððÆýÜKÔéLønMSåcÃXØúöCãígðÙýÜÝ' to 'þðð_æý_ü_k_ôé_løn_m_såc_ã_x_øúö_cãígð_ùý_ü_ý'

Shrinking ..............................................................(62 time(s))
{<<192,65,65,65,65,65,65,65,65,65,65,65,65,65,65,65,65,65,65,65,65,65,65,65,65,65,65,65,65,65>>,<<224,95,97,95,97,95,97,95,97,95,97,95,97,95,97,95,97,95,97,95,97,95,97,95,97,95,97,95,97,95,97,95,97,95,97,95,97,95,97,95,97,95,97,95,97,95,97,95,97,95,97,95,97,95,97,95,97,95,97>>}
failed to morph 'ÀAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' to 'à_a_a_a_a_a_a_a_a_a_a_a_a_a_a_a_a_a_a_a_a_a_a_a_a_a_a_a_a_a'

You can also see that, because we wanted 30-character strings, PropEr generates a shrunk version that is still 30 characters long. Let's inform PropEr that we want to constrain the length of the strings but be a little more flexible so PropEr can generate better failing test cases:

prop_morph() ->
    ?FORALL({Camel, Snake}
	   ,resize(20, camel_and_snake())
	   ,camel_and_snake()
	   ,?WHENFAIL(io:format('user', "~nfailed to morph '~s' to '~s'~n", [Camel, Snake])
		     ,Snake =:= camel_to_snake(Camel)
		     )
	   ).

camel_and_snake() ->
    ?SIZED(Length, camel_and_snake(Length)).

You can read more about resize/2 and the ?SIZED macro but basically they let PropEr know to constrain the size a bit but with more flexibility than a static length.

Running the tests now:

proper_test_ (prop_morph)...!
Failed: After 1 test(s).
{<<220>>,<<252>>}

failed to morph 'Ü' to 'ü'

Shrinking ..(2 time(s))
{<<192>>,<<224>>}

failed to morph 'À' to 'à'

Much easier!

Let's adjust our implementation to account for these upper/lower bounds:

is_upper_char(Char) ->
    (Char >= $A andalso Char =< $Z)
	orelse (16#C0 =< Char andalso Char =< 16#D6)
	orelse (16#D8 =< Char andalso Char =< 16#DE).

to_lower_char(Char) when is_integer(Char), $A =< Char, Char =< $Z -> Char + 32;
to_lower_char(Char) when is_integer(Char), 16#C0 =< Char, Char =< 16#D6 ->
    Char + 32;
to_lower_char(Char) when is_integer(Char), 16#D8 =< Char, Char =< 16#DE ->
    Char + 32;
to_lower_char(Char) -> Char.

And the tests now pass nicely:

 proper_test_ (prop_morph) .......................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................
OK: Passed 500 test(s).

Honing the skill

Thinking in properties is not necessarily intuitive at first. There are many choices for what properties to choose to test, so pick and choose which apply to the particulars of the code you're testing. Another helpful thing is to keep the properties as simple as possible at first. As you build confidence in the generators and property tests, you can layer on more complex properties to ensure the tests are encapsulating the properties of your code.

As you progress down the road of property testing, you will hopefully find that you force yourself to think more deeply about your code and hopefully head off issues before the tests locate them. A great exercise is to pair with a teammate, have one write the implementation and one write the property tests, and compete to see if the tester can find bugs in the implementation.

Running Kazoo VMs with KVM/QEMU

I've been running Kazoo in CentOS VMs to replicate production setups to solve support tickets. But getting my laptop talking to the VMs running on my dev server was non-obvious to me. I've finally got it working for an all-in-one Kazoo server and learned a little bit too.

The setup

I have three systems to connect:

Computer Network IP
My laptop (or any computer on my LAN really) 192.168.1.5
My dev server (beefy blade) 192.168.1.10
My Kazoo VM (using libvirt) 192.168.122.21

So the dev server running libvirt defines a default network of 192.168.122.0/24 and assigns IP addresses to the VMs from that pool.

By default, the dev server (the host) can talk to the VMs on the subnet and the VMs can talk to each other but computers on my LAN cannot talk to the VM subnet.

What to do?

Setup the laptop

The dev server knows how to route to the VMs so we need the laptop to send packets destined for the VM to the dev server. This is actually straight-forward to accomplish by adding a route to the kernel routing table:

First, check out the routing table:

sudo ip route show
default via 192.168.1.1 dev eth0 proto dhcp metric 100
192.168.1.0/24 dev eth0 proto kernel scope link src 192.168.1.5 metric 100

Now add the new route for the VM subnet IPs to route to the dev server:

sudo ip route add 192.168.122.0/24 via 192.168.1.10 dev eth0

Verify the routes:

sudo ip route show
default via 192.168.1.1 dev eth0 proto dhcp metric 100
192.168.1.0/24 dev eth0 proto kernel scope link src 192.168.1.5 metric 100
192.168.122.0/24 via 192.168.1.10 dev eth0

Setup the dev server

The main issue on the dev server was the iptables wasn't setup to accept NEW connections to the subnet.

iptables -L FORWARD
...
ACCEPT     all  --  anywhere             192.168.122.0/24     ctstate RELATED,ESTABLISHED

Just RELATED and ESTABLISHED.

I needed to add NEW to that list:

sudo iptables -I FORWARD -m state -d 192.168.122.0/24 --state NEW,RELATED,ESTABLISHED -j ACCEPT

You may also need to modify the POSTROUTING to masquerade:

iptables -t nat -A POSTROUTING -j MASQUERADE -o eth0

Setup the VM

On the VM side all I really did was turn of firewalld:

systemctl stop firewalld
systemctl disable firewalld

No plans to open this to the greater Internet so for now this is acceptable to me. :)

Testing

Now that the three servers should be communicating properly, let's take a look. On each server, start a tcpdump:

sudo tcpdump -vv port 8000
tcpdump: listening on eth0, link-type EN10MB (Ethernet), capture size 262144 bytes

In this case, look for all traffic on port 8000 (sending or receiving) as that is Kazoo's default API port.

Now, from the laptop, query the base API URL:

laptop$ curl -v http://192.168.122.21:8000

In the various TCP dumps you should see something like:

# Laptop
11:06:24.242471 IP (tos 0x0, ttl 64, id 21252, offset 0, flags [DF], proto TCP (6), length 60)
    laptop.49560 > VM.8000: Flags [S], cksum 0xfcf3 (incorrect -> 0x04e3), seq 1995735410, win 29200, options [mss 1460,sackOK,TS val 3284285253 ecr 0,nop,wscale 7], length 0
11:06:24.243341 IP (tos 0x0, ttl 63, id 0, offset 0, flags [DF], proto TCP (6), length 60)
    VM.8000 > laptop.49560: Flags [S.], cksum 0x73bf (correct), seq 2588808479, ack 1995735411, win 28960, options [mss 1460,sackOK,TS val 83877269 ecr 3284285253,nop,wscale 7], length 0
11:06:24.243385 IP (tos 0x0, ttl 64, id 21253, offset 0, flags [DF], proto TCP (6), length 52)
    laptop.49560 > VM.8000: Flags [.], cksum 0xfceb (incorrect -> 0x12c6), seq 1, ack 1, win 229, options [nop,nop,TS val 3284285254 ecr 83877269], length 0
11:06:24.243452 IP (tos 0x0, ttl 64, id 21254, offset 0, flags [DF], proto TCP (6), length 135)
    laptop.49560 > VM.8000: Flags [P.], cksum 0xfd3e (incorrect -> 0xe0bf), seq 1:84, ack 1, win 229, options [nop,nop,TS val 3284285254 ecr 83877269], length 83
11:06:24.244169 IP (tos 0x0, ttl 63, id 51382, offset 0, flags [DF], proto TCP (6), length 52)
    VM.8000 > laptop.49560: Flags [.], cksum 0x1274 (correct), seq 1, ack 84, win 227, options [nop,nop,TS val 83877270 ecr 3284285254], length 0
11:06:24.445887 IP (tos 0x0, ttl 63, id 51383, offset 0, flags [DF], proto TCP (6), length 2798)
    VM.8000 > laptop.49560: Flags [P.], cksum 0x07a6 (incorrect -> 0x7cb2), seq 1:2747, ack 84, win 227, options [nop,nop,TS val 83877472 ecr 3284285254], length 2746
11:06:24.445911 IP (tos 0x0, ttl 64, id 21255, offset 0, flags [DF], proto TCP (6), length 52)
    laptop.49560 > VM.8000: Flags [.], cksum 0xfceb (incorrect -> 0x05f9), seq 84, ack 2747, win 272, options [nop,nop,TS val 3284285456 ecr 83877472], length 0
11:06:24.446377 IP (tos 0x0, ttl 64, id 21256, offset 0, flags [DF], proto TCP (6), length 52)
    laptop.49560 > VM.8000: Flags [F.], cksum 0xfceb (incorrect -> 0x05f7), seq 84, ack 2747, win 272, options [nop,nop,TS val 3284285457 ecr 83877472], length 0
11:06:24.447064 IP (tos 0x0, ttl 63, id 51385, offset 0, flags [DF], proto TCP (6), length 52)
    VM.8000 > laptop.49560: Flags [F.], cksum 0x0622 (correct), seq 2747, ack 85, win 227, options [nop,nop,TS val 83877473 ecr 3284285457], length 0
11:06:24.447090 IP (tos 0x0, ttl 64, id 21257, offset 0, flags [DF], proto TCP (6), length 52)
    laptop.49560 > VM.8000: Flags [.], cksum 0xfceb (incorrect -> 0x05f4), seq 85, ack 2748, win 272, options [nop,nop,TS val 3284285458 ecr 83877473], length 0

# Dev machine (host)
    laptop.49560 > 192.168.122.21.8000: Flags [S], cksum 0x04e3 (correct), seq 1995735410, win 29200, options [mss 1460,sackOK,TS val 3284285253 ecr 0,nop,wscale 7], length 0
18:06:24.240212 IP (tos 0x0, ttl 63, id 0, offset 0, flags [DF], proto TCP (6), length 60)
    192.168.122.21.8000 > laptop.49560: Flags [S.], cksum 0xfcf3 (incorrect -> 0x73bf), seq 2588808479, ack 1995735411, win 28960, options [mss 1460,sackOK,TS val 83877269 ecr 3284285253,nop,wscale 7], length 0
18:06:24.240672 IP (tos 0x0, ttl 64, id 21253, offset 0, flags [DF], proto TCP (6), length 52)
    laptop.49560 > 192.168.122.21.8000: Flags [.], cksum 0x12c6 (correct), seq 1, ack 1, win 229, options [nop,nop,TS val 3284285254 ecr 83877269], length 0
18:06:24.240702 IP (tos 0x0, ttl 64, id 21254, offset 0, flags [DF], proto TCP (6), length 135)
    laptop.49560 > 192.168.122.21.8000: Flags [P.], cksum 0xe0bf (correct), seq 1:84, ack 1, win 229, options [nop,nop,TS val 3284285254 ecr 83877269], length 83
18:06:24.241046 IP (tos 0x0, ttl 63, id 51382, offset 0, flags [DF], proto TCP (6), length 52)
    192.168.122.21.8000 > laptop.49560: Flags [.], cksum 0xfceb (incorrect -> 0x1274), seq 1, ack 84, win 227, options [nop,nop,TS val 83877270 ecr 3284285254], length 0
18:06:24.442439 IP (tos 0x0, ttl 63, id 51383, offset 0, flags [DF], proto TCP (6), length 2798)
    192.168.122.21.8000 > laptop.49560: Flags [P.], cksum 0x07a6 (incorrect -> 0x7cb2), seq 1:2747, ack 84, win 227, options [nop,nop,TS val 83877472 ecr 3284285254], length 2746
18:06:24.443185 IP (tos 0x0, ttl 64, id 21255, offset 0, flags [DF], proto TCP (6), length 52)
    laptop.49560 > 192.168.122.21.8000: Flags [.], cksum 0x05f9 (correct), seq 84, ack 2747, win 272, options [nop,nop,TS val 3284285456 ecr 83877472], length 0
18:06:24.443660 IP (tos 0x0, ttl 64, id 21256, offset 0, flags [DF], proto TCP (6), length 52)
    laptop.49560 > 192.168.122.21.8000: Flags [F.], cksum 0x05f7 (correct), seq 84, ack 2747, win 272, options [nop,nop,TS val 3284285457 ecr 83877472], length 0
18:06:24.443935 IP (tos 0x0, ttl 63, id 51385, offset 0, flags [DF], proto TCP (6), length 52)
    192.168.122.21.8000 > laptop.49560: Flags [F.], cksum 0xfceb (incorrect -> 0x0622), seq 2747, ack 85, win 227, options [nop,nop,TS val 83877473 ecr 3284285457], length 0
18:06:24.444356 IP (tos 0x0, ttl 64, id 21257, offset 0, flags [DF], proto TCP (6), length 52)
    laptop.49560 > 192.168.122.21.8000: Flags [.], cksum 0x05f4 (correct), seq 85, ack 2748, win 272, options [nop,nop,TS val 3284285458 ecr 83877473], length 0

# VM
18:06:24.243655 IP (tos 0x0, ttl 63, id 21252, offset 0, flags [DF], proto TCP (6), length 60)
    laptop.49560 > vm.8000: Flags [S], cksum 0x04e3 (correct), seq 1995735410, win 29200, options [mss 1460,sackOK,TS val 3284285253 ecr 0,nop,wscale 7], length 0
18:06:24.243713 IP (tos 0x0, ttl 64, id 0, offset 0, flags [DF], proto TCP (6), length 60)
    vm.8000 > laptop.49560: Flags [S.], cksum 0xfcf3 (incorrect -> 0x73bf), seq 2588808479, ack 1995735411, win 28960, options [mss 1460,sackOK,TS val 83877269 ecr 3284285253,nop,wscale 7], length 0
18:06:24.244510 IP (tos 0x0, ttl 63, id 21253, offset 0, flags [DF], proto TCP (6), length 52)
    laptop.49560 > vm.8000: Flags [.], cksum 0x12c6 (correct), seq 1, ack 1, win 229, options [nop,nop,TS val 3284285254 ecr 83877269], length 0
18:06:24.244543 IP (tos 0x0, ttl 63, id 21254, offset 0, flags [DF], proto TCP (6), length 135)
    laptop.49560 > vm.8000: Flags [P.], cksum 0xe0bf (correct), seq 1:84, ack 1, win 229, options [nop,nop,TS val 3284285254 ecr 83877269], length 83
18:06:24.244555 IP (tos 0x0, ttl 64, id 51382, offset 0, flags [DF], proto TCP (6), length 52)
    vm.8000 > laptop.49560: Flags [.], cksum 0xfceb (incorrect -> 0x1274), seq 1, ack 84, win 227, options [nop,nop,TS val 83877270 ecr 3284285254], length 0
18:06:24.445989 IP (tos 0x0, ttl 64, id 51383, offset 0, flags [DF], proto TCP (6), length 2798)
    vm.8000 > laptop.49560: Flags [P.], cksum 0x07a6 (incorrect -> 0x7cb2), seq 1:2747, ack 84, win 227, options [nop,nop,TS val 83877472 ecr 3284285254], length 2746
18:06:24.446930 IP (tos 0x0, ttl 63, id 21255, offset 0, flags [DF], proto TCP (6), length 52)
    laptop.49560 > vm.8000: Flags [.], cksum 0x05f9 (correct), seq 84, ack 2747, win 272, options [nop,nop,TS val 3284285456 ecr 83877472], length 0
18:06:24.447336 IP (tos 0x0, ttl 63, id 21256, offset 0, flags [DF], proto TCP (6), length 52)
    laptop.49560 > vm.8000: Flags [F.], cksum 0x05f7 (correct), seq 84, ack 2747, win 272, options [nop,nop,TS val 3284285457 ecr 83877472], length 0
18:06:24.447502 IP (tos 0x0, ttl 64, id 51385, offset 0, flags [DF], proto TCP (6), length 52)
    vm.8000 > laptop.49560: Flags [F.], cksum 0xfceb (incorrect -> 0x0622), seq 2747, ack 85, win 227, options [nop,nop,TS val 83877473 ecr 3284285457], length 0
18:06:24.448032 IP (tos 0x0, ttl 63, id 21257, offset 0, flags [DF], proto TCP (6), length 52)
    laptop.49560 > vm.8000: Flags [.], cksum 0x05f4 (correct), seq 85, ack 2748, win 272, options [nop,nop,TS val 3284285458 ecr 83877473], length 0

Continuing

Instead of having to edit the routing table on every device on the network to forward packets to the dev machine, I setup my router to do it for me. Consult your router's manual for how to add static routes, then add something along the lines of:

Network/HostIP 192.168.122.0 Subnet
Netmask 255.255.255.0  
Gateway 192.168.1.10 Dev Server IP
Metric 0  
Interface LAN  

Naming may vary but hopefully it is clear enough to get you started.

Next Steps

  • Setup a cluster of Kazoo VMs with multiple zones and see how it goes
  • Orchestrate it all with…?