Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,16 @@

### Changed

- **Local wears the host OS's mark.** The container sees only Linux, so
`make serve` names the machine's OS (macOS, Windows, a Linux distro by
its os-release, Omarchy by its own directory) as `HOST_OS`/`HOST_OS_URL`;
the Overseer's Local card wears that page's favicon and says which OS.

- **Deployments' actions fold into a Deploy menu and More.** Both deploys
sit behind one Deploy menu, each with a line on what it costs
(`menu_item`'s new `hint`); Back up now stays a button; Rehearse a
restore joins rolling back and destroying in More.

- **What a stack is made of, said once and read everywhere.** Services group
by their `category` (App, AI, Data) in `aegis services` and the setup
wizard, `ServiceType` gone; the wizard asks "Add Payment? Stripe checkout,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -396,10 +396,11 @@ <h2 id="{{ card_id }}-title" class="text-sm font-semibold text-aegis-text">{{ ti

{# One entry in a dropdown() menu. ``attrs`` carries the htmx verb
(``hx-get``/``hx-post``/``hx-delete`` plus target and swap); the menu
closes itself on the click, so a dialog never opens over it. #}
{% macro menu_item(label, attrs, danger=False) %}
closes itself on the click, so a dialog never opens over it.
``hint``: a muted line under the label, what choosing it costs. #}
{% macro menu_item(label, attrs, danger=False, hint=None) %}
<li><button type="button" {{ attrs|safe }} @click="$el.closest('details').removeAttribute('open')"
class="w-full text-left px-3 py-1.5 text-sm {{ 'text-error' if danger else 'text-aegis-text' }} hover:bg-aegis-border/30 rounded transition-colors">{{ label }}</button></li>
class="w-full text-left px-3 py-1.5 text-sm {{ 'text-error' if danger else 'text-aegis-text' }} hover:bg-aegis-border/30 rounded transition-colors">{{ label }}{% if hint %}<span data-hint class="block text-xs text-aegis-muted">{{ hint }}</span>{% endif %}</button></li>
{% endmacro %}

{# A menu behind a button. It opens below on the ``align`` side; app.js
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@
one of four views. A deployed server says where redeploys come from.
Without a server yet, the way to one (New server); one being made,
the way to it there. With a server, the
server, what can be asked of ``aegis deploy-agent`` for it (rolling
back and destroying folded into More), and, while one runs, its newest
server, what can be asked of ``aegis deploy-agent`` for it (deploys
in one menu; rehearsing, rolling back and destroying folded into More), and, while one runs, its newest
job (whole under Jobs). #}
{% from "components/macros/layout.html" import card, dropdown, facts %}
{% from "pages/overseer/deployments/_macros.html" import agent_note, ask, deployed_card, destroy_form, goto, job_panel, note, place %}
{% from "pages/overseer/deployments/_macros.html" import agent_note, ask, ask_item, deployed_card, destroy_form, goto, job_panel, note, place %}
{% if actions.view == "deployed" %}
{{ deployed_card(actions) }}
{% elif actions.view == "no-server" %}
Expand All @@ -27,13 +27,17 @@
{% with live = actions.live %}{% include "pages/overseer/deployments/_live.html" %}{% endwith %}
{% with storage = actions.storage %}{% include "pages/overseer/deployments/_storage.html" %}{% endwith %}
<div data-idle class="flex flex-wrap items-center gap-2">
{{ ask("Deploy", actions.partials, "deploy", "primary") }}
{# Keeps the site up and nobody signed out; additive migrations only. #}
{{ ask("Deploy without downtime", actions.partials, "deploy-rolling") }}
{# Both deploys behind one menu, each saying what it costs. #}
{% call dropdown("Deploy", align="left") %}
{{ ask_item("Deploy", actions.partials, "deploy", hint="Restarts everything; a moment offline") }}
{# Additive migrations only. #}
{{ ask_item("Deploy without downtime", actions.partials, "deploy-rolling", hint="Site stays up, nobody signed out") }}
{% endcall %}
{{ ask("Back up now", actions.partials, "backup") }}
{% if actions.drill %}{{ ask(actions.drill.label, actions.partials, actions.drill.verb) }}{% endif %}
{# Rolling back and destroying are rare: folded into a menu, asked there. #}
{# Rehearsing a restore, rolling back and destroying are rare: folded
into a menu, asked there. #}
{% call dropdown("More", align="left") %}
{% if actions.drill %}{{ ask_item(actions.drill.label, actions.partials, actions.drill.verb) }}{% endif %}
<li data-rollback-item class="w-80 p-2">{% with r = actions.rollback %}{% include "pages/overseer/deployments/_rollback.html" %}{% endwith %}</li>
{% if actions.destroy %}<li data-destroy class="w-80 p-2 border-t border-aegis-border">{{ destroy_form(actions.partials, actions.destroy) }}</li>{% endif %}
{% endcall %}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,9 +74,15 @@

{% macro asks(url) %}data-write hx-post="{{ url }}" hx-target="#deploy-job" hx-swap="innerHTML"{% endmacro %}

{% macro ask_attrs(partials, verb) %}{{ asks(partials ~ "/" ~ verb) }} data-ask="{{ verb }}"{% endmacro %}

{% macro ask(label, partials, verb, tone="default") %}
{% set attrs %}{{ asks(partials ~ "/" ~ verb) }} data-ask="{{ verb }}"{% endset %}
{{ action(label, attrs, tone) }}
{{ action(label, ask_attrs(partials, verb), tone) }}
{% endmacro %}

{# ``ask`` as a row of a dropdown() menu. #}
{% macro ask_item(label, partials, verb, hint=None) %}
{{ menu_item(label, ask_attrs(partials, verb), hint=hint) }}
{% endmacro %}

{% macro destroy_form(partials, name) %}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@
``running_on()``: the cloud this server is on, read from that cloud's own
metadata service at 169.254.169.254, which every one of them answers on the
server itself and nowhere else. Asked once per process (a server does not
move); nothing answering is ``Local`` before any deploy, ``Self-hosted``
after. ``deploys_to()``: where this project deploys to, from ``aegis
move); nothing answering is ``Local`` before any deploy (wearing the
mark of the OS ``make serve`` named), ``Self-hosted`` after.
``deploys_to()``: where this project deploys to, from ``aegis
deploy-provision``'s record in ``.aegis/deploy.yml``, which stays on the
machine you deploy from (it is never synced to the server). ``providers()``:
the providers ``aegis`` knows, and what it can do on each, from
Expand All @@ -18,14 +19,15 @@
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
import json
import os
from pathlib import Path
from typing import Any

import httpx
import yaml

from app.core import exposure
from app.core.brand_icons import favicon_url
from app.core.brand_icons import domain_of, favicon_url

METADATA = "http://169.254.169.254"
PROBE_SECONDS = 0.5 # a link-local answer is instant; off a cloud, nothing is
Expand Down Expand Up @@ -88,13 +90,18 @@ def provider(key: str | None) -> Provider | None:


def _where(
key: str | None, name: str, facts: list[tuple[str, Any]], console: str | None
key: str | None,
name: str,
facts: list[tuple[str, Any]],
console: str | None,
logo: str | None = None,
) -> dict[str, Any]:
"""``logo``: where no provider is, a mark of its own (Local's OS)."""
provider = BY_KEY.get(key or "")
return {
"key": key,
"name": name,
"logo": provider.logo if provider else None,
"logo": provider.logo if provider else logo,
"facts": [
(label, str(value)) for label, value in facts if value not in (None, "")
],
Expand Down Expand Up @@ -199,8 +206,13 @@ async def ask(
for where in found:
if where is not None:
return where
name = "Self-hosted" if exposure.deployed() else "Local"
return _where(None, name, [], None)
if exposure.deployed():
return _where(None, "Self-hosted", [], None)
# The machine running Docker, as make serve names it (a fact it stamps,
# not a setting anyone configures).
domain = domain_of(os.environ.get("HOST_OS_URL"))
logo = favicon_url(domain) if domain else None
return _where(None, "Local", [("OS", os.environ.get("HOST_OS"))], None, logo)


def deploy_config(path: Path = DEPLOY_CONFIG) -> dict[str, Any]:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ services:
- "127.0.0.1:${WEBSERVER_HOST_PORT:-${PORT:-8000}}:${PORT:-8000}"
environment:
- WATCHFILES_FORCE_POLLING=true
# The machine running Docker (make serve names it): Local's mark.
- HOST_OS=${HOST_OS:-}
- HOST_OS_URL=${HOST_OS_URL:-}
{%- if include_finance and finance_plaid %}
- PLAID_TUNNEL_METRICS_URL=http://plaid-tunnel:20241
{%- endif %}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ from __future__ import annotations

import argparse
import os
import platform
import shutil
import subprocess
import sys
Expand Down Expand Up @@ -106,6 +107,23 @@ def _deploy_agent_ready(env: dict[str, str]) -> None:
{%- endif %}


def _host_os() -> tuple[str, str]:
"""This machine's OS and its home page (for its mark), which the
containers cannot see: they run Linux whatever runs Docker. Omarchy
keeps Arch's os-release, so its own directory names it."""
if sys.platform == "darwin":
return "macOS", "https://apple.com"
if sys.platform == "win32":
return "Windows", "https://microsoft.com"
if (Path.home() / ".local/share/omarchy").is_dir():
return "Omarchy", "https://omarchy.org"
try:
release = platform.freedesktop_os_release()
except OSError:
return "Linux", "https://kernel.org"
return release.get("NAME", "Linux"), release.get("HOME_URL", "https://kernel.org")


def serve(*, detach: bool = False) -> None:
print(
"Starting services in background..."
Expand Down Expand Up @@ -135,6 +153,7 @@ def serve(*, detach: bool = False) -> None:
cmd = [*_COMPOSE_DEV, *profiles, "up", "--remove-orphans"]
if detach:
cmd.append("-d")
env["HOST_OS"], env["HOST_OS_URL"] = _host_os()
{%- if include_deploy and include_redis %}
_deploy_agent_ready(env)
{%- endif %}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import httpx
import pytest

from app.core.brand_icons import favicon_url
from app.core.config import settings
from app.services.system import hosting

Expand Down Expand Up @@ -96,6 +97,28 @@ async def test_no_cloud_is_local_or_self_hosted(
assert (found["key"], found["name"]) == (None, name)


async def test_local_wears_the_host_os_mark(monkeypatch: pytest.MonkeyPatch) -> None:
"""``make serve`` names the machine's OS (the container only knows
Linux): Local wears its mark, and says which."""
monkeypatch.setattr(settings, "BUILD_ID", "dev")
monkeypatch.setenv("HOST_OS", "Arch Linux")
monkeypatch.setenv("HOST_OS_URL", "https://archlinux.org/")
found = await hosting.running_on(transport=_cloud({}))
assert found["name"] == "Local"
assert found["logo"] == favicon_url("archlinux.org")
assert ("OS", "Arch Linux") in found["facts"]


async def test_a_server_never_wears_the_deploying_machines_os(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(settings, "BUILD_ID", "abc1234")
monkeypatch.setenv("HOST_OS", "macOS")
monkeypatch.setenv("HOST_OS_URL", "https://apple.com")
found = await hosting.running_on(transport=_cloud({}))
assert (found["logo"], found["facts"]) == (None, [])


async def test_it_asks_once_per_process() -> None:
asked: list[str] = []

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
"""`make serve` names the machine's OS for the Overseer's Local card: the
container sees only Linux, whatever runs Docker."""

from __future__ import annotations

from pathlib import Path
import platform
import sys

import pytest

from scripts import dev_tasks


@pytest.fixture
def linux(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Path:
"""A Linux machine with an empty home; returns the home."""
monkeypatch.setattr(sys, "platform", "linux")
monkeypatch.setattr(Path, "home", lambda: tmp_path)
return tmp_path


@pytest.mark.parametrize(
("plat", "expected"),
[
("darwin", ("macOS", "https://apple.com")),
("win32", ("Windows", "https://microsoft.com")),
],
)
def test_mac_and_windows_by_platform(
monkeypatch: pytest.MonkeyPatch, plat: str, expected: tuple[str, str]
) -> None:
monkeypatch.setattr(sys, "platform", plat)
assert dev_tasks._host_os() == expected


def test_a_linux_distro_names_itself(
monkeypatch: pytest.MonkeyPatch, linux: Path
) -> None:
release = {"NAME": "Arch Linux", "HOME_URL": "https://archlinux.org/"}
monkeypatch.setattr(platform, "freedesktop_os_release", lambda: release)
assert dev_tasks._host_os() == ("Arch Linux", "https://archlinux.org/")


def test_omarchy_is_arch_set_up_its_own_way(
monkeypatch: pytest.MonkeyPatch, linux: Path
) -> None:
"""Omarchy keeps Arch's os-release; its own directory gives it away."""
(linux / ".local/share/omarchy").mkdir(parents=True)
release = {"NAME": "Arch Linux", "HOME_URL": "https://archlinux.org/"}
monkeypatch.setattr(platform, "freedesktop_os_release", lambda: release)
assert dev_tasks._host_os() == ("Omarchy", "https://omarchy.org")


def test_a_linux_without_os_release_is_linux(
monkeypatch: pytest.MonkeyPatch, linux: Path
) -> None:
def missing() -> dict[str, str]:
raise OSError

monkeypatch.setattr(platform, "freedesktop_os_release", missing)
assert dev_tasks._host_os() == ("Linux", "https://kernel.org")
Original file line number Diff line number Diff line change
Expand Up @@ -1780,3 +1780,24 @@ def test_a_deploy_can_keep_the_site_up(
TestClient(app).post(f"{actions.PARTIALS}/deploy-rolling")
assert _queued(redis)[-1]["action"] == "deploy-rolling"
assert PROTOCOL["actions"]["deploy-rolling"]["command"] == ["deploy", "--rolling"]


def test_the_row_is_a_deploy_menu_back_up_and_more(
redis: FakeRedis, app: FastAPI, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Five buttons were clutter (#556): both deploys sit behind one
Deploy menu, each saying what it costs; a restore rehearsal is rare,
so it is in More with rolling back and destroying."""
deployed(monkeypatch, {"server": BOX, "provision": READY})
row = one(_card(TestClient(app), PAGE), "[data-idle]")
menus = select(row, ":scope > details[data-dropdown]")
assert [text(one(m, "summary")) for m in menus] == ["Deploy", "More"]
deploys = menus[0]
assert [b.get("data-ask") for b in select(deploys, "[data-ask]")] == [
"deploy",
"deploy-rolling",
]
assert all(text(one(b, "[data-hint]")) for b in select(deploys, "[data-ask]"))
assert [b.get("data-ask") for b in select(row, ":scope > [data-ask]")] == ["backup"]
if actions.DRILLS:
one(menus[1], '[data-ask="restore-drill"]')
Loading