diff --git a/presentation/chessh.org b/presentation/chessh.org index 3e2f247..ff13cfd 100644 --- a/presentation/chessh.org +++ b/presentation/chessh.org @@ -1,5 +1,6 @@ #+TITLE: Practicing Elixir by Building Concurrent, Distributed, Multiplayer Games in the Terminal #+AUTHOR: Lizzy Hunt (Simponic) +#+STARTUP: fold inlineimages * Reminder: linux.usu.edu This meeting should be being streamed live at [[https://linux.usu.edu/streams]]. @@ -21,14 +22,11 @@ CheSSH is a multiplayer distributed game of chess over SSH - let's take a quick [[https://chessh.linux.usu.edu]] -* Elixir - Functional Programming & Meta-Programming -Elixir is a self-proclaimed "dynamic, functional language for building scalable and maintainable applications". Obviously, one of Elixir's -main selling points is that of its functional paradigm - it's the second in the list. - -We'll take a quick look at some features of Elixir, and find that functional programming brings a lot to the table. +* Elixir - What You Need +Elixir is a self-proclaimed "dynamic, functional language for building scalable and maintainable applications". ** Basic Data Types -1. ~int~s, ~bool~s, ~string~s are all here +1. ~int~'s, ~bool~'s, ~string~'s are all here + ~1~, ~true~, ~"Hello"~ 2. Atoms: prefixed with ":" are named constants whose name is their value, similar to symbols in LISP + ~:x~, ~:three~ @@ -40,11 +38,165 @@ We'll take a quick look at some features of Elixir, and find that functional pro + ~{1,2,3}~, ~{1, {2, 3}}~ ** Pattern Matching -The match operator "=" does not mean its convential meaning of assignment, but instead an assertion of equivalence. +The match operator "=" does not mean its convential meaning of assignment, but instead an assertion of equivalence. This gives way to a unique +feature of Elixir - pattern matching (similar to that found in Rust's ~match~ or Scala's ~case~). -This gives way to one of Elixir's unique features of pattern matching similar to that found in Rust's ~match~ or Scala's ~case~. +With pattern matching we can access data from complex structures declaratively. +For example: +#+BEGIN_SRC elixir + [head | tail] = [1,2,3] + %{a: a_value} = %{a: 10} + {:ok, result} = {:ok, 2} + [head, tail, a_value, result] +#+END_SRC +And will raise an exception when the pattern cannot match: + +#+BEGIN_SRC elixir + %{a: a_value} = %{b: 10} +#+END_SRC + +*** Error Handling +Functions that can error will typically return a two-tuple, the first element of which is either the atom ~:ok~ or ~:error~, and the second is the +error info or value. + +For many scenarios, the fact that a failed pattern match raises an exception is enough information to know we shouldn't execute further. +#+BEGIN_SRC elixir + defmodule Sequences do + def fib(n) when n < 0, do: {:error, :too_small} + def fib(n) when n <= 1, do: {:ok, n} + def fib(n) when n > 1 do + {:ok, n1} = fib(n-1) + {:ok, n2} = fib(n-2) + + {:ok, n1 + n2} + end + end + + {:ok, f10} = Sequences.fib(10) + {:ok, fn1} = Sequences.fib(-1) + + IO.puts(fn1) +#+END_SRC + +But sometimes we do want to capture that error information! In this case, we use ~case~! + +#+BEGIN_SRC elixir + case Sequences.fib(-1) do + {:ok, val} -> val + {:error, err} -> + IO.puts("Ran into :error #{inspect(err)}") + 0 + end +#+END_SRC + +** Piping +Elixir's pipe operator ~|>~ allows programmers to easily write statements as a composition of functions. It simply takes the value of the +function on the left, and passes it as the first argument to the function on the right. + +For example, to find the length of the longest string in a list of strings: +#+BEGIN_SRC elixir + ["Hello, world", "Another string", "Where are all these strings coming from"] + |> Enum.map(&String.length/1) + |> Enum.max() +#+END_SRC + +** Meta-programming +Akin to my favorite language of all time, LISP, Elixir provides a way to interact directly with code as data (and thus the AST) via a powerful macro system. + +However, they are not as elegant, and for that reason, Chris McCord suggests in his book "Metaprogramming Elixir": + +#+BEGIN_QUOTE +Rule 1 : Don't Write Macros +#+END_QUOTE + +The main reasoning is that it becomes difficult to debug, and hides too much from the user. These are fine trade-offs when you're working alone. + +*** when-prime the functional way +#+BEGIN_SRC elixir + defmodule Prime do + def is_prime(2), do: true + def is_prime(n) when rem(n, 2) == 0 or n <= 1, do: false + def is_prime(n) do + is_prime_helper(n, 3) + end + + defp is_prime_helper(n, i) when i * i > n, do: true + defp is_prime_helper(n, i) when rem(n, i) == 0, do: false + defp is_prime_helper(n, i) do + is_prime_helper(n, i + 2) + end + end +#+END_SRC + +#+BEGIN_SRC elixir + when_prime_do = fn n, when_true, when_false -> + if Prime.is_prime(n) do + when_true.() + else + when_false.() + end + end + + when_prime_do.(10, fn -> "10 is prime" end, fn -> "10 is not prime" end) +#+END_SRC + +*** when-prime the metaprogramming way +#+BEGIN_SRC elixir + defmodule When do + defmacro prime(n, do: true_body, else: false_body) do + quote do + if Prime.is_prime(unquote(n)), do: unquote(true_body), else: unquote(false_body) + end + end + end + + require When + When.prime 10, do: "10 is prime", else: "10 is not prime" +#+END_SRC + +*** Real-world use-case: ~use~ +One such use case for macros (besides those covered previously in my LISP presentation) is to emulate module "inheritance" to share functions. + +We can think of a module in Elixir as a set of functions. Then, we can perform unions of modules by the ~use~ macros. + +Additionally, with ~behaviours~ we can define callbacks to implement in each unioned module. + +#+BEGIN_SRC elixir + defmodule Animal do + @callback noise() :: String.t() + + defmacro __using__(_opts) do + quote do + @behaviour Animal + + def speak() do + IO.puts("#{__MODULE__} says #{noise()}") + end + end + end + end + + defmodule Dog do + use Animal + + def noise() do + "Bark" + end + end + + defmodule Cat do + use Animal + + def noise() do + "Meow" + end + end + + Cat.speak() + Dog.speak() +#+END_SRC * Elixir - Concurrency Elixir is built on top of (and completely interoperable with) Erlang - a language developed to build massively fault-tolerant systems in the 80's @@ -154,9 +306,23 @@ This demo shows how we can: + Basic Elixir constructs (pattern matching, atoms, function calls, referencing functions) * CheSSH -With a very brief and quick exploration into concurrency with Elixir, we can now explore the architecture of CheSSH, +With a brief quick exploration into concurrency with Elixir, we can now explore the architecture of CheSSH, and the hardware cluster it runs on: [[./pis.jpeg]] +** Erlang SSH Module - (maybe) building a tic tac toe game! +So much networking stuff is built on top of Erlang that its standard library - OTP - has implementations for tons of stuff you'd regularly reach for a library to help; ssh, snmp, +ftp, are all built in "OTP Applications". +It requires a little bit of time with headaches, but the docs are generally pretty good (with occasional source code browsing): [[https://www.erlang.org/doc/man/ssh.html]] + +** Architecture +[[./architecture.png]] + +** Lessons Learned +1. Use Kubernetes (~buildscripts~ is so horribly janky it's actually funny) +2. Docker was a great idea +3. Don't hardcode IP's +4. Don't try to use Multicast +5. Load balancing SSH diff --git a/presentation/tic_tac_toe.exs b/presentation/tic_tac_toe.exs new file mode 100644 index 0000000..e05bed4 --- /dev/null +++ b/presentation/tic_tac_toe.exs @@ -0,0 +1,355 @@ +defmodule Generator do + def gen_reference() do + min = String.to_integer("100000", 36) + max = String.to_integer("ZZZZZZ", 36) + + max + |> Kernel.-(min) + |> :rand.uniform() + |> Kernel.+(min) + |> Integer.to_string(36) + end +end + +defmodule TicTacToe.GameManager do + use GenServer + + defmodule State do + defstruct games: %{}, + joinable_games: [], + player_games: %{} + end + + def start_link(_) do + GenServer.start_link(__MODULE__, %{ + pid: nil + }) + end + + def init(_) do + {:ok, %State{}} + end + + defp create_board(), do: Enum.map(0..2, fn _ -> Enum.map(0..2, fn _ -> :empty end) end) + + defp create_game(game_id, player) do + %{ + x: player, + o: nil, + board: create_board() + } + end + + def handle_info( + {:join, %{client_pid: client_pid, username: username, player_id: connection_id} = player}, + %State{player_games: player_games, games: games, joinable_games: joinable_games} = state + ) do + if length(joinable_games) == 0 do + game_id = Generator.gen_reference() + send(client_pid, {:join_game, game_id}) + + {:ok, + %State{ + state + | games: Map.put(games, game_id, create_game(game_id, player)), + joinable_games: joinable_games ++ [game_id], + player_games: Map.put(player_games, player_id, game_id) + }} + else + [joining_game_id | rest] = joinable_games + game = Map.get(games, joining_game_id) + send(game.x.client_pid, :player_joined) + send(client_pid, {:join_game, game_id}) + + {:ok, + %State{ + state + | games: Map.put(games, game_id, %{game | o: player}), + joinable_games: rest, + connection_games: Map.put(player_games, connection_id, game_id) + }} + end + end +end + +defmodule TicTacToe.SSHDaemon do + @port 4000 + @key_dir "/tmp/keys" + use GenServer + require Logger + + def start_link(_) do + GenServer.start_link(__MODULE__, %{ + pid: nil + }) + end + + def init(state) do + send(self(), :start) + + {:ok, state} + end + + def handle_info(:start, state) do + game_manager_pid = + case GenServer.start_link(TicTacToe.GameManager, [%{}]) do + {:ok, game_manager_pid} -> + game_manager_pid + + _ -> + nil + end + + case :ssh.daemon( + @port, + system_dir: @key_dir, + ssh_cli: + {TicTacToe.SSHListener, + [ + %TicTacToe.SSHListener.State{ + game_manager_pid: game_manager_pid + } + ]}, + disconnectfun: &on_disconnect/1, + id_string: :random, + parallel_login: true, + max_sessions: 1_000, + subsystems: [], + no_auth_needed: true + ) do + {:ok, pid} -> + Logger.info("SSH server started on port #{port}, on #{inspect(pid)}") + + Process.link(pid) + + {:noreply, %{state | pid: pid, game_manager_pid: game_manager_pid}, :hibernate} + + {:error, err} -> + raise inspect(err) + end + + {:noreply, state} + end + + def handle_info(_, state), do: {:noreply, state} + + defp on_disconnect(_reason) do + Logger.info("#{inspect(self())} disconnected") + end +end + +defmodule TicTacToe.SSHListener do + alias Chessh.SSH.Client + + alias IO.ANSI + + require Logger + + @behaviour :ssh_server_channel + @session_closed_message [ + ANSI.clear(), + ["This session has been closed"] + ] + + defmodule State do + defstruct channel_id: nil, + client_pid: nil, + game_manager_pid: nil, + connection_ref: nil + end + + def init([%State{} = init_state]) do + {:ok, init_state} + end + + def handle_msg({:ssh_channel_up, channel_id, connection_ref}, %State{} = state) do + Logger.debug("SSH channel up #{inspect(:ssh.connection_info(connection_ref))}") + + username = + :ssh.connection_info(connection_ref) + |> Keyword.fetch!(:user) + |> String.Chars.to_string() + + {:ok, + %State{ + state + | channel_id: channel_id, + connection_ref: connection_ref, + player: %{ + id: Generator.gen_reference(), + username: username + } + }} + end + + def handle_msg( + {:EXIT, client_pid, _reason}, + %State{client_pid: client_pid, channel_id: channel_id} = state + ) do + send(client_pid, :quit) + {:stop, channel_id, state} + end + + def handle_msg( + {:send_data, data}, + %State{connection_ref: connection_ref, channel_id: channel_id} = state + ) do + :ssh_connection.send(connection_ref, channel_id, data) + {:ok, state} + end + + def handle_msg( + :session_closed, + %State{connection_ref: connection_ref, channel_id: channel_id} = state + ) do + :ssh_connection.send(connection_ref, channel_id, @session_closed_message) + {:stop, channel_id, state} + end + + def handle_msg(msg, term) do + Logger.debug("Unknown msg #{inspect(msg)}, #{inspect(term)}") + end + + def handle_ssh_msg( + {:ssh_cm, _connection_handler, {:data, _channel_id, _type, data}}, + %State{client_pid: client_pid} = state + ) do + send(client_pid, {:data, data}) + {:ok, state} + end + + def handle_ssh_msg( + {:ssh_cm, connection_handler, + {:pty, channel_id, want_reply?, {_term, _width, _height, _pixwidth, _pixheight, _opts}}}, + %State{} = state + ) do + Logger.debug("#{inspect(state.player_session)} has requested a PTY") + :ssh_connection.reply_request(connection_handler, want_reply?, :success, channel_id) + {:ok, state} + end + + def handle_ssh_msg( + {:ssh_cm, connection_handler, {:env, channel_id, want_reply?, var, value}}, + state + ) do + :ssh_connection.reply_request(connection_handler, want_reply?, :failure, channel_id) + + {:ok, state} + end + + def handle_ssh_msg( + {:ssh_cm, _connection_handler, + {:window_change, _channel_id, _width, _height, _pixwidth, _pixheight}}, + %State{client_pid: client_pid} = state + ) do + {:ok, state} + end + + def handle_ssh_msg( + {:ssh_cm, connection_handler, {:shell, channel_id, want_reply?}}, + %State{player: player} = state + ) do + :ssh_connection.reply_request(connection_handler, want_reply?, :success, channel_id) + + {:ok, client_pid} = + GenServer.start_link(Client, [ + %Client.State{ + tui_pid: self(), + player: player + } + ]) + + send(client_pid, :refresh) + {:ok, %State{state | client_pid: client_pid}} + end + + def handle_ssh_msg( + msg, + %State{channel_id: channel_id} = state + ) do + Logger.debug("UNKOWN MESSAGE #{inspect(msg)}") + # {:stop, channel_id, state} + {:ok, state} + end + + def terminate(_reason, _state) do + :ok + end +end + +defmodule TicTacToe.Client do + alias IO.ANSI + use GenServer + + @clear_codes [ + ANSI.clear(), + ANSI.home() + ] + + defmodule State do + defstruct tui_pid: nil, + game_manager_pid: nil, + player: %{}, + game_id: nil + end + + @impl true + def init([%State{game_manager_pid: game_manager_pid, player: player} = state]) do + player = %{ + player + | client_pid: self() + } + + send(game_manager_pid, {:join, player}) + + {:ok, + %State{ + player: player + }} + end + + @impl true + def handle_info(:quit, %State{} = state) do + {:stop, :normal, state} + end + + @impl true + def handle_info({:join_game, game_id}, %State{} = state) do + state = %State{state | game_id: game_id} + render(state) + {:stop, :normal, state} + end + + def handle( + {:data, data}, + %State{} = state + ) do + case keymap(data) do + :quit -> + {:stop, :normal, state} + end + end + + def handle( + :player_joined, + %State{} = state + ) do + render(state) + {:noreply, state} + end + + defp render(%State{ + tui_pid: tui_pid + }) do + send(tui_pid, {:send_data, ["Testing"]}) + end + + def keymap(key) do + case key do + # Exit keys - C-c and C-d + <<3>> -> :quit + <<4>> -> :quit + x -> x + end + end +end