diff --git a/CHANGELOG.md b/CHANGELOG.md index a0c48e896..933dbd670 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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, diff --git a/aegis/templates/copier-aegis-project/{{ project_slug }}/app/components/web_frontend/templates/components/macros/layout.html b/aegis/templates/copier-aegis-project/{{ project_slug }}/app/components/web_frontend/templates/components/macros/layout.html index baf0fe530..86fa5d4a7 100644 --- a/aegis/templates/copier-aegis-project/{{ project_slug }}/app/components/web_frontend/templates/components/macros/layout.html +++ b/aegis/templates/copier-aegis-project/{{ project_slug }}/app/components/web_frontend/templates/components/macros/layout.html @@ -396,10 +396,11 @@

{{ 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) %}
  • + 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 %}{{ hint }}{% endif %} {% endmacro %} {# A menu behind a button. It opens below on the ``align`` side; app.js diff --git a/aegis/templates/copier-aegis-project/{{ project_slug }}/app/components/web_frontend/templates/pages/overseer/deployments/_actions.html b/aegis/templates/copier-aegis-project/{{ project_slug }}/app/components/web_frontend/templates/pages/overseer/deployments/_actions.html index def51747c..d90286e66 100644 --- a/aegis/templates/copier-aegis-project/{{ project_slug }}/app/components/web_frontend/templates/pages/overseer/deployments/_actions.html +++ b/aegis/templates/copier-aegis-project/{{ project_slug }}/app/components/web_frontend/templates/pages/overseer/deployments/_actions.html @@ -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" %} @@ -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 %}
    - {{ 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 %}
  • {% with r = actions.rollback %}{% include "pages/overseer/deployments/_rollback.html" %}{% endwith %}
  • {% if actions.destroy %}
  • {{ destroy_form(actions.partials, actions.destroy) }}
  • {% endif %} {% endcall %} diff --git a/aegis/templates/copier-aegis-project/{{ project_slug }}/app/components/web_frontend/templates/pages/overseer/deployments/_macros.html b/aegis/templates/copier-aegis-project/{{ project_slug }}/app/components/web_frontend/templates/pages/overseer/deployments/_macros.html index c1cbcffff..c70f71feb 100644 --- a/aegis/templates/copier-aegis-project/{{ project_slug }}/app/components/web_frontend/templates/pages/overseer/deployments/_macros.html +++ b/aegis/templates/copier-aegis-project/{{ project_slug }}/app/components/web_frontend/templates/pages/overseer/deployments/_macros.html @@ -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) %} diff --git a/aegis/templates/copier-aegis-project/{{ project_slug }}/app/services/system/hosting.py b/aegis/templates/copier-aegis-project/{{ project_slug }}/app/services/system/hosting.py index fc557f115..c4cdbacc4 100644 --- a/aegis/templates/copier-aegis-project/{{ project_slug }}/app/services/system/hosting.py +++ b/aegis/templates/copier-aegis-project/{{ project_slug }}/app/services/system/hosting.py @@ -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 @@ -18,6 +19,7 @@ from collections.abc import Awaitable, Callable from dataclasses import dataclass import json +import os from pathlib import Path from typing import Any @@ -25,7 +27,7 @@ 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 @@ -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, "") ], @@ -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]: diff --git a/aegis/templates/copier-aegis-project/{{ project_slug }}/docker-compose.dev.yml.jinja b/aegis/templates/copier-aegis-project/{{ project_slug }}/docker-compose.dev.yml.jinja index fd4ee4b05..b89237a92 100644 --- a/aegis/templates/copier-aegis-project/{{ project_slug }}/docker-compose.dev.yml.jinja +++ b/aegis/templates/copier-aegis-project/{{ project_slug }}/docker-compose.dev.yml.jinja @@ -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 %} diff --git a/aegis/templates/copier-aegis-project/{{ project_slug }}/scripts/dev_tasks.py.jinja b/aegis/templates/copier-aegis-project/{{ project_slug }}/scripts/dev_tasks.py.jinja index 02a8e4724..598171939 100644 --- a/aegis/templates/copier-aegis-project/{{ project_slug }}/scripts/dev_tasks.py.jinja +++ b/aegis/templates/copier-aegis-project/{{ project_slug }}/scripts/dev_tasks.py.jinja @@ -10,6 +10,7 @@ from __future__ import annotations import argparse import os +import platform import shutil import subprocess import sys @@ -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..." @@ -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 %} diff --git a/aegis/templates/copier-aegis-project/{{ project_slug }}/tests/services/test_hosting.py b/aegis/templates/copier-aegis-project/{{ project_slug }}/tests/services/test_hosting.py index 2a559b989..0d3544759 100644 --- a/aegis/templates/copier-aegis-project/{{ project_slug }}/tests/services/test_hosting.py +++ b/aegis/templates/copier-aegis-project/{{ project_slug }}/tests/services/test_hosting.py @@ -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 @@ -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] = [] diff --git a/aegis/templates/copier-aegis-project/{{ project_slug }}/tests/test_dev_tasks_host_os.py b/aegis/templates/copier-aegis-project/{{ project_slug }}/tests/test_dev_tasks_host_os.py new file mode 100644 index 000000000..9d2c1888f --- /dev/null +++ b/aegis/templates/copier-aegis-project/{{ project_slug }}/tests/test_dev_tasks_host_os.py @@ -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") diff --git a/aegis/templates/copier-aegis-project/{{ project_slug }}/tests/web/test_overseer_deploy_actions.py b/aegis/templates/copier-aegis-project/{{ project_slug }}/tests/web/test_overseer_deploy_actions.py index 55df5e3d5..783a2dbb8 100644 --- a/aegis/templates/copier-aegis-project/{{ project_slug }}/tests/web/test_overseer_deploy_actions.py +++ b/aegis/templates/copier-aegis-project/{{ project_slug }}/tests/web/test_overseer_deploy_actions.py @@ -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"]')