Update deployment infrastructure
Torey Heinz
committed Mar 14, 2026
commit d2ace26773dd022e58f4c4b44b497a65f332a790
Showing 11
changed files with
1173 additions
and 0 deletions
.tool-versions
+3
-0
| @@ | @@ -0,0 +1,3 @@ |
| + | erlang 27.0 |
| + | elixir 1.18.1-otp-27 |
| + | nodejs 22.11.0 |
AGENTS.md
+334
-0
| @@ | @@ -0,0 +1,334 @@ |
| + | This is a web application written using the Phoenix web framework. |
| + | |
| + | ## Project guidelines |
| + | |
| + | - Use `mix precommit` alias 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.Layouts` module is aliased in the `my_app_web.ex` file, so you can use it without needing to alias it again |
| + | - Anytime you run into errors with no `current_scope` assign: |
| + | - You failed to follow the Authenticated Routes guidelines, or you failed to pass `current_scope` to `<Layouts.app>` |
| + | - **Always** fix the `current_scope` error by moving your routes to the proper `live_session` and ensure you pass `current_scope` as needed |
| + | - Phoenix v1.8 moved the `<.flash_group>` component to the `Layouts` module. You are **forbidden** from calling `<.flash_group>` outside of the `layouts.ex` module |
| + | - Out of the box, `core_components.ex` imports an `<.icon name="hero-x-mark" class="w-5 h-5"/>` component for for hero icons. **Always** use the `<.icon>` component for icons, **never** use `Heroicons` modules or similar |
| + | - **Always** use the imported `<.input>` component for form inputs from `core_components.ex` when 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 `@apply` when 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 `src` or link `href` in the layouts |
| + | - You must import the vendor deps into app.js and app.css to use them |
| + | - **Never write inline <script>custom js</script> tags within templates** |
| + | |
| + | ### 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 |
| + | |
| + | |
| + | <!-- usage-rules-start --> |
| + | |
| + | <!-- phoenix:elixir-start --> |
| + | ## 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, or `List` for index based list access, ie: |
| + | |
| + | i = 0 |
| + | mylist = ["blue", "green"] |
| + | Enum.at(mylist, i) |
| + | |
| + | - 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 |
| + | |
| + | - **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 as `my_struct.field` or use higher level APIs that are available on the struct if they exist, `Ecto.Changeset.get_field/2` for changesets |
| + | - Elixir's standard library has everything necessary for date and time manipulation. Familiarize yourself with the common `Time`, `Date`, `DateTime`, and `Calendar` interfaces by accessing their documentation as necessary. **Never** install additional dependencies unless asked or for date/time parsing (which you can use the `date_time_parser` package) |
| + | - Don't use `String.to_atom/1` on user input (memory leak risk) |
| + | - Predicate function names should not start with `is_` and should end in a question mark. Names like `is_thing` should be reserved for guards |
| + | - Elixir's builtin OTP primitives like `DynamicSupervisor` and `Registry`, require names in the child spec, such as `{DynamicSupervisor, name: MyApp.MyDynamicSup}`, then you can use `DynamicSupervisor.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 pass `timeout: :infinity` as 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.exs` or run all previously failed tests with `mix test --failed` |
| + | - `mix deps.clean --all` is **almost never needed**. **Avoid** using it unless you have good reason |
| + | <!-- phoenix:elixir-end --> |
| + | |
| + | <!-- phoenix:phoenix-start --> |
| + | ## Phoenix guidelines |
| + | |
| + | - Remember Phoenix router `scope` blocks 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 `alias` for route definitions! The `scope` provides the alias, ie: |
| + | |
| + | scope "/admin", AppWeb.Admin do |
| + | pipe_through :browser |
| + | |
| + | live "/users", UserLive, :index |
| + | end |
| + | |
| + | the UserLive route would point to the `AppWeb.Admin.UserLive` module |
| + | |
| + | - `Phoenix.View` no longer is needed or included with Phoenix, don't use it |
| + | <!-- phoenix:phoenix-end --> |
| + | |
| + | <!-- phoenix:ecto-start --> |
| + | ## 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.Query` and other supporting modules when you write `seeds.exs` |
| + | - `Ecto.Schema` fields always use the `:string` type, even for `:text`, columns, ie: `field :name, :string` |
| + | - `Ecto.Changeset.validate_number/2` **DOES NOT SUPPORT the `:allow_nil` option**. 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 in `cast` calls or similar for security purposes. Instead they must be explicitly set when creating the struct |
| + | <!-- phoenix:ecto-end --> |
| + | |
| + | <!-- phoenix:html-start --> |
| + | ## Phoenix HTML guidelines |
| + | |
| + | - Phoenix templates **always** use `~H` or .html.heex files (known as HEEx), **never** use `~E` |
| + | - **Always** use the imported `Phoenix.Component.form/1` and `Phoenix.Component.inputs_for/1` function to build forms. **Never** use `Phoenix.HTML.form_for` or `Phoenix.HTML.inputs_for` as 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`'s `html_helpers` block, so they will be available to all LiveViews, LiveComponent's, and all modules that do `use MyAppWeb, :html` (replace "my_app" by the actual app name) |
| + | |
| + | - Elixir supports `if/else` but **does NOT support `if/else if` or `if/elsif`. **Never use `else if` or `elseif` in Elixir**, **always** use `cond` or `case` for multiple conditionals. |
| + | |
| + | **Never do this (invalid)**: |
| + | |
| + | <%= if condition do %> |
| + | ... |
| + | <% else if other_condition %> |
| + | ... |
| + | <% end %> |
| + | |
| + | Instead **always** do this: |
| + | |
| + | <%= cond do %> |
| + | <% condition -> %> |
| + | ... |
| + | <% condition2 -> %> |
| + | ... |
| + | <% true -> %> |
| + | ... |
| + | <% end %> |
| + | |
| + | - 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 with `phx-no-curly-interpolation`: |
| + | |
| + | <code phx-no-curly-interpolation> |
| + | let obj = {key: "val"} |
| + | </code> |
| + | |
| + | Within `phx-no-curly-interpolation` annotated 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:html-end --> |
| + | |
| + | <!-- phoenix:liveview-start --> |
| + | ## Phoenix LiveView guidelines |
| + | |
| + | - **Never** use the deprecated `live_redirect` and `live_patch` functions, instead **always** use the `<.link navigate={href}>` and `<.link patch={href}>` in templates, and `push_navigate` and `push_patch` functions LiveViews |
| + | - **Avoid LiveComponent's** unless you have a strong, specific need for them |
| + | - LiveViews should be named like `AppWeb.WeatherLive`, with a `Live` suffix. When you go to add LiveView routes to the router, the default `:browser` scope is **already aliased** with the `AppWeb` module, so you can just do `live "/weather", WeatherLive` |
| + | - Remember anytime you use `phx-hook="MyHook"` and that js hook manages its own DOM, you **must** also set the `phx-update="ignore"` attribute |
| + | - **Never** write embedded `<script>` tags in HEEx. Instead always write your scripts and hooks in the `assets/js` directory and integrate them with the `assets/js/app.js` file |
| + | |
| + | ### 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)` |
| + | |
| + | - When using the `stream/3` interfaces in the LiveView, the LiveView template must 1) always set `phx-update="stream"` on the parent element, with a DOM id on the parent element like `id="messages"` and 2) consume the `@streams.stream_name` collection and use the id as the DOM id for each child. For a call like `stream(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/2` or `Enum.reject/2` on 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"` or `phx-update="prepend"` for collections |
| + | |
| + | ### LiveView tests |
| + | |
| + | - `Phoenix.LiveViewTest` module and `LazyHTML` (included) for making your assertions |
| + | - Form tests are driven by `Phoenix.LiveViewTest`'s `render_submit/2` and `render_change/2` functions |
| + | - 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.LiveViewTest` functions like `element/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.Component` functions 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 `LazyHTML` selectors 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 a `to_form/2` assigned in the LiveView module that is derived from a changeset |
| + | <!-- phoenix:liveview-end --> |
| + | |
| + | <!-- usage-rules-end --> |
| \ No newline at end of file | |
build.sh
+36
-0
| @@ | @@ -0,0 +1,36 @@ |
| + | #!/usr/bin/env bash |
| + | # exit on error |
| + | set -o errexit |
| + | |
| + | # Load ASDF (use explicit path for non-interactive shells) |
| + | export HOME="/home/dev" |
| + | source /home/dev/.asdf/asdf.sh |
| + | export PATH="/home/dev/.asdf/shims:/home/dev/.asdf/bin:$PATH" |
| + | |
| + | # Debug: Check if mix is available |
| + | echo "PATH: $PATH" |
| + | which mix || { echo "mix not found in PATH"; exit 1; } |
| + | |
| + | # Load environment (symlinked from shared/) |
| + | # Required for compile and migration steps in prod |
| + | if [ -f .env ]; then |
| + | echo "Loading environment from .env" |
| + | set -a |
| + | source .env |
| + | set +a |
| + | fi |
| + | |
| + | # Initial setup |
| + | mix deps.get --only prod |
| + | MIX_ENV=prod mix compile |
| + | |
| + | # Compile assets |
| + | MIX_ENV=prod mix assets.build |
| + | MIX_ENV=prod mix assets.deploy |
| + | |
| + | # Run migrations |
| + | MIX_ENV=prod mix ecto.migrate |
| + | |
| + | # Create server script, Build the release, and overwrite the existing release directory |
| + | MIX_ENV=prod mix phx.gen.release |
| + | MIX_ENV=prod mix release --overwrite |
config/deploy.exs
+23
-0
| @@ | @@ -0,0 +1,23 @@ |
| + | import Config |
| + | |
| + | # Deployment configuration for Mix.Tasks.Deploy |
| + | # See: lib/mix/tasks/deploy.ex |
| + | |
| + | # Shared configuration across all environments |
| + | config :deploy, |
| + | repository: "git@github.com:toreyheinz/PiDayGame.git", |
| + | shared_dirs: ["tmp", "logs"], |
| + | shared_files: [".env"], |
| + | build_script: "./build.sh", |
| + | app_name: "pi_day" |
| + | |
| + | # Production environment configuration |
| + | config :deploy, :production, |
| + | branch: "main", |
| + | user: "dev", |
| + | domain: "ssh.teagles.io", |
| + | port: 22, |
| + | deploy_to: "/var/www/piday.teagles.io", |
| + | url: "https://piday.teagles.io", |
| + | app_port: 4010 |
| + | |
deploy/nginx.conf
+60
-0
| @@ | @@ -0,0 +1,60 @@ |
| + | # WebSocket connection upgrade |
| + | map $http_upgrade $connection_upgrade { |
| + | default upgrade; |
| + | '' close; |
| + | } |
| + | |
| + | upstream ${UPSTREAM_NAME} { |
| + | server 127.0.0.1:${APP_PORT} fail_timeout=0; |
| + | } |
| + | |
| + | server { |
| + | listen 80; |
| + | server_name ${SERVER_NAME}; |
| + | |
| + | # Logs |
| + | access_log /var/log/nginx/${SERVER_NAME}_access.log; |
| + | error_log /var/log/nginx/${SERVER_NAME}_error.log; |
| + | |
| + | # All requests go through Phoenix (including static files) |
| + | location / { |
| + | proxy_pass http://${UPSTREAM_NAME}; |
| + | proxy_http_version 1.1; |
| + | |
| + | # Standard proxy headers |
| + | proxy_set_header Host $host; |
| + | proxy_set_header X-Real-IP $remote_addr; |
| + | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; |
| + | proxy_set_header X-Forwarded-Proto $scheme; |
| + | proxy_set_header X-Forwarded-Port $server_port; |
| + | proxy_set_header X-Forwarded-Host $host; |
| + | |
| + | # WebSocket support |
| + | proxy_set_header Upgrade $http_upgrade; |
| + | proxy_set_header Connection $connection_upgrade; |
| + | |
| + | # Timeouts |
| + | proxy_connect_timeout 60s; |
| + | proxy_send_timeout 60s; |
| + | proxy_read_timeout 86400s; # Long timeout for WebSockets |
| + | |
| + | # Buffer settings |
| + | proxy_buffering on; |
| + | proxy_buffer_size 4k; |
| + | proxy_buffers 8 4k; |
| + | proxy_busy_buffers_size 8k; |
| + | |
| + | # File upload size |
| + | client_max_body_size 10M; |
| + | } |
| + | |
| + | # Deny access to hidden files |
| + | location ~ /\. { |
| + | deny all; |
| + | } |
| + | |
| + | # Let's Encrypt challenge location |
| + | location ~ /.well-known/acme-challenge { |
| + | allow all; |
| + | } |
| + | } |
deploy/pi_day.service
+31
-0
| @@ | @@ -0,0 +1,31 @@ |
| + | [Unit] |
| + | Description=pi_day Phoenix Application |
| + | After=network.target postgresql.service |
| + | |
| + | [Service] |
| + | Type=simple |
| + | User=dev |
| + | Group=dev |
| + | WorkingDirectory=${DEPLOY_TO}/current |
| + | |
| + | # Environment |
| + | Environment="MIX_ENV=prod" |
| + | Environment="PORT=${APP_PORT}" |
| + | Environment="PHX_SERVER=true" |
| + | Environment="PHX_HOST=${PHX_HOST}" |
| + | Environment="LANG=en_US.UTF-8" |
| + | |
| + | # Start/stop using release scripts (asdf sourced via rel/env.sh.eex) |
| + | ExecStart=${DEPLOY_TO}/current/_build/prod/rel/pi_day/bin/pi_day start |
| + | ExecStop=${DEPLOY_TO}/current/_build/prod/rel/pi_day/bin/pi_day stop |
| + | |
| + | Restart=on-failure |
| + | RestartSec=5 |
| + | |
| + | # Logging |
| + | StandardOutput=journal |
| + | StandardError=journal |
| + | SyslogIdentifier=pi_day |
| + | |
| + | [Install] |
| + | WantedBy=multi-user.target |
mix/tasks/deploy.ex b/lib/mix/tasks/deploy.ex
+287
-0
| @@ | @@ -0,0 +1,287 @@ |
| + | defmodule Mix.Tasks.Deploy do |
| + | use Mix.Task |
| + | |
| + | @shortdoc "Deploy Phoenix application to VPS" |
| + | @moduledoc """ |
| + | Deploy Phoenix application to a VPS using SSH. |
| + | |
| + | ## Usage |
| + | |
| + | mix deploy # Deploy to production (default) |
| + | mix deploy staging # Deploy to staging |
| + | mix deploy staging feature-xyz # Deploy specific branch to staging |
| + | |
| + | ## Configuration |
| + | |
| + | Configure deployment in `config/deploy.exs`: |
| + | |
| + | import Config |
| + | |
| + | config :deploy, |
| + | repository: "git@github.com:username/app-name.git", |
| + | shared_dirs: ["uploads", "tmp", "logs"], |
| + | shared_files: [".env"], |
| + | build_script: "./build.sh" |
| + | |
| + | config :deploy, :production, |
| + | branch: "main", |
| + | user: "dev", |
| + | domain: "ssh.example.com", |
| + | port: 22, |
| + | deploy_to: "/var/www/app-name", |
| + | url: "https://app.example.com", |
| + | app_port: 4000 |
| + | |
| + | config :deploy, :staging, |
| + | branch: "develop", |
| + | user: "dev", |
| + | domain: "ssh.example.com", |
| + | port: 22, |
| + | deploy_to: "/var/www/staging.app-name", |
| + | url: "https://staging.app.example.com", |
| + | app_port: 4001 |
| + | """ |
| + | |
| + | require Logger |
| + | |
| + | @impl Mix.Task |
| + | def run(args) do |
| + | # Load deployment config |
| + | Mix.Task.run("loadconfig", ["config/deploy.exs"]) |
| + | |
| + | {env, branch} = parse_args(args) |
| + | config = get_deploy_config(env) |
| + | |
| + | # Allow branch override |
| + | config = if branch, do: Map.put(config, :branch, branch), else: config |
| + | |
| + | Logger.info("Deploying to #{env} environment...") |
| + | Logger.info("Branch: #{config.branch}") |
| + | Logger.info("Host: #{config.user}@#{config.domain}:#{config.port}") |
| + | Logger.info("Deploy to: #{config.deploy_to}") |
| + | |
| + | # Execute deployment |
| + | with :ok <- check_requirements(config), |
| + | {:ok, config} <- create_release_dir(config), |
| + | :ok <- clone_or_fetch_code(config), |
| + | :ok <- link_shared_paths(config), |
| + | :ok <- run_build_script(config), |
| + | :ok <- update_symlinks(config), |
| + | :ok <- restart_service(config), |
| + | :ok <- cleanup_old_releases(config), |
| + | :ok <- health_check(config) do |
| + | Logger.info("✅ Deployment completed successfully!") |
| + | else |
| + | {:error, reason} -> |
| + | Logger.error("❌ Deployment failed: #{reason}") |
| + | exit(1) |
| + | end |
| + | end |
| + | |
| + | defp parse_args([]), do: {:production, nil} |
| + | defp parse_args([env]), do: {String.to_atom(env), nil} |
| + | defp parse_args([env, branch]), do: {String.to_atom(env), branch} |
| + | |
| + | defp get_deploy_config(env), do: Deploy.SSH.get_deploy_config(env) |
| + | |
| + | defp check_requirements(config) do |
| + | required_keys = [:repository, :user, :domain, :deploy_to, :branch] |
| + | missing = Enum.filter(required_keys, &(not Map.has_key?(config, &1))) |
| + | |
| + | if Enum.empty?(missing) do |
| + | :ok |
| + | else |
| + | {:error, "Missing required config keys: #{inspect(missing)}"} |
| + | end |
| + | end |
| + | |
| + | defp create_release_dir(config) do |
| + | timestamp = DateTime.utc_now() |> DateTime.to_unix() |> to_string() |
| + | release_path = "#{config.deploy_to}/releases/#{timestamp}" |
| + | |
| + | case ssh_exec(config, "mkdir -p #{release_path}") do |
| + | {_, 0} -> |
| + | config = Map.put(config, :release_path, release_path) |
| + | {:ok, config} |
| + | {output, _} -> |
| + | {:error, "Failed to create release directory: #{output}"} |
| + | end |
| + | end |
| + | |
| + | defp clone_or_fetch_code(%{release_path: release_path} = config) do |
| + | repo_path = "#{config.deploy_to}/repo" |
| + | |
| + | # Clone or update repository |
| + | clone_cmd = """ |
| + | if [ -d #{repo_path} ]; then |
| + | cd #{repo_path} && git fetch origin |
| + | else |
| + | git clone #{config.repository} #{repo_path} |
| + | fi |
| + | """ |
| + | |
| + | case ssh_exec(config, clone_cmd) do |
| + | {_, 0} -> |
| + | # Archive the specific branch to release directory (use origin/ to get fetched code) |
| + | archive_cmd = """ |
| + | cd #{repo_path} && \ |
| + | git archive origin/#{config.branch} | tar -x -C #{release_path} |
| + | """ |
| + | |
| + | case ssh_exec(config, archive_cmd) do |
| + | {_, 0} -> :ok |
| + | {output, _} -> {:error, "Failed to archive code: #{output}"} |
| + | end |
| + | {output, _} -> |
| + | {:error, "Failed to clone/fetch repository: #{output}"} |
| + | end |
| + | end |
| + | |
| + | defp link_shared_paths(%{release_path: release_path} = config) do |
| + | shared_path = "#{config.deploy_to}/shared" |
| + | |
| + | # Create shared directories if they don't exist |
| + | shared_dirs = Map.get(config, :shared_dirs, []) |
| + | shared_files = Map.get(config, :shared_files, []) |
| + | |
| + | # Create shared structure |
| + | create_shared_cmd = """ |
| + | mkdir -p #{shared_path} && \ |
| + | #{Enum.map(shared_dirs, fn dir -> "mkdir -p #{shared_path}/#{dir}" end) |> Enum.join(" && ")} |
| + | """ |
| + | |
| + | case ssh_exec(config, create_shared_cmd) do |
| + | {_, 0} -> |
| + | # Link shared directories |
| + | link_dirs_cmd = shared_dirs |
| + | |> Enum.map(fn dir -> |
| + | "ln -nfs #{shared_path}/#{dir} #{release_path}/#{dir}" |
| + | end) |
| + | |> Enum.join(" && ") |
| + | |
| + | # Link shared files |
| + | link_files_cmd = shared_files |
| + | |> Enum.map(fn file -> |
| + | dir = Path.dirname(file) |
| + | "mkdir -p #{release_path}/#{dir} && " <> |
| + | "if [ -f #{shared_path}/#{file} ]; then " <> |
| + | "ln -nfs #{shared_path}/#{file} #{release_path}/#{file}; " <> |
| + | "fi" |
| + | end) |
| + | |> Enum.join(" && ") |
| + | |
| + | commands = [link_dirs_cmd, link_files_cmd] |
| + | |> Enum.reject(&(&1 == "")) |
| + | |
| + | if Enum.empty?(commands) do |
| + | :ok |
| + | else |
| + | full_cmd = Enum.join(commands, " && ") |
| + | case ssh_exec(config, full_cmd) do |
| + | {_, 0} -> :ok |
| + | {output, _} -> {:error, "Failed to link shared paths: #{output}"} |
| + | end |
| + | end |
| + | {output, _} -> |
| + | {:error, "Failed to create shared directories: #{output}"} |
| + | end |
| + | end |
| + | |
| + | defp run_build_script(%{release_path: release_path} = config) do |
| + | build_script = Map.get(config, :build_script, "./build.sh") |
| + | |
| + | Logger.info("Running build script...") |
| + | |
| + | # First make it executable |
| + | chmod_cmd = "chmod +x #{release_path}/#{build_script}" |
| + | case ssh_exec(config, chmod_cmd) do |
| + | {_, 0} -> |
| + | # Run the build script with explicit bash to ensure profile is loaded |
| + | build_cmd = "cd #{release_path} && /bin/bash -l -c '#{build_script}'" |
| + | |
| + | case ssh_exec(config, build_cmd) do |
| + | {_, 0} -> :ok |
| + | {output, _} -> {:error, "Build failed: #{output}"} |
| + | end |
| + | {output, _} -> |
| + | {:error, "Failed to make build script executable: #{output}"} |
| + | end |
| + | end |
| + | |
| + | defp update_symlinks(%{release_path: release_path} = config) do |
| + | current_path = "#{config.deploy_to}/current" |
| + | |
| + | # Update current symlink atomically |
| + | update_cmd = """ |
| + | ln -nfs #{release_path} #{current_path}.tmp && \ |
| + | mv -Tf #{current_path}.tmp #{current_path} |
| + | """ |
| + | |
| + | case ssh_exec(config, update_cmd) do |
| + | {_, 0} -> :ok |
| + | {output, _} -> {:error, "Failed to update symlinks: #{output}"} |
| + | end |
| + | end |
| + | |
| + | defp restart_service(config) do |
| + | # Service name is {app_name}-{env} (e.g., testapp-staging, testapp-production) |
| + | service_name = "#{config.app_name}-#{config.env}" |
| + | |
| + | restart_cmd = """ |
| + | sudo systemctl daemon-reload && \ |
| + | sudo systemctl restart #{service_name} && \ |
| + | sudo systemctl status #{service_name} | head -n 10 |
| + | """ |
| + | |
| + | Logger.info("Restarting service: #{service_name}") |
| + | |
| + | case ssh_exec(config, restart_cmd) do |
| + | {output, 0} -> |
| + | Logger.info(output) |
| + | :ok |
| + | {output, _} -> |
| + | {:error, "Failed to restart service: #{output}"} |
| + | end |
| + | end |
| + | |
| + | defp cleanup_old_releases(config) do |
| + | # Keep only the last 5 releases |
| + | cleanup_cmd = """ |
| + | cd #{config.deploy_to}/releases && \ |
| + | ls -t | tail -n +6 | xargs -r rm -rf |
| + | """ |
| + | |
| + | case ssh_exec(config, cleanup_cmd) do |
| + | {_, 0} -> :ok |
| + | {_, _} -> |
| + | Logger.warning("Failed to cleanup old releases") |
| + | :ok # Don't fail deployment for cleanup |
| + | end |
| + | end |
| + | |
| + | defp health_check(config) do |
| + | if url = Map.get(config, :url) do |
| + | Logger.info("Performing health check...") |
| + | |
| + | health_cmd = "curl -fsSL -o /dev/null -w '%{http_code}' --retry 3 --retry-delay 2 #{url}" |
| + | |
| + | case ssh_exec(config, health_cmd) do |
| + | {"200", 0} -> |
| + | Logger.info("✅ Health check passed!") |
| + | :ok |
| + | {status, 0} -> |
| + | Logger.warning("Health check returned status: #{status}") |
| + | :ok # Don't fail for non-200 status |
| + | {output, _} -> |
| + | Logger.warning("Health check failed: #{output}") |
| + | :ok # Don't fail deployment for health check |
| + | end |
| + | else |
| + | :ok |
| + | end |
| + | end |
| + | |
| + | defp ssh_exec(config, command, _opts \\ []), do: Deploy.SSH.ssh_exec(config, command) |
| + | |
| + | defp scp_upload(config, local_file, remote_file), do: Deploy.SSH.scp_upload(config, local_file, remote_file) |
| + | end |
| \ No newline at end of file | |
mix/tasks/deploy.nginx.ex b/lib/mix/tasks/deploy.nginx.ex
+156
-0
| @@ | @@ -0,0 +1,156 @@ |
| + | defmodule Mix.Tasks.Deploy.Nginx do |
| + | use Mix.Task |
| + | |
| + | @shortdoc "Update NGINX configuration on server" |
| + | @moduledoc """ |
| + | Update NGINX configuration for the deployed Phoenix application. |
| + | |
| + | ## Usage |
| + | |
| + | mix deploy.nginx # Update nginx config for production |
| + | mix deploy.nginx staging # Update nginx config for staging |
| + | |
| + | This task will: |
| + | 1. Upload the nginx configuration from deploy/nginx.conf |
| + | 2. Replace template variables with actual values |
| + | 3. Test the configuration |
| + | 4. Reload nginx if the test passes |
| + | """ |
| + | |
| + | require Logger |
| + | |
| + | @impl Mix.Task |
| + | def run(args) do |
| + | # Load deployment config |
| + | Mix.Task.run("loadconfig", ["config/deploy.exs"]) |
| + | |
| + | env = case args do |
| + | [] -> :production |
| + | [env] -> String.to_atom(env) |
| + | _ -> |
| + | Logger.error("Too many arguments. Usage: mix deploy.nginx [environment]") |
| + | exit(1) |
| + | end |
| + | |
| + | config = get_deploy_config(env) |
| + | |
| + | Logger.info("Updating NGINX configuration for #{env} environment...") |
| + | |
| + | with :ok <- check_requirements(config), |
| + | :ok <- upload_nginx_config(config), |
| + | :ok <- test_nginx_config(config), |
| + | :ok <- reload_nginx(config) do |
| + | Logger.info("✅ NGINX configuration updated successfully!") |
| + | else |
| + | {:error, reason} -> |
| + | Logger.error("❌ Failed to update NGINX configuration: #{reason}") |
| + | exit(1) |
| + | end |
| + | end |
| + | |
| + | defp get_deploy_config(env), do: Deploy.SSH.get_deploy_config(env) |
| + | |
| + | defp check_requirements(config) do |
| + | required_keys = [:user, :domain, :url, :app_port, :app_name] |
| + | missing = Enum.filter(required_keys, &(not Map.has_key?(config, &1))) |
| + | |
| + | if Enum.empty?(missing) do |
| + | :ok |
| + | else |
| + | {:error, "Missing required config keys: #{inspect(missing)}"} |
| + | end |
| + | end |
| + | |
| + | defp upload_nginx_config(config) do |
| + | # Check for nginx.conf in deploy directory |
| + | nginx_files = Path.wildcard("deploy/nginx*.conf") |
| + | |
| + | nginx_file = cond do |
| + | "deploy/nginx.conf" in nginx_files -> "deploy/nginx.conf" |
| + | length(nginx_files) > 0 -> hd(nginx_files) |
| + | true -> nil |
| + | end |
| + | |
| + | if nginx_file do |
| + | # Extract server name from URL - used as nginx config name for uniqueness |
| + | server_name = URI.parse(config.url).host || "localhost" |
| + | |
| + | # SSL domain - could be different from server name for wildcard certs |
| + | ssl_domain = Map.get(config, :ssl_domain, server_name) |
| + | |
| + | Logger.info("Uploading nginx configuration from #{nginx_file} for #{server_name}...") |
| + | |
| + | # Read template |
| + | template = File.read!(nginx_file) |
| + | |
| + | # Create unique upstream name from server_name (replace dots/dashes with underscores) |
| + | upstream_name = server_name |> String.replace(~r/[.-]/, "_") |
| + | |
| + | # Replace variables |
| + | content = template |
| + | |> String.replace("${APP_NAME}", config.app_name) |
| + | |> String.replace("${APP_PORT}", to_string(config.app_port)) |
| + | |> String.replace("${SERVER_NAME}", server_name) |
| + | |> String.replace("${SSL_DOMAIN}", ssl_domain) |
| + | |> String.replace("${UPSTREAM_NAME}", upstream_name) |
| + | |> String.replace("${DEPLOY_TO}", Map.get(config, :deploy_to, "/var/www/#{config.app_name}")) |
| + | |
| + | # Create temp file - use server_name for unique filenames per environment |
| + | temp_file = Path.join(System.tmp_dir!(), "nginx-#{server_name}.conf") |
| + | File.write!(temp_file, content) |
| + | |
| + | # Upload to server |
| + | remote_temp = "/tmp/nginx-#{server_name}.conf" |
| + | |
| + | case scp_upload(config, temp_file, remote_temp) do |
| + | {_, 0} -> |
| + | # Move to sites-available - use server_name as config name |
| + | move_cmd = """ |
| + | sudo cp #{remote_temp} /etc/nginx/sites-available/#{server_name} && \ |
| + | sudo ln -sf /etc/nginx/sites-available/#{server_name} /etc/nginx/sites-enabled/#{server_name} |
| + | """ |
| + | |
| + | case ssh_exec(config, move_cmd) do |
| + | {_, 0} -> |
| + | File.rm(temp_file) |
| + | :ok |
| + | {output, _} -> |
| + | File.rm(temp_file) |
| + | {:error, "Failed to install nginx config: #{output}"} |
| + | end |
| + | {output, _} -> |
| + | File.rm(temp_file) |
| + | {:error, "Failed to upload nginx config: #{output}"} |
| + | end |
| + | else |
| + | {:error, "No nginx configuration file found in deploy/"} |
| + | end |
| + | end |
| + | |
| + | defp test_nginx_config(config) do |
| + | Logger.info("Testing nginx configuration...") |
| + | |
| + | case ssh_exec(config, "sudo nginx -t") do |
| + | {output, 0} -> |
| + | Logger.info(output) |
| + | :ok |
| + | {output, _} -> |
| + | {:error, "Nginx configuration test failed: #{output}"} |
| + | end |
| + | end |
| + | |
| + | defp reload_nginx(config) do |
| + | Logger.info("Reloading nginx...") |
| + | |
| + | case ssh_exec(config, "sudo systemctl reload nginx") do |
| + | {_, 0} -> |
| + | :ok |
| + | {output, _} -> |
| + | {:error, "Failed to reload nginx: #{output}"} |
| + | end |
| + | end |
| + | |
| + | defp ssh_exec(config, command), do: Deploy.SSH.ssh_exec(config, command) |
| + | |
| + | defp scp_upload(config, local_file, remote_file), do: Deploy.SSH.scp_upload(config, local_file, remote_file) |
| + | end |
| \ No newline at end of file | |
mix/tasks/deploy.service.ex b/lib/mix/tasks/deploy.service.ex
+159
-0
| @@ | @@ -0,0 +1,159 @@ |
| + | defmodule Mix.Tasks.Deploy.Service do |
| + | use Mix.Task |
| + | |
| + | @shortdoc "Update systemd service on server" |
| + | @moduledoc """ |
| + | Update systemd service configuration for the deployed Phoenix application. |
| + | |
| + | ## Usage |
| + | |
| + | mix deploy.service # Update service for production |
| + | mix deploy.service staging # Update service for staging |
| + | |
| + | This task will: |
| + | 1. Upload the service file from deploy/ |
| + | 2. Replace template variables with actual values |
| + | 3. Reload systemd daemon |
| + | 4. Restart the service |
| + | """ |
| + | |
| + | require Logger |
| + | |
| + | @impl Mix.Task |
| + | def run(args) do |
| + | # Load deployment config |
| + | Mix.Task.run("loadconfig", ["config/deploy.exs"]) |
| + | |
| + | env = case args do |
| + | [] -> :production |
| + | [env] -> String.to_atom(env) |
| + | _ -> |
| + | Logger.error("Too many arguments. Usage: mix deploy.service [environment]") |
| + | exit(1) |
| + | end |
| + | |
| + | config = get_deploy_config(env) |
| + | |
| + | Logger.info("Updating systemd service for #{env} environment...") |
| + | |
| + | with :ok <- check_requirements(config), |
| + | :ok <- upload_service_file(config), |
| + | :ok <- reload_and_restart_service(config) do |
| + | Logger.info("✅ Systemd service updated successfully!") |
| + | else |
| + | {:error, reason} -> |
| + | Logger.error("❌ Failed to update systemd service: #{reason}") |
| + | exit(1) |
| + | end |
| + | end |
| + | |
| + | defp get_deploy_config(env), do: Deploy.SSH.get_deploy_config(env) |
| + | |
| + | defp check_requirements(config) do |
| + | required_keys = [:user, :domain, :deploy_to, :app_port, :app_name] |
| + | missing = Enum.filter(required_keys, &(not Map.has_key?(config, &1))) |
| + | |
| + | if Enum.empty?(missing) do |
| + | :ok |
| + | else |
| + | {:error, "Missing required config keys: #{inspect(missing)}"} |
| + | end |
| + | end |
| + | |
| + | defp upload_service_file(config) do |
| + | # Look for service file |
| + | service_files = Path.wildcard("deploy/*.service") |
| + | |
| + | service_file = case service_files do |
| + | [] -> nil |
| + | [file] -> file |
| + | files -> |
| + | # Prefer one that matches app name |
| + | Enum.find(files, hd(files), &String.contains?(&1, config.app_name)) |
| + | end |
| + | |
| + | if service_file do |
| + | # Service name is {app_name}-{env} (e.g., testapp-staging, testapp-production) |
| + | service_name = "#{config.app_name}-#{config.env}" |
| + | |
| + | Logger.info("Uploading service file from #{service_file} as #{service_name}...") |
| + | |
| + | # Read template |
| + | template = File.read!(service_file) |
| + | |
| + | # Extract host from URL |
| + | host = case URI.parse(Map.get(config, :url, "")) do |
| + | %{host: nil} -> "localhost" |
| + | %{host: h} -> h |
| + | end |
| + | |
| + | # Replace variables |
| + | content = template |
| + | |> String.replace("${DEPLOY_TO}", Map.get(config, :deploy_to, "/var/www/#{config.app_name}")) |
| + | |> String.replace("${APP_PORT}", to_string(config.app_port)) |
| + | |> String.replace("${PHX_HOST}", host) |
| + | |
| + | # Ensure PHX_SERVER=true is set |
| + | content = if not String.contains?(content, "PHX_SERVER") do |
| + | String.replace(content, "Environment=\"PORT=", "Environment=\"PHX_SERVER=true\"\nEnvironment=\"PORT=") |
| + | else |
| + | content |
| + | end |
| + | |
| + | # Create temp file |
| + | temp_file = Path.join(System.tmp_dir!(), "#{service_name}.service") |
| + | File.write!(temp_file, content) |
| + | |
| + | # Upload to server |
| + | remote_temp = "/tmp/#{service_name}.service" |
| + | |
| + | case scp_upload(config, temp_file, remote_temp) do |
| + | {_, 0} -> |
| + | # Install service |
| + | install_cmd = """ |
| + | sudo cp #{remote_temp} /etc/systemd/system/#{service_name}.service && \ |
| + | sudo systemctl daemon-reload |
| + | """ |
| + | |
| + | case ssh_exec(config, install_cmd) do |
| + | {_, 0} -> |
| + | File.rm(temp_file) |
| + | :ok |
| + | {output, _} -> |
| + | File.rm(temp_file) |
| + | {:error, "Failed to install service: #{output}"} |
| + | end |
| + | {output, _} -> |
| + | File.rm(temp_file) |
| + | {:error, "Failed to upload service file: #{output}"} |
| + | end |
| + | else |
| + | {:error, "No service file found in deploy/"} |
| + | end |
| + | end |
| + | |
| + | defp reload_and_restart_service(config) do |
| + | # Service name is {app_name}-{env} (e.g., testapp-staging, testapp-production) |
| + | service_name = "#{config.app_name}-#{config.env}" |
| + | |
| + | Logger.info("Restarting service: #{service_name}") |
| + | |
| + | restart_cmd = """ |
| + | sudo systemctl daemon-reload && \ |
| + | sudo systemctl restart #{service_name} && \ |
| + | sudo systemctl status #{service_name} | head -n 10 |
| + | """ |
| + | |
| + | case ssh_exec(config, restart_cmd) do |
| + | {output, 0} -> |
| + | Logger.info(output) |
| + | :ok |
| + | {output, _} -> |
| + | {:error, "Failed to restart service: #{output}"} |
| + | end |
| + | end |
| + | |
| + | defp ssh_exec(config, command), do: Deploy.SSH.ssh_exec(config, command) |
| + | |
| + | defp scp_upload(config, local_file, remote_file), do: Deploy.SSH.scp_upload(config, local_file, remote_file) |
| + | end |
| \ No newline at end of file | |
mix/tasks/deploy/ssh.ex b/lib/mix/tasks/deploy/ssh.ex
+67
-0
| @@ | @@ -0,0 +1,67 @@ |
| + | defmodule Deploy.SSH do |
| + | @moduledoc """ |
| + | Shared SSH utilities for deployment mix tasks. |
| + | |
| + | Provides ssh_exec/2, scp_upload/3, and get_deploy_config/1 |
| + | used by Mix.Tasks.Deploy, Deploy.Service, and Deploy.Nginx. |
| + | """ |
| + | |
| + | require Logger |
| + | |
| + | @doc """ |
| + | Load and merge deployment config for the given environment. |
| + | Returns a map with all config keys merged from base + env. |
| + | """ |
| + | def get_deploy_config(env) do |
| + | base_config = Application.get_all_env(:deploy) |
| + | env_config = Keyword.get(base_config, env, []) |
| + | |
| + | if env_config == [] do |
| + | Logger.error("No configuration found for environment: #{env}") |
| + | exit(1) |
| + | end |
| + | |
| + | app_name = Keyword.get(base_config, :app_name) || (Mix.Project.config()[:app] |> to_string()) |
| + | |
| + | base_config |
| + | |> Keyword.delete(:production) |
| + | |> Keyword.delete(:staging) |
| + | |> Keyword.merge(env_config) |
| + | |> Keyword.put(:env, env) |
| + | |> Keyword.put(:app_name, app_name) |
| + | |> Map.new() |
| + | end |
| + | |
| + | @doc """ |
| + | Execute a command on the remote server via SSH. |
| + | Returns {output, exit_code}. |
| + | """ |
| + | def ssh_exec(config, command) do |
| + | port_opt = if config.port && config.port != 22, do: "-p #{config.port}", else: "" |
| + | |
| + | ssh_command = """ |
| + | ssh -T -A -o ConnectTimeout=10 #{port_opt} \ |
| + | #{config.user}@#{config.domain} '#{command}' |
| + | """ |
| + | |
| + | Logger.debug("SSH command: #{inspect(ssh_command)}") |
| + | Logger.debug("Remote command: #{inspect(command)}") |
| + | |
| + | System.cmd("bash", ["-c", ssh_command], stderr_to_stdout: true) |
| + | end |
| + | |
| + | @doc """ |
| + | Upload a local file to the remote server via SCP. |
| + | Returns {output, exit_code}. |
| + | """ |
| + | def scp_upload(config, local_file, remote_file) do |
| + | port_opt = if config.port && config.port != 22, do: "-P #{config.port}", else: "" |
| + | |
| + | scp_command = """ |
| + | scp #{port_opt} #{local_file} \ |
| + | #{config.user}@#{config.domain}:#{remote_file} |
| + | """ |
| + | |
| + | System.cmd("bash", ["-c", scp_command], stderr_to_stdout: true) |
| + | end |
| + | end |
rel/env.sh.eex
+17
-0
| @@ | @@ -0,0 +1,17 @@ |
| + | #!/bin/sh |
| + | |
| + | # Load environment file first (needed for unique node naming) |
| + | # The .env is symlinked from shared/ into the release directory |
| + | # RELEASE_ROOT is like /var/www/app/current/_build/prod/rel/app |
| + | # We need to go up to /var/www/app/current/.env |
| + | RELEASE_DIR="$(cd "${RELEASE_ROOT}/../../../.." 2>/dev/null && pwd)" |
| + | if [ -f "${RELEASE_DIR}/.env" ]; then |
| + | set -a |
| + | . "${RELEASE_DIR}/.env" |
| + | set +a |
| + | fi |
| + | |
| + | # VPS deployment - use simple node naming (no distributed Erlang needed) |
| + | # Node name derived from PHX_HOST to ensure uniqueness per environment |
| + | export RELEASE_DISTRIBUTION="sname" |
| + | export RELEASE_NODE="pi_day_$(echo ${PHX_HOST:-localhost} | tr '.-' '_')" |