From 0f4520d9872c050fa8852f221c432f6e75797c81 Mon Sep 17 00:00:00 2001 From: giles Date: Thu, 5 Mar 2026 16:39:21 +0000 Subject: [PATCH] Add standalone mode for sx-web.org deployment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SX_STANDALONE=true env var: no OAuth, no root header, no cross-service fragments. Same image runs in both rose-ash cooperative and standalone. - Factory: added no_oauth parameter to create_base_app() - Standalone layout defcomps skip ~root-header-auto/~root-mobile-auto - Fixed Dockerfile: was missing sx/sx/ component directory copy - CI: deploys sx-web swarm stack on main branch when sx changes - Stack config at ~/sx-web/ (Caddy → sx_docs, Redis) Co-Authored-By: Claude Opus 4.6 --- .gitea/workflows/ci.yml | 14 ++++++++++- shared/infrastructure/factory.py | 5 ++-- sx/Dockerfile | 1 + sx/app.py | 24 ++++++++++++++++-- sx/sx/layouts.sx | 42 ++++++++++++++++++++++++++++++++ sx/sxc/pages/layouts.py | 22 ++++++++++++++--- 6 files changed, 100 insertions(+), 8 deletions(-) diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 4d38535..1eec7f5 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -84,13 +84,25 @@ jobs: fi done - # Deploy swarm stack only on main branch + # Deploy swarm stacks only on main branch if [ '${{ github.ref_name }}' = 'main' ]; then source .env docker stack deploy -c docker-compose.yml rose-ash echo 'Waiting for swarm services to update...' sleep 10 docker stack services rose-ash + + # Deploy sx-web standalone stack (sx-web.org) + SX_REBUILT=false + if [ \"\$REBUILD_ALL\" = true ] || echo \"\$CHANGED\" | grep -q '^sx/'; then + SX_REBUILT=true + fi + if [ \"\$SX_REBUILT\" = true ]; then + echo 'Deploying sx-web stack (sx-web.org)...' + docker stack deploy -c /root/sx-web/docker-compose.yml sx-web + sleep 5 + docker stack services sx-web + fi else echo 'Skipping swarm deploy (branch: ${{ github.ref_name }})' fi diff --git a/shared/infrastructure/factory.py b/shared/infrastructure/factory.py index 601012f..ce99672 100644 --- a/shared/infrastructure/factory.py +++ b/shared/infrastructure/factory.py @@ -47,6 +47,7 @@ def create_base_app( context_fn: Callable[[], Awaitable[dict]] | None = None, before_request_fns: Sequence[Callable[[], Awaitable[None]]] | None = None, domain_services_fn: Callable[[], None] | None = None, + no_oauth: bool = False, ) -> Quart: """ Create a Quart app with shared infrastructure. @@ -156,7 +157,7 @@ def create_base_app( # Auto-register OAuth client blueprint for non-account apps # (account is the OAuth authorization server) _NO_OAUTH = {"account"} - if name not in _NO_OAUTH: + if name not in _NO_OAUTH and not no_oauth: from shared.infrastructure.oauth import create_oauth_blueprint app.register_blueprint(create_oauth_blueprint(name)) @@ -205,7 +206,7 @@ def create_base_app( # Auth state check via grant verification + silent OAuth handshake # MUST run before _load_user so stale sessions are cleared first - if name not in _NO_OAUTH: + if name not in _NO_OAUTH and not no_oauth: @app.before_request async def _check_auth_state(): from quart import session as qs diff --git a/sx/Dockerfile b/sx/Dockerfile index 166a76d..7a12ba6 100644 --- a/sx/Dockerfile +++ b/sx/Dockerfile @@ -26,6 +26,7 @@ COPY sx/ ./sx-app-tmp/ RUN cp -r sx-app-tmp/app.py sx-app-tmp/path_setup.py \ sx-app-tmp/bp sx-app-tmp/sxc sx-app-tmp/services \ sx-app-tmp/content sx-app-tmp/__init__.py ./ 2>/dev/null || true && \ + ([ -d sx-app-tmp/sx ] && cp -r sx-app-tmp/sx ./sx || true) && \ rm -rf sx-app-tmp # Sibling models for cross-domain SQLAlchemy imports diff --git a/sx/app.py b/sx/app.py index 92a0c50..5d19739 100644 --- a/sx/app.py +++ b/sx/app.py @@ -1,10 +1,12 @@ from __future__ import annotations +import os import path_setup # noqa: F401 -from shared.infrastructure.factory import create_base_app from bp import register_pages from services import register_domain_services +SX_STANDALONE = os.getenv("SX_STANDALONE") == "true" + async def sx_docs_context() -> dict: """SX docs app context processor — fetches cross-service fragments.""" @@ -39,11 +41,29 @@ async def sx_docs_context() -> dict: return ctx +async def sx_standalone_context() -> dict: + """Minimal context for standalone mode — no cross-service fragments.""" + from shared.infrastructure.context import base_context + ctx = await base_context() + ctx["menu_items"] = [] + ctx["cart_mini"] = "" + ctx["auth_menu"] = "" + ctx["nav_tree"] = "" + return ctx + + def create_app() -> "Quart": + from shared.infrastructure.factory import create_base_app + + extra_kw = {} + if SX_STANDALONE: + extra_kw["no_oauth"] = True + app = create_base_app( "sx", - context_fn=sx_docs_context, + context_fn=sx_standalone_context if SX_STANDALONE else sx_docs_context, domain_services_fn=register_domain_services, + **extra_kw, ) from sxc.pages import setup_sx_pages diff --git a/sx/sx/layouts.sx b/sx/sx/layouts.sx index fd983c9..6e08b12 100644 --- a/sx/sx/layouts.sx +++ b/sx/sx/layouts.sx @@ -93,3 +93,45 @@ :label "sx" :href "/" :level 1 :colour "violet" :items (~sx-main-nav :section section)) (~root-mobile-auto))) + +;; --------------------------------------------------------------------------- +;; Standalone layouts (no root header, no auth — for sx-web.org) +;; --------------------------------------------------------------------------- + +(defcomp ~sx-standalone-layout-full (&key section) + (~sx-header-row :nav (~sx-main-nav :section section))) + +(defcomp ~sx-standalone-layout-oob (&key section) + (<> (~sx-header-row + :nav (~sx-main-nav :section section) + :oob true) + (~clear-oob-div :id "sx-header-child"))) + +(defcomp ~sx-standalone-layout-mobile (&key section) + (~mobile-menu-section + :label "sx" :href "/" :level 1 :colour "violet" + :items (~sx-main-nav :section section))) + +(defcomp ~sx-standalone-section-layout-full (&key section sub-label sub-href sub-nav selected) + (~sx-header-row + :nav (~sx-main-nav :section section) + :child (~sx-sub-row :sub-label sub-label :sub-href sub-href + :sub-nav sub-nav :selected selected))) + +(defcomp ~sx-standalone-section-layout-oob (&key section sub-label sub-href sub-nav selected) + (<> (~oob-header-sx :parent-id "sx-header-child" + :row (~sx-sub-row :sub-label sub-label :sub-href sub-href + :sub-nav sub-nav :selected selected)) + (~sx-header-row + :nav (~sx-main-nav :section section) + :oob true))) + +(defcomp ~sx-standalone-section-layout-mobile (&key section sub-label sub-href sub-nav) + (<> + (when sub-nav + (~mobile-menu-section + :label (or sub-label section) :href sub-href :level 2 :colour "violet" + :items sub-nav)) + (~mobile-menu-section + :label "sx" :href "/" :level 1 :colour "violet" + :items (~sx-main-nav :section section)))) diff --git a/sx/sxc/pages/layouts.py b/sx/sxc/pages/layouts.py index aee86a9..f04a965 100644 --- a/sx/sxc/pages/layouts.py +++ b/sx/sxc/pages/layouts.py @@ -1,11 +1,27 @@ """SX docs layout registration — all layouts delegate to .sx defcomps.""" from __future__ import annotations +import os def _register_sx_layouts() -> None: """Register the sx docs layout presets.""" from shared.sx.layouts import register_sx_layout - register_sx_layout("sx", "sx-layout-full", "sx-layout-oob", "sx-layout-mobile") - register_sx_layout("sx-section", "sx-section-layout-full", - "sx-section-layout-oob", "sx-section-layout-mobile") + if os.getenv("SX_STANDALONE") == "true": + register_sx_layout("sx", + "sx-standalone-layout-full", + "sx-standalone-layout-oob", + "sx-standalone-layout-mobile") + register_sx_layout("sx-section", + "sx-standalone-section-layout-full", + "sx-standalone-section-layout-oob", + "sx-standalone-section-layout-mobile") + else: + register_sx_layout("sx", + "sx-layout-full", + "sx-layout-oob", + "sx-layout-mobile") + register_sx_layout("sx-section", + "sx-section-layout-full", + "sx-section-layout-oob", + "sx-section-layout-mobile")