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
18 changes: 14 additions & 4 deletions .github/workflows/ci-pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -58,9 +58,12 @@ jobs:
fetch-depth: 0

- name: Run Gitleaks
uses: gitleaks/gitleaks-action@v2
env:
GITLEAKS_LICENSE: ${{ secrets.GITLEAKS_LICENSE }}
run: |
docker run --rm \
-v "$GITHUB_WORKSPACE:/repo" \
-w /repo \
zricethezav/gitleaks:v8.30.1 \
detect --source=/repo --redact

# ── Change detection (skip irrelevant jobs) ──
changes:
Expand Down Expand Up @@ -306,7 +309,7 @@ jobs:
timeout-minutes: 2
steps:
- uses: actions/checkout@v4
- uses: hadolint/hadolint-action@v3
- uses: hadolint/hadolint-action@v3.4.0
with:
dockerfile: docker/Dockerfile
ignore: DL3008,DL3013
Expand Down Expand Up @@ -336,10 +339,17 @@ jobs:
steps:
- uses: actions/checkout@v4

- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: '3.11'

- name: Install validator dependencies
run: |
sudo apt-get update
sudo apt-get install -y jq rsync wget zip
python -m pip install --upgrade pip
python -m pip install -r apphub/requirements.txt pytest

- name: Smoke test AppHub appstore sync entrypoints
run: |
Expand Down
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,20 @@ All notable changes to Websoft9 are documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [2.4.0-dev] - 2026-08-21

### Added
- **Scheduled Tasks** — Add managed scheduled tasks for recurring platform operations.
- **External Database Profiles** — Add install profiles for applications that use externally managed databases, including WordPress with external MySQL.
- **Platform Readiness** — Add a readiness endpoint and wait for the platform to become ready before the setup wizard proceeds.

### Changed
- **Installation** — Use the major-minor image tag for fresh installations, while upgrades continue to use the full release version.
- **Image Pulling** — Use registry mirror fallback when pulling utility images.

### Fixed
- **Terminal File Uploads** — Remove the gateway's default 1 MB API request limit for file uploads.

## [2.3.4] - 2026-08-05

### Fixed
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
---
title: 'WordPress 外部 MySQL 安装模式'
type: 'feature'
created: '2026-08-06'
status: 'in-progress'
baseline_commit: 'a1748d3a'
context:
- '{project-root}/docs/wordpress-external-mysql-pilot.md'
---

<frozen-after-approval reason="human-owned intent — do not modify unless human renegotiates">

## Intent

**Problem:** App Store 已能从 Docker Library 发现 WordPress 的 `external-mysql` profile,但 Console 无法选择该模式,AppHub 也无法安全物化模板或初始化外部数据库。

**Approach:** 保持默认安装请求、模板和下游 Gitea/Portainer 流程不变;为可选 profile 增加通用前端选择、受限后端解析和 WordPress 外部 MySQL 初始化。

## Boundaries & Constraints

**Always:** 默认模式不发送 profile 字段且使用原始 `docker-compose.yml`/`.env`;profile 必须由本地完整模板对重新验证;最终仓库固定只有 `docker-compose.yml` 和 `.env`;管理员密码不得写入日志、状态、响应或最终 `.env`;运行账号使用现有 `W9_POWER_PASSWORD` 生成逻辑。

**Ask First:** 支持远程 Endpoint、其他数据库引擎、TLS、已安装应用迁移、外部资源自动清理、持久化管理员凭据。

**Never:** 为 WordPress 在 Console 硬编码字段或模式;信任客户端 profile 字段白名单;修改默认 WordPress 的安装与部署语义;删除外部数据库或账号。

## I/O & Edge-Case Matrix

| Scenario | Input / State | Expected Output / Behavior | Error Handling |
|----------|---------------|----------------------------|----------------|
| 默认安装 | 无 `profile` | 原请求与默认模板保持不变 | 沿用现有安装错误 |
| 外部 MySQL | 完整 profile 对及合规参数 | 物化外部模板,建库和低权限账号,部署 WordPress | 管理员密码不持久化 |
| 伪造 profile | profile 缺失、名称非法或字段超出 `.env.<profile>` | 在启动异步安装前拒绝 | 400,不回显秘密 |
| 数据库失败 | 无法连接、同名数据库、权限不足 | 不创建 Git/Stack,返回可操作的无秘密错误 | 不清理已创建外部资源 |

</frozen-after-approval>

## Code Map

- `console/src/features/app-store/app-store-model.ts` -- App Store 前端模型。
- `console/src/features/app-store/use-app-store-apps.ts` -- 合并静态安装 metadata。
- `console/src/features/app-store/app-store-page.tsx` -- 通用安装表单和请求。
- `apphub/src/schemas/appInstall.py` -- 安装 API 请求模型。
- `apphub/src/services/install_profile.py` -- profile 白名单解析、模板物化和敏感字段处理。
- AppHub 不连接外部 MySQL;用户负责预先创建目标数据库及可用账号。
- `apphub/src/services/common_check.py` -- 同步请求校验。
- `apphub/src/services/app_manager.py` -- 临时工作区物化与 provision 调用。

## Tasks & Acceptance

**Execution:**
- [ ] `console/src/features/app-store/*` -- 读取 profiles、选择模式、按命名约定隐藏密码,并仅在选择 profile 时提交 profile 和 profile_settings。
- [ ] `apphub/src/schemas/appInstall.py`、`apphub/src/services/common_check.py` -- 增加可选 profile 契约,并在异步任务前校验本地模板白名单和本地 Endpoint。
- [ ] `apphub/src/services/install_profile.py` -- 以 profile 配对文件覆盖临时工作区的固定 compose/.env,且移除管理员凭据。
- [x] 外部 MySQL 仅物化用户提供的已有数据库连接信息;不新增数据库驱动、不建库、不建用户或授权。
- [ ] `apphub/src/services/app_manager.py` -- 仅在 profile 存在时执行物化和 provision,之后复用既有推送/部署流程。
- [ ] `apphub/tests/` 与 Console 测试 -- 覆盖默认兼容、profile 白名单、模板归一化、秘密剔除和请求负载。

**Acceptance Criteria:**
- Given 默认 WordPress 安装,when 用户提交,then 请求、生成文件和部署行为与改动前一致。
- Given `external-mysql`,when 用户填写合规参数,then 最终 `.env` 含运行连接信息与新生成的 `W9_POWER_PASSWORD`,但不含管理员账号或密码。
- Given 未知 profile 或额外 profile 字段,when 请求安装,then API 在创建安装任务前以 400 拒绝。
- Given 外部 MySQL 初始化失败,when 后端报告错误,then 错误、日志和状态中不含管理员密码。

## Design Notes

`settings` 继续表示默认模板字段;`profile_settings` 只表示选择 profile 后的模板字段。profile 物化发生在复制整个应用目录之后、初始化 `.env` 之前,因此后续 Git、镜像拉取和 Portainer 无需识别额外文件名。

## Verification

**Commands:**
- `cd apphub && pytest tests/test_install_profiles.py` -- profile 校验和物化通过。
- `cd console && npm run build` -- TypeScript 构建通过。
- `docker compose -f docker/docker-compose.dev.yml up -d --build` -- 运行容器包含改动。
- `docker exec websoft9-dev ...` -- 真实 App Store 入口验证默认及外部模式。
1 change: 1 addition & 0 deletions _bmad-output/implementation-artifacts/sprint-status.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ development_status:
4-6-build-the-controlled-terminal-bridge-and-session-audit: ready-for-dev
4-7-build-the-core-services-view-and-log-drilldown: review
4-8-build-the-websoft9-runtime-logs-page: review
4-9-build-lightweight-scheduled-tasks: done
epic-4-retrospective: optional

epic-5: in-progress
Expand Down
5 changes: 4 additions & 1 deletion apphub/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,7 @@ docker
tenacity
aiodocker
paramiko
python-multipart
python-multipart
PyMySQL
psycopg2-binary
croniter
43 changes: 42 additions & 1 deletion apphub/src/api/v1/routers/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
from src.schemas.appCatalog import AppCatalogResponse
from src.schemas.appComposeInstall import ComposeInstallAcceptedResponse, ComposeInstallRequest, ComposeValidationRequest, ComposeValidationResponse
from src.schemas.appInstallAcceptedResponse import AppInstallAcceptedResponse
from src.schemas.appInstall import appInstall
from src.schemas.appInstall import ExternalDatabaseConnectionTestRequest, appInstall
from src.schemas.appPhpInfo import AppPhpInfoResponse
from src.schemas.appPhpMigration import AppPhpMigrationRequest
from src.schemas.appResponse import AppResponse
Expand Down Expand Up @@ -127,6 +127,23 @@ async def event_generator():
},
)

@router.get(
"/databases",
summary="List External Databases",
description="List external databases across installed apps.",
responses={
200: {"description": "Successful Response"},
400: {"model": ErrorResponse},
500: {"model": ErrorResponse},
},
)
def get_external_databases(
endpointId: int = Query(None, description="Endpoint ID to get databases from. If not set, get databases from the local endpoint"),
locale: str = Query("en", description="Language used to resolve installed app media", regex="^(zh|en)(-[A-Za-z]{2})?$"),
):
return AppManger().get_external_databases(endpointId, locale)


@router.get(
"/apps/{app_id}",
summary="Inspect App",
Expand Down Expand Up @@ -346,6 +363,30 @@ async def apps_install(
)


@router.post(
"/apps/install/external-db/test-connection",
summary="Test External Database Connection",
responses={
200: {"model": dict},
400: {"model": ErrorResponse},
500: {"model": ErrorResponse},
},
)
def test_external_database_install_connection(payload: ExternalDatabaseConnectionTestRequest):
from src.services.common_check import validate_external_database_version
from src.services.install_profile import validate_external_database_connection

actual_type = validate_external_database_connection(
payload.host,
payload.port,
payload.database_name,
payload.username,
payload.password,
)
validate_external_database_version(payload.app_name, payload.app_version, actual_type.database_type, actual_type.version)
return {"status": "success", "database_type": actual_type.database_type}


@router.post(
"/apps/install/compose",
summary="Install Custom Compose Application",
Expand Down
100 changes: 100 additions & 0 deletions apphub/src/api/v1/routers/scheduled_tasks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import asyncio
import hashlib
import json
from typing import Optional

from fastapi import APIRouter, Cookie, Query, Request, Response
from fastapi.responses import StreamingResponse

from src.schemas.errorResponse import ErrorResponse
from src.schemas.scheduledTasks import ScheduledTaskToggleRequest, ScheduledTaskWriteRequest
from src.services.product_auth import PRODUCT_AUTH_COOKIE_NAME
from src.services.scheduled_tasks import ScheduledTaskService


router = APIRouter()
_scheduled_task_service = ScheduledTaskService()


def _get_scheduled_task_service() -> ScheduledTaskService:
return _scheduled_task_service


@router.get("/scheduled-tasks", responses={200: {"model": dict}, 401: {"model": ErrorResponse}})
def list_scheduled_tasks(session_token: Optional[str] = Cookie(default=None, alias=PRODUCT_AUTH_COOKIE_NAME)):
return _get_scheduled_task_service().list_tasks(session_token)


@router.post("/scheduled-tasks/sync", status_code=202, responses={401: {"model": ErrorResponse}})
def sync_scheduled_tasks(session_token: Optional[str] = Cookie(default=None, alias=PRODUCT_AUTH_COOKIE_NAME)):
return _get_scheduled_task_service().start_sync(session_token)


@router.get("/scheduled-tasks/stream", responses={200: {"description": "Scheduled task stream established"}, 401: {"model": ErrorResponse}})
async def stream_scheduled_tasks(request: Request, session_token: Optional[str] = Cookie(default=None, alias=PRODUCT_AUTH_COOKIE_NAME)):
async def event_generator():
last_digest: Optional[str] = None
while not await request.is_disconnected():
snapshot = _get_scheduled_task_service().list_cached_tasks(session_token)
payload = json.dumps(snapshot, ensure_ascii=False, separators=(",", ":"))
digest = hashlib.sha256(payload.encode("utf-8")).hexdigest()
if digest != last_digest:
last_digest = digest
yield f"event: snapshot\ndata: {payload}\n\n"
else:
yield ": keep-alive\n\n"
await asyncio.sleep(2)
return StreamingResponse(event_generator(), media_type="text/event-stream", headers={"Cache-Control": "no-cache", "Connection": "keep-alive", "X-Accel-Buffering": "no"})


@router.post("/scheduled-tasks/hosts/{profile_id}/capability", responses={400: {"model": ErrorResponse}, 401: {"model": ErrorResponse}, 503: {"model": ErrorResponse}})
def check_scheduled_task_host_capability(profile_id: str, session_token: Optional[str] = Cookie(default=None, alias=PRODUCT_AUTH_COOKIE_NAME)):
return _get_scheduled_task_service().check_host_capability(session_token, profile_id)


@router.post("/scheduled-tasks", status_code=201, responses={400: {"model": ErrorResponse}, 401: {"model": ErrorResponse}, 409: {"model": ErrorResponse}})
def create_scheduled_task(payload: ScheduledTaskWriteRequest, session_token: Optional[str] = Cookie(default=None, alias=PRODUCT_AUTH_COOKIE_NAME)):
return _get_scheduled_task_service().create_task(session_token, payload.model_dump())


@router.put("/scheduled-tasks/{task_id}", responses={400: {"model": ErrorResponse}, 401: {"model": ErrorResponse}, 404: {"model": ErrorResponse}})
def update_scheduled_task(task_id: str, payload: ScheduledTaskWriteRequest, session_token: Optional[str] = Cookie(default=None, alias=PRODUCT_AUTH_COOKIE_NAME)):
return _get_scheduled_task_service().update_task(session_token, task_id, payload.model_dump())


@router.post("/scheduled-tasks/{task_id}/toggle", responses={401: {"model": ErrorResponse}, 404: {"model": ErrorResponse}})
def toggle_scheduled_task(task_id: str, payload: ScheduledTaskToggleRequest, session_token: Optional[str] = Cookie(default=None, alias=PRODUCT_AUTH_COOKIE_NAME)):
return _get_scheduled_task_service().toggle_task(session_token, task_id, payload.enabled)


@router.delete("/scheduled-tasks/{task_id}", status_code=204, responses={401: {"model": ErrorResponse}, 404: {"model": ErrorResponse}})
def delete_scheduled_task(task_id: str, session_token: Optional[str] = Cookie(default=None, alias=PRODUCT_AUTH_COOKIE_NAME)):
_get_scheduled_task_service().delete_task(session_token, task_id)
return Response(status_code=204)


@router.post("/scheduled-tasks/{task_id}/run", status_code=202, responses={401: {"model": ErrorResponse}, 404: {"model": ErrorResponse}, 503: {"model": ErrorResponse}})
def run_scheduled_task(task_id: str, session_token: Optional[str] = Cookie(default=None, alias=PRODUCT_AUTH_COOKIE_NAME)):
return _get_scheduled_task_service().run_task(session_token, task_id)


@router.post("/scheduled-tasks/{task_id}/refresh-status", responses={401: {"model": ErrorResponse}, 404: {"model": ErrorResponse}})
def refresh_scheduled_task_status(task_id: str, session_token: Optional[str] = Cookie(default=None, alias=PRODUCT_AUTH_COOKIE_NAME)):
return _get_scheduled_task_service().refresh_status(session_token, task_id)


@router.get("/scheduled-tasks/{task_id}/runs", responses={401: {"model": ErrorResponse}, 404: {"model": ErrorResponse}})
def list_scheduled_task_runs(task_id: str, offset: int = Query(default=0, ge=0), limit: int = Query(default=20, ge=1, le=100), session_token: Optional[str] = Cookie(default=None, alias=PRODUCT_AUTH_COOKIE_NAME)):
return _get_scheduled_task_service().list_runs(session_token, task_id, offset, limit)


@router.get("/scheduled-tasks/{task_id}/runs/{run_id}/log", responses={401: {"model": ErrorResponse}, 404: {"model": ErrorResponse}})
def get_scheduled_task_run_log(task_id: str, run_id: str, before: Optional[int] = Query(default=None, ge=0), session_token: Optional[str] = Cookie(default=None, alias=PRODUCT_AUTH_COOKIE_NAME)):
return _get_scheduled_task_service().get_run_log(session_token, task_id, run_id, before)


@router.get("/scheduled-tasks/{task_id}/runs/{run_id}/log/download", responses={401: {"model": ErrorResponse}, 404: {"model": ErrorResponse}})
def download_scheduled_task_run_log(task_id: str, run_id: str, session_token: Optional[str] = Cookie(default=None, alias=PRODUCT_AUTH_COOKIE_NAME)):
content = _get_scheduled_task_service().download_run_log(session_token, task_id, run_id)
filename = f"scheduled-task-{task_id}-{run_id}.log"
return Response(content=content, media_type="text/plain; charset=utf-8", headers={"Content-Disposition": f'attachment; filename="{filename}"'})
28 changes: 25 additions & 3 deletions apphub/src/api/v1/routers/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
from typing import Optional

import requests
from fastapi import APIRouter, Query, Path, Cookie
from fastapi import APIRouter, BackgroundTasks, Cookie, Path, Query, Request, Response
from src.schemas.appSettings import AppSettings, PlatformGatewayBatchUpdateRequest, GenerateSelfSignedCertRequest, ApplyLetsEncryptCertRequest, UploadCertRequest
from src.schemas.errorResponse import ErrorResponse
from src.schemas.productRuntimeState import ProductEditionStateResponse
Expand Down Expand Up @@ -102,15 +102,37 @@ def update_settings(
500: {"model": ErrorResponse},
}
)
def apply_platform_gateway_settings(payload: PlatformGatewayBatchUpdateRequest):
return SettingsManager().write_platform_gateway_settings(
def apply_platform_gateway_settings(
payload: PlatformGatewayBatchUpdateRequest,
request: Request,
response: Response,
background_tasks: BackgroundTasks,
):
manager = SettingsManager()
was_https_enabled = manager._is_platform_https_enabled()
will_enable_https = manager._parse_bool(payload.https_enabled)
request_is_https = (request.headers.get("x-forwarded-proto") or request.url.scheme) == "https"

result = manager.write_platform_gateway_settings(
bound_domain=payload.bound_domain,
https_enabled=payload.https_enabled,
force_https=payload.force_https,
ssl_cert=payload.ssl_cert,
ssl_key=payload.ssl_key,
restart_gateway=False,
)

if was_https_enabled and not will_enable_https and request_is_https:
response.delete_cookie(
key=PRODUCT_AUTH_COOKIE_NAME,
path="/",
samesite="lax",
secure=True,
)

background_tasks.add_task(manager._restart_platform_gateway)
return result


@router.post(
"/settings/platform_gateway/generate-self-signed-cert",
Expand Down
10 changes: 10 additions & 0 deletions apphub/src/cli/apphub_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from src.services.product_auth import ProductAuthService
from src.core.exception import CustomException
from src.services.appstore_sync_manager import AppStoreSyncManager
from src.services.scheduled_tasks import ScheduledTaskService

@click.group()
def cli():
Expand Down Expand Up @@ -119,6 +120,15 @@ def upgrade(target, channel, dev, force_refresh):
raise click.ClickException(str(e))


@cli.command(hidden=True)
def reconcile_scheduled_tasks():
"""Restore local scheduled-task runners and cron after an upgrade."""
try:
ScheduledTaskService().reconcile_local_schedule()
except Exception as e:
raise click.ClickException(str(e))


@cli.command(hidden=True)
@click.option('--password', prompt=True, hide_input=True, confirmation_prompt=True, help='New password for the system user')
def resetpwd(password):
Expand Down
Loading
Loading