31 KiB
This is a web application written using the Phoenix web framework.
Elixir guidelines
-
Elixir lists do not support index based access via the access syntax
Never do this (invalid):
i = 0 mylist = ["blue", "green"] mylist[i]Instead, always use
Enum.at, pattern matching, orListfor index based list access, ie:i = 0 mylist = ["blue", "green"] Enum.at(mylist, i) -
Elixir supports
if/elsebut **does NOT supportif/else iforif/elsif. Never useelse iforelseifin Elixir, always usecondorcasefor multiple conditionals.Never do this (invalid):
<%= if condition do %> ... <% else if other_condition %> ... <% end %>Instead always do this:
<%= cond do %> <% condition -> %> ... <% condition2 -> %> ... <% true -> %> ... <% end %> -
Elixir variables are immutable, but can be rebound, so for block expressions like
if,case,cond, etc you must bind the result of the expression to a variable if you want to use it and you CANNOT rebind the result inside the expression, ie:# INVALID: we are rebinding inside the `if` and the result never gets assigned if connected?(socket) do socket = assign(socket, :val, val) end # VALID: we rebind the result of the `if` to a new variable socket = if connected?(socket) do assign(socket, :val, val) end -
Use
withfor chaining operations that return{:ok, _}or{:error, _} -
Never nest multiple modules in the same file as it can cause cyclic dependencies and compilation errors
-
Never use map access syntax (
changeset[:field]) on structs as they do not implement the Access behaviour by default. For regular structs, you must access the fields directly, such asmy_struct.fieldor use higher level APIs that are available on the struct if they exist,Ecto.Changeset.get_field/2for changesets -
Elixir's standard library has everything necessary for date and time manipulation. Familiarize yourself with the common
Time,Date,DateTime, andCalendarinterfaces by accessing their documentation as necessary. Never install additional dependencies unless asked or for date/time parsing (which you can use thedate_time_parserpackage) -
Don't use
String.to_atom/1on user input (memory leak risk) -
Predicate function names should not start with
is_and should end in a question mark. Names likeis_thingshould be reserved for guards -
Elixir's builtin OTP primitives like
DynamicSupervisorandRegistry, require names in the child spec, such as{DynamicSupervisor, name: MyApp.MyDynamicSup}, then you can useDynamicSupervisor.start_child(MyApp.MyDynamicSup, child_spec) -
Use
Task.async_stream(collection, callback, options)for concurrent enumeration with back-pressure. The majority of times you will want to passtimeout: :infinityas option
Mix guidelines
- Read the docs and options before using tasks (by using
mix help task_name) - To debug test failures, run tests in a specific file with
mix test test/my_test.exsor run all previously failed tests withmix test --failed mix deps.clean --allis almost never needed. Avoid using it unless you have good reason
Phoenix guidelines
-
Remember Phoenix router
scopeblocks include an optional alias which is prefixed for all routes within the scope. Always be mindful of this when creating routes within a scope to avoid duplicate module prefixes. -
You never need to create your own
aliasfor route definitions! Thescopeprovides the alias, ie:scope "/admin", AppWeb.Admin do pipe_through :browser live "/users", UserLive, :index endthe UserLive route would point to the
AppWeb.Admin.UserLivemodule -
Phoenix.Viewno longer is needed or included with Phoenix, don't use it
Ecto Guidelines
- Always preload Ecto associations in queries when they'll be accessed in templates, ie a message that needs to reference the
message.user.email - Remember
import Ecto.Queryand other supporting modules when you writeseeds.exs Ecto.Schemafields always use the:stringtype, even for:text, columns, ie:field :name, :stringEcto.Changeset.validate_number/2DOES NOT SUPPORT the:allow_niloption. By default, Ecto validations only run if a change for the given field exists and the change value is not nil, so such as option is never needed- You must use
Ecto.Changeset.get_field(changeset, :field)to access changeset fields - Fields which are set programatically, such as
user_id, must not be listed incastcalls or similar for security purposes. Instead they must be explicitly set when creating the struct
Phoenix HTML guidelines
-
Phoenix templates always use
~Hor .html.heex files (known as HEEx), never use~E -
Always use the imported
Phoenix.Component.form/1andPhoenix.Component.inputs_for/1function to build forms. Never usePhoenix.HTML.form_fororPhoenix.HTML.inputs_foras they are outdated -
When building forms always use the already imported
Phoenix.Component.to_form/2(assign(socket, form: to_form(...))and<.form for={@form} id="msg-form">), then access those forms in the template via@form[:field] -
Always add unique DOM IDs to key elements (like forms, buttons, etc) when writing templates, these IDs can later be used in tests (
<.form for={@form} id="product-form">) -
For "app wide" template imports, you can import/alias into the
my_app_web.ex'shtml_helpersblock, so they will be available to all LiveViews, LiveComponent's, and all modules that douse MyAppWeb, :html(replace "my_app" by the actual app name) -
HEEx require special tag annotation if you want to insert literal curly's like
{or}. If you want to show a textual code snippet on the page in a<pre>or<code>block you must annotate the parent tag withphx-no-curly-interpolation:<code phx-no-curly-interpolation> let obj = {key: "val"} </code>Within
phx-no-curly-interpolationannotated tags, you can use{and}without escaping them, and dynamic Elixir expressions can still be used with<%= ... %>syntax -
HEEx class attrs support lists, but you must always use list
[...]syntax. You can use the class list syntax to conditionally add classes, always do this for multiple class values:<a class={[ "px-2 text-white", @some_flag && "py-5", if(@other_condition, do: "border-red-500", else: "border-blue-100"), ... ]}>Text</a>and always wrap
if's inside{...}expressions with parens, like done above (if(@other_condition, do: "...", else: "..."))and never do this, since it's invalid (note the missing
[and]):<a class={ "px-2 text-white", @some_flag && "py-5" }> ... => Raises compile syntax error on invalid HEEx attr syntax -
Never use
<% Enum.each %>or non-for comprehensions for generating template content, instead always use<%= for item <- @collection do %> -
HEEx HTML comments use
<%!-- comment --%>. Always use the HEEx HTML comment syntax for template comments (<%!-- comment --%>) -
HEEx allows interpolation via
{...}and<%= ... %>, but the<%= %>only works within tag bodies. Always use the{...}syntax for interpolation within tag attributes, and for interpolation of values within tag bodies. Always interpolate block constructs (if, cond, case, for) within tag bodies using<%= ... %>.Always do this:
<div id={@id}> {@my_assign} <%= if @some_block_condition do %> {@another_assign} <% end %> </div>and Never do this – the program will terminate with a syntax error:
<%!-- THIS IS INVALID NEVER EVER DO THIS --%> <div id="<%= @invalid_interpolation %>"> {if @invalid_block_construct do} {end} </div>
Phoenix LiveView guidelines
- Never use the deprecated
live_redirectandlive_patchfunctions, instead always use the<.link navigate={href}>and<.link patch={href}>in templates, andpush_navigateandpush_patchfunctions LiveViews - Avoid LiveComponent's unless you have a strong, specific need for them
- LiveViews should be named like
AppWeb.WeatherLive, with aLivesuffix. When you go to add LiveView routes to the router, the default:browserscope is already aliased with theAppWebmodule, so you can just dolive "/weather", WeatherLive - Remember anytime you use
phx-hook="MyHook"and that js hook manages its own DOM, you must also set thephx-update="ignore"attribute - Never write embedded
<script>tags in HEEx. Instead always write your scripts and hooks in theassets/jsdirectory and integrate them with theassets/js/app.jsfile
LiveView streams
-
Always use LiveView streams for collections for assigning regular lists to avoid memory ballooning and runtime termination with the following operations:
- basic append of N items -
stream(socket, :messages, [new_msg]) - resetting stream with new items -
stream(socket, :messages, [new_msg], reset: true)(e.g. for filtering items) - prepend to stream -
stream(socket, :messages, [new_msg], at: -1) - deleting items -
stream_delete(socket, :messages, msg)
- basic append of N items -
-
When using the
stream/3interfaces in the LiveView, the LiveView template must 1) always setphx-update="stream"on the parent element, with a DOM id on the parent element likeid="messages"and 2) consume the@streams.stream_namecollection and use the id as the DOM id for each child. For a call likestream(socket, :messages, [new_msg])in the LiveView, the template would be:<div id="messages" phx-update="stream"> <div :for={{id, msg} <- @streams.messages} id={id}> {msg.text} </div> </div> -
LiveView streams are not enumerable, so you cannot use
Enum.filter/2orEnum.reject/2on them. Instead, if you want to filter, prune, or refresh a list of items on the UI, you must refetch the data and re-stream the entire stream collection, passing reset: true:def handle_event("filter", %{"filter" => filter}, socket) do # re-fetch the messages based on the filter messages = list_messages(filter) {:noreply, socket |> assign(:messages_empty?, messages == []) # reset the stream with the new messages |> stream(:messages, messages, reset: true)} end -
LiveView streams do not support counting or empty states. If you need to display a count, you must track it using a separate assign. For empty states, you can use Tailwind classes:
<div id="tasks" phx-update="stream"> <div class="hidden only:block">No tasks yet</div> <div :for={{id, task} <- @stream.tasks} id={id}> {task.name} </div> </div>The above only works if the empty state is the only HTML block alongside the stream for-comprehension.
-
Never use the deprecated
phx-update="append"orphx-update="prepend"for collections
LiveView tests
-
Phoenix.LiveViewTestmodule andLazyHTML(included) for making your assertions -
Form tests are driven by
Phoenix.LiveViewTest'srender_submit/2andrender_change/2functions -
Come up with a step-by-step test plan that splits major test cases into small, isolated files. You may start with simpler tests that verify content exists, gradually add interaction tests
-
Always reference the key element IDs you added in the LiveView templates in your tests for
Phoenix.LiveViewTestfunctions likeelement/2,has_element/2, selectors, etc -
Never tests again raw HTML, always use
element/2,has_element/2, and similar:assert has_element?(view, "#my-form") -
Instead of relying on testing text content, which can change, favor testing for the presence of key elements
-
Focus on testing outcomes rather than implementation details
-
Be aware that
Phoenix.Componentfunctions like<.form>might produce different HTML than expected. Test against the output HTML structure, not your mental model of what you expect it to be -
When facing test failures with element selectors, add debug statements to print the actual HTML, but use
LazyHTMLselectors to limit the output, ie:html = render(view) document = LazyHTML.from_fragment(html) matches = LazyHTML.filter(document, "your-complex-selector") IO.inspect(matches, label: "Matches")
Form handling
Creating a form from params
If you want to create a form based on handle_event params:
def handle_event("submitted", params, socket) do
{:noreply, assign(socket, form: to_form(params))}
end
When you pass a map to to_form/1, it assumes said map contains the form params, which are expected to have string keys.
You can also specify a name to nest the params:
def handle_event("submitted", %{"user" => user_params}, socket) do
{:noreply, assign(socket, form: to_form(user_params, as: :user))}
end
Creating a form from changesets
When using changesets, the underlying data, form params, and errors are retrieved from it. The :as option is automatically computed too. E.g. if you have a user schema:
defmodule MyApp.Users.User do
use Ecto.Schema
...
end
And then you create a changeset that you pass to to_form:
%MyApp.Users.User{}
|> Ecto.Changeset.change()
|> to_form()
Once the form is submitted, the params will be available under %{"user" => user_params}.
In the template, the form form assign can be passed to the <.form> function component:
<.form for={@form} id="todo-form" phx-change="validate" phx-submit="save">
<.input field={@form[:field]} type="text" />
</.form>
Always give the form an explicit, unique DOM ID, like id="todo-form".
Avoiding form errors
Always use a form assigned via to_form/2 in the LiveView, and the <.input> component in the template. In the template always access forms this:
<%!-- ALWAYS do this (valid) --%>
<.form for={@form} id="my-form">
<.input field={@form[:field]} type="text" />
</.form>
And never do this:
<%!-- NEVER do this (invalid) --%>
<.form for={@changeset} id="my-form">
<.input field={@changeset[:field]} type="text" />
</.form>
- You are FORBIDDEN from accessing the changeset in the template as it will cause errors
- Never use
<.form let={f} ...>in the template, instead always use<.form for={@form} ...>, then drive all form references from the form assign as in@form[:field]. The UI should always be driven by ato_form/2assigned in the LiveView module that is derived from a changeset
Project guidelines
- Use
mix precommitalias when you are done with all changes and fix any pending issues - Use the already included and available
:req(Req) library for HTTP requests, avoid:httpoison,:tesla, and:httpc. Req is included by default and is the preferred HTTP client for Phoenix apps
Phoenix v1.8 guidelines
- Always begin your LiveView templates with
<Layouts.app flash={@flash} ...>which wraps all inner content - The
MyAppWeb.Layoutsmodule is aliased in themy_app_web.exfile, so you can use it without needing to alias it again - Anytime you run into errors with no
current_scopeassign:- You failed to follow the Authenticated Routes guidelines, or you failed to pass
current_scopeto<Layouts.app> - Always fix the
current_scopeerror by moving your routes to the properlive_sessionand ensure you passcurrent_scopeas needed
- You failed to follow the Authenticated Routes guidelines, or you failed to pass
- Phoenix v1.8 moved the
<.flash_group>component to theLayoutsmodule. You are forbidden from calling<.flash_group>outside of thelayouts.exmodule - Out of the box,
core_components.eximports an<.icon name="hero-x-mark" class="w-5 h-5"/>component for for hero icons. Always use the<.icon>component for icons, never useHeroiconsmodules or similar - Always use the imported
<.input>component for form inputs fromcore_components.exwhen available.<.input>is imported and using it will will save steps and prevent errors - If you override the default input classes (
<.input class="myclass px-2 py-1 rounded-lg">)) class with your own values, no default classes are inherited, so your custom classes must fully style the input
JS and CSS guidelines
-
Use Tailwind CSS classes and custom CSS rules to create polished, responsive, and visually stunning interfaces.
-
Tailwindcss v4 no longer needs a tailwind.config.js and uses a new import syntax in
app.css:@import "tailwindcss" source(none); @source "../css"; @source "../js"; @source "../../lib/my_app_web"; -
Always use and maintain this import syntax in the app.css file for projects generated with
phx.new -
Never use
@applywhen writing raw css -
Always manually write your own tailwind-based components instead of using daisyUI for a unique, world-class design
-
Out of the box only the app.js and app.css bundles are supported
- You cannot reference an external vendor'd script
srcor linkhrefin the layouts - You must import the vendor deps into app.js and app.css to use them
- Never write inline tags within templates
- You cannot reference an external vendor'd script
UI/UX & design guidelines
- Produce world-class UI designs with a focus on usability, aesthetics, and modern design principles
- Implement subtle micro-interactions (e.g., button hover effects, and smooth transitions)
- Ensure clean typography, spacing, and layout balance for a refined, premium look
- Focus on delightful details like hover effects, loading states, and smooth page transitions
Authentication
- Always handle authentication flow at the router level with proper redirects
- Always be mindful of where to place routes.
phx.gen.authcreates multiple router plugs andlive_sessionscopes:- A
live_session :current_userscope - For routes that need the current user but don't require authentication - A
live_session :require_authenticated_userscope - For routes that require authentication - In both cases, a
@current_scopeis assigned to the Plug connection and LiveView socket
- A
- Always let the user know in which router scopes,
live_session, and pipeline you are placing the route, AND SAY WHY phx.gen.authassigns thecurrent_scopeassign - it does not assign thecurrent_userassign.- To derive/access
current_user, always use thecurrent_scope.userassign, never use@current_userin templates or LiveViews - Never duplicate
live_sessionnames. Alive_session :current_usercan only be defined once in the router, so all routes for thelive_session :current_usermust be grouped in a single block - Anytime you hit
current_scopeerrors or the logged in session isn't displaying the right content, always double check the router and ensure you are using the correctlive_sessiondescribed below
Routes that require authentication
LiveViews that require login should always be placed inside the existing live_session :require_authenticated_user block:
scope "/", AppWeb do
pipe_through [:browser, :require_authenticated_user]
live_session :require_authenticated_user,
on_mount: [{AppWeb.UserAuth, :ensure_authenticated}] do
# phx.gen.auth generated routes
live "/users/settings", UserSettingsLive, :edit
live "/users/settings/confirm_email/:token", UserSettingsLive, :confirm_email
# our own routes that require logged in user
live "/", MyLiveThatRequiresAuth, :index
end
end
Routes that work with or without authentication
LiveViews that can work with or without authentication, always use the existing :current_user scope, ie:
scope "/", MyAppWeb do
pipe_through [:browser]
live_session :current_user,
on_mount: [{MyAppWeb.UserAuth, :mount_current_scope}] do
# our own routes that work with or without authentication
live "/", PublicLive
end
end
Prerequisites
Before setting up the project, ensure you have the following installed:
- Elixir 1.17+
- Erlang/OTP
- PostgreSQL with PostGIS extension
Project Overview
This is an Elixir Phoenix LiveView application that serves as a real-time APRS (Automatic Packet Reporting System) tracker and visualizer. It connects to the APRS-IS network to receive live amateur radio packets and displays them on an interactive map interface.
Development Commands
Setup
mix setup- Complete project setup (deps.get + ecto.setup)mix deps.get- Install dependenciesmix ecto.setup- Create database, run migrations, and seed datamix ecto.reset- Drop and recreate databasemix phx.server- Start Phoenix server (http://localhost:4000)iex -S mix phx.server- Start server in interactive Elixir shell
Testing
mix test- Run full test suitemix test --stale- Run only tests affected by code changesmix test.watch- Continuous testing with file watchingmix test --cover- Generate test coverage reports
Code Quality
mix format- Format code according to .formatter.exsmix credo- Static code analysis and style checkingmix dialyzer- Static type analysis (must run and fix errors/warnings)mix sobelow- Security vulnerability scanning- CRITICAL: ALWAYS run
mix formatBEFORE committing - never commit unformatted code - MANDATORY: Run
mix compile --warnings-as-errorsand ensure it passes before considering any task complete
Assets (No Node.js Required)
mix assets.deploy- Build and minify frontend assets (Tailwind CSS + ESBuild)- Phoenix uses standalone ESBuild and Tailwind binaries - no npm/yarn needed
- JavaScript bundling handled by ESBuild
- CSS compilation handled by Tailwind CLI
Architecture
Core Components
- Aprsme.AprsIsConnection - TCP connection to APRS-IS network with reconnection logic
- Aprsme.PacketConsumer - Processes incoming APRS packets using GenStage pipeline
- Aprsme.Packet - Database schema for APRS packets with PostGIS geographic data
- AprsmeWeb.MapLive.Index - Main real-time map interface using Phoenix LiveView
- Aprsme.Workers.PacketCleanupWorker - Oban background job for data cleanup
Data Flow
- APRS-IS connection receives packets via TCP
- PacketConsumer processes packets through GenStage pipeline
- Packets stored in PostgreSQL with PostGIS geographic indexing
- LiveView broadcasts real-time updates to connected clients via PubSub
- Background workers handle cleanup and maintenance tasks
Key Dependencies
- Phoenix LiveView for real-time UI without JavaScript
- PostGIS for geographic data storage and spatial queries
- Oban for background job processing
- GenStage for packet processing pipelines
- Tailwind CSS + ESBuild for frontend assets (no Node.js)
Test-Driven Development
MANDATORY: Follow strict test-driven development (TDD) practices:
CRITICAL: When a new feature or bug is introduced, ALWAYS write a test for it FIRST, then write the code to satisfy the test. This is non-negotiable.
- Red Phase: Write failing tests first before implementing any functionality
- Green Phase: Write minimal code to make tests pass
- Refactor Phase: Improve code while keeping tests green
TDD Workflow
- ALWAYS write tests before implementing new features or fixing bugs
- When addressing a bug, first write a test that reproduces the bug (it should fail)
- When adding a feature, first write tests that define the expected behavior
- Start with the simplest failing test case
- Write only enough code to make the test pass
- Refactor with confidence knowing tests will catch regressions
- Run
mix testfrequently during development - Use
mix test.watchfor continuous feedback
Testing Patterns
Tests use comprehensive mocking to prevent external connections:
- APRS-IS connections are mocked in test environment
- Database uses sandbox mode for isolation
- External API calls mocked with Mox library
- Write unit tests for business logic, integration tests for workflows
- Test edge cases and error conditions thoroughly
- Maintain high test coverage with
mix test --cover
Code Style Guidelines
- CRITICAL: Never write production code without tests first
- Use LiveView for UI interactions, minimize JavaScript
- Prefer pattern matching over if/case statements
- Follow idiomatic Elixir conventions
- CRITICAL: ALWAYS run
mix formatBEFORE committing - this is non-negotiable - Address any compiler warnings
- Run
mix dialyzerand fix all errors/warnings - MANDATORY: Run
mix compile --warnings-as-errorsand ensure it passes before considering any task complete - Use function composition over nested conditionals
- Write descriptive test names that explain behavior
Pre-Commit Checklist
CRITICAL: NEVER commit or push code with syntax errors or compilation failures. Always validate before committing.
MANDATORY PRE-COMMIT STEPS - Must be executed in this exact order before EVERY git commit:
-
mix format- ALWAYS run this FIRST before any git operations- This formats all Elixir code according to project standards
- NEVER skip this step - unformatted code should never be committed
-
mix compile --warnings-as-errors- ensure no warnings or compilation errors- Must pass with zero warnings and zero errors
-
mix test- ensure all tests pass (at minimum, ensure no syntax/compilation errors)- At minimum verify no compilation failures even if some tests fail
-
MANDATORY: If any step fails, fix the issues before proceeding
-
Only after ALL checks pass should you commit and push your changes
NEVER PUSH BROKEN CODE: Syntax errors, compilation failures, or basic test failures should be fixed immediately before any git operations. Pushing broken code breaks CI/CD pipelines and wastes deployment resources.
REMEMBER: Always run mix format before every commit - this is non-negotiable and must become automatic habit.
Git Commit Messages
When creating git commits:
- Write clear, concise commit messages following conventional commit format (e.g.,
feat:,fix:,refactor:,docs:) - Focus on the "why" rather than the "what" in commit messages
- DO NOT add "Generated with Claude Code" or similar attribution to commit messages
- DO NOT add Co-Authored-By lines for Claude
- Keep commit messages professional and focused solely on the code changes
Important Documentation Updates
- MANDATORY: Whenever you implement improvements or changes to the system:
- Update
/CHANGELOG.mdwith:- Add new entries under
[Unreleased]section - Use categories: Added, Changed, Fixed, Removed
- Be specific and user-focused in descriptions
- Add new entries under
- Update
/docs/improvement-todos.mdwith:- Mark completed items as done with the implementation date
- Add any new improvements discovered during implementation
- Update priority levels based on new insights
- Document any technical decisions or trade-offs made
- Update
- This ensures continuity across sessions and helps track progress on system improvements
Web Testing
- MANDATORY: When viewing any website or web application, always use Puppeteer to take screenshots and interact with the page
- Use
mcp__puppeteer__puppeteer_navigate,mcp__puppeteer__puppeteer_screenshot, and other Puppeteer tools - This ensures accurate visual feedback and proper testing of the user interface
Deployment
The application supports Kubernetes deployment with manifests in k8s/ directory and GitHub Actions CI/CD pipeline. Database migrations run automatically via init containers.
Infrastructure
The application runs on a highly available infrastructure:
- Kubernetes: k3s cluster deployed across 3 VMs
- Virtualization: Proxmox VE hosting the VMs
- Hardware: 3 Intel N100 nodes, each with:
- 32GB RAM
- 1TB SSD storage
- Low power consumption (~15W per node)
- Distribution: VMs spread across physical nodes for hardware redundancy
Kubernetes Commands
The app is deployed in a k3s cluster with the following structure:
- App name:
aprs - Namespace:
aprs - Deployment: StatefulSet with 2-3 replicas
- Manifests: Located in
~/dev/infra/clusters/aprs/
Common kubectl commands for debugging:
# Check pod status
kubectl get pods -n aprs
# Get logs from the app
kubectl logs -f deployment/aprs -n aprs
# Get logs from a specific pod
kubectl logs <pod-name> -n aprs
# Describe pod for events and details
kubectl describe pod <pod-name> -n aprs
# Restart the statefulset
kubectl rollout restart statefulset/aprs -n aprs
# Check statefulset status
kubectl rollout status statefulset/aprs -n aprs
# Execute commands in the pod (StatefulSet)
kubectl exec -it aprs-0 -n aprs -- /app/bin/aprsme remote
# Check cluster membership
kubectl exec -it <pod-name> -n aprs -- /app/bin/aprsme eval "Node.list()"
# Check leader status
kubectl exec -it <pod-name> -n aprs -- /app/bin/aprsme eval "Aprsme.Cluster.LeaderElection.is_leader?()"
Clustering Architecture
The application uses distributed Erlang clustering to ensure only one APRS-IS connection across multiple replicas:
-
StatefulSet Deployment:
- Uses Kubernetes StatefulSet for stable pod names (aprs-0, aprs-1, etc.)
- Headless service provides DNS entries for each pod
- Stable network identities enable Erlang distribution
-
Leader Election: Uses
:globalregistry for distributed leader election- Only the elected leader maintains the APRS-IS connection
- Automatic failover when leader goes down
- Leader election managed by
Aprsme.Cluster.LeaderElection
-
Connection Management:
Aprsme.Cluster.ConnectionManagerstarts/stops APRS-IS based on leadership- Uses
DynamicSupervisorto manage connection lifecycle - Prevents duplicate connections and packet processing
-
Cluster Configuration:
- Uses
libclusterwith Kubernetes.DNS strategy - Automatic node discovery via headless service
- Erlang cookie configured via RELEASE_COOKIE environment variable
- Environment variables:
CLUSTER_ENABLED=true- Enables clusteringRELEASE_NODE- Erlang node nameRELEASE_COOKIE- Erlang distribution cookie
- Uses
-
Deployment:
- Default replicas: 3 (configurable in
aprs-deployment.yaml) - Only leader processes APRS packets
- All nodes serve web traffic
- Default replicas: 3 (configurable in
1Password Integration
When working with passwords and secrets:
- ALWAYS use 1Password account ID:
YOOATCZZSVGH7AD6VABUVPORLI - Store all passwords, API keys, and secrets in 1Password
- Use the
opCLI tool for programmatic access - Never hardcode passwords in configuration files