Skip to content

Commit c5d5187

Browse files
committed
feat: update install and setup wizard flows
1 parent 9c3332f commit c5d5187

22 files changed

Lines changed: 2187 additions & 21 deletions

apphub/src/api/v1/routers/auth.py

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -72,11 +72,21 @@ def check_embedded_gateway_access(
7272
if not status_payload["enabled"]:
7373
return Response(status_code=204)
7474

75+
if status_payload.get("cloud_marketplace_setup_pending") and status_payload.get("initialization_required"):
76+
return Response(
77+
status_code=403,
78+
headers={"X-Websoft9-Setup-Route": "/setup"},
79+
)
80+
7581
if status_payload["initialization_required"]:
76-
raise CustomException(
82+
if status_payload.get("cloud_marketplace_setup"):
83+
return Response(
84+
status_code=403,
85+
headers={"X-Websoft9-Setup-Route": "/setup"},
86+
)
87+
return Response(
7788
status_code=403,
78-
message="Product Authentication Setup Required",
79-
details="Initialize the product operator account before opening embedded workspaces",
89+
headers={"X-Websoft9-Setup-Route": "/auth/setup"},
8090
)
8191

8292
if not status_payload["authenticated"]:
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
from typing import Optional
2+
3+
from fastapi import APIRouter, Cookie, Query
4+
5+
from src.core.exception import CustomException
6+
from src.schemas.errorResponse import ErrorResponse
7+
from src.schemas.setupWizard import (
8+
SetupWizardAppResponse,
9+
SetupWizardCompleteResponse,
10+
SetupWizardInstallAcceptedResponse,
11+
SetupWizardInstallRequest,
12+
SetupWizardInstallStatusResponse,
13+
SetupWizardPlatformInitCompleteResponse,
14+
SetupWizardStateResponse,
15+
)
16+
from src.services.product_auth import PRODUCT_AUTH_COOKIE_NAME
17+
from src.services.setup_wizard import SetupWizardService
18+
19+
router = APIRouter()
20+
21+
22+
@router.get(
23+
"/setup-wizard/state",
24+
summary="Get setup wizard state",
25+
responses={200: {"model": SetupWizardStateResponse}, 400: {"model": ErrorResponse}, 500: {"model": ErrorResponse}},
26+
)
27+
def get_setup_wizard_state(
28+
session_token: Optional[str] = Cookie(default=None, alias=PRODUCT_AUTH_COOKIE_NAME),
29+
):
30+
return SetupWizardService().get_state(session_token=session_token)
31+
32+
33+
@router.get(
34+
"/setup-wizard/app",
35+
summary="Get marketplace app metadata",
36+
responses={200: {"model": SetupWizardAppResponse}, 400: {"model": ErrorResponse}, 404: {"model": ErrorResponse}, 500: {"model": ErrorResponse}},
37+
)
38+
def get_setup_wizard_app(
39+
locale: str = Query("en", regex="^(zh|en)(-[A-Za-z]{2})?$"),
40+
):
41+
return SetupWizardService().get_app(locale)
42+
43+
44+
@router.post(
45+
"/setup-wizard/platform-init-complete",
46+
summary="Advance setup wizard after platform initialization",
47+
responses={200: {"model": SetupWizardPlatformInitCompleteResponse}, 401: {"model": ErrorResponse}, 500: {"model": ErrorResponse}},
48+
)
49+
def complete_platform_initialization(
50+
session_token: Optional[str] = Cookie(default=None, alias=PRODUCT_AUTH_COOKIE_NAME),
51+
):
52+
return SetupWizardService().mark_platform_init_complete(session_token=session_token)
53+
54+
55+
@router.post(
56+
"/setup-wizard/install",
57+
summary="Start marketplace app installation",
58+
responses={200: {"model": SetupWizardInstallAcceptedResponse}, 400: {"model": ErrorResponse}, 401: {"model": ErrorResponse}, 500: {"model": ErrorResponse}},
59+
)
60+
def install_marketplace_app(
61+
payload: SetupWizardInstallRequest,
62+
endpointId: int = Query(None, description="Endpoint ID to install app on, if not set install on the local endpoint"),
63+
session_token: Optional[str] = Cookie(default=None, alias=PRODUCT_AUTH_COOKIE_NAME),
64+
):
65+
return SetupWizardService().install_app(payload.model_dump(), session_token=session_token, endpoint_id=endpointId)
66+
67+
68+
@router.get(
69+
"/setup-wizard/install/{tracking_id}",
70+
summary="Get marketplace app installation status",
71+
responses={200: {"model": SetupWizardInstallStatusResponse}, 404: {"model": ErrorResponse}, 500: {"model": ErrorResponse}},
72+
)
73+
def get_marketplace_app_install_status(tracking_id: str):
74+
return SetupWizardService().get_install_status(tracking_id)
75+
76+
77+
@router.post(
78+
"/setup-wizard/complete",
79+
summary="Mark setup wizard complete",
80+
responses={200: {"model": SetupWizardCompleteResponse}, 400: {"model": ErrorResponse}, 401: {"model": ErrorResponse}, 500: {"model": ErrorResponse}},
81+
)
82+
def complete_setup_wizard(
83+
session_token: Optional[str] = Cookie(default=None, alias=PRODUCT_AUTH_COOKIE_NAME),
84+
):
85+
return SetupWizardService().complete(session_token=session_token)

apphub/src/cli/apphub_cli.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
import click
1010
from src.core.runtime_paths import resolve_apphub_config_path
11+
from src.services.marketplace_bootstrap import MarketplaceBootstrapService
1112
from src.services.product_metadata import write_product_edition
1213
from src.services.settings_manager import SettingsManager
1314
from src.services.product_auth import ProductAuthService
@@ -86,6 +87,18 @@ def setedition(edition_key):
8687
except Exception as e:
8788
raise click.ClickException(str(e))
8889

90+
91+
@cli.command()
92+
@click.option('--app-slug', required=True, help='Marketplace app slug')
93+
@click.option('--default-locale', required=True, type=click.Choice(['en', 'zh-CN'], case_sensitive=True), help='Default setup locale')
94+
def setmarketplace(app_slug, default_locale):
95+
"""Set marketplace bootstrap metadata"""
96+
try:
97+
payload = MarketplaceBootstrapService().write(app_slug=app_slug, default_locale=default_locale)
98+
click.echo(json.dumps(payload, ensure_ascii=False))
99+
except Exception as e:
100+
raise click.ClickException(str(e))
101+
89102
@cli.command()
90103
@click.argument('target', required=True, type=click.Choice(['apps'], case_sensitive=False))
91104
@click.option('--channel', type=click.Choice(['release', 'rc', 'dev'], case_sensitive=False), help='Upgrade using the specified artifact channel')

apphub/src/main.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
from src.api.v1.routers import backup as api_backup
1919
from src.api.v1.routers import compose_app as api_compose_app
2020
from src.api.v1.routers import appstore_sync as api_appstore_sync
21+
from src.api.v1.routers import setup_wizard as api_setup_wizard
2122
from src.core.config import ConfigManager
2223
from src.core.exception import CustomException
2324
from src.core.api_key_auth import should_skip_api_key_auth
@@ -160,5 +161,6 @@ async def validation_exception_handler(request: Request, exc: RequestValidationE
160161
app.include_router(api_settings.router,tags=["settings"])
161162
app.include_router(api_compose_app.router,tags=["compose-apps"])
162163
app.include_router(api_appstore_sync.router,tags=["appstore-sync"])
164+
app.include_router(api_setup_wizard.router,tags=["setup-wizard"])
163165

164166
remove_422_responses()

apphub/src/schemas/productAuth.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,8 @@ class ProductAuthStatusResponse(BaseModel):
7777
enabled: bool
7878
initialization_required: bool
7979
authenticated: bool
80+
cloud_marketplace_setup: bool = False
81+
cloud_marketplace_setup_pending: bool = False
8082
protected_modules: list[str]
8183
current_user: Optional[ProductAuthOperator] = None
8284
storage_boundary: ProductAuthStorageBoundary

apphub/src/schemas/setupWizard.py

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
from __future__ import annotations
2+
3+
from typing import Literal, Optional
4+
5+
from pydantic import BaseModel, Field, field_validator
6+
7+
from src.core.exception import CustomException
8+
9+
10+
SetupWizardStep = Literal[
11+
"welcome",
12+
"platform_init",
13+
"app_init_ready",
14+
"app_init_running",
15+
"app_init_failed",
16+
"complete",
17+
]
18+
19+
SetupWizardInstallStatus = Literal["running", "succeeded", "failed"]
20+
SetupWizardInputType = Literal["text", "number", "password"]
21+
22+
23+
class SetupWizardError(BaseModel):
24+
code: str
25+
message: str
26+
retryable: bool = True
27+
field_names: list[str] = Field(default_factory=list)
28+
29+
30+
class SetupWizardStateResponse(BaseModel):
31+
enabled: bool
32+
current_step: SetupWizardStep
33+
app_slug: Optional[str] = None
34+
default_locale: str = "en"
35+
installed_app_id: Optional[str] = None
36+
completed: bool
37+
tracking_id: Optional[str] = None
38+
pending_app_id: Optional[str] = None
39+
last_error: Optional[SetupWizardError] = None
40+
updated_at: str
41+
completed_at: Optional[str] = None
42+
43+
44+
class SetupWizardInputField(BaseModel):
45+
name: str
46+
label: str
47+
type: SetupWizardInputType = "text"
48+
required: bool = True
49+
default_value: Optional[str] = None
50+
placeholder: Optional[str] = None
51+
description: Optional[str] = None
52+
53+
54+
class SetupWizardAppResponse(BaseModel):
55+
app_slug: str
56+
default_locale: str = "en"
57+
display_name: str
58+
edition: str
59+
default_app_id: str
60+
is_web_app: bool
61+
requires_user_inputs: bool
62+
required_inputs: list[SetupWizardInputField] = Field(default_factory=list)
63+
settings: dict[str, str] = Field(default_factory=dict)
64+
65+
66+
class SetupWizardPlatformInitCompleteResponse(BaseModel):
67+
current_step: SetupWizardStep
68+
updated_at: str
69+
70+
71+
class SetupWizardInstallRequest(BaseModel):
72+
app_id: str = Field(min_length=2, max_length=20)
73+
edition: str = Field(min_length=1, max_length=128)
74+
domain_name: str = Field(min_length=1, max_length=255)
75+
user_inputs: dict[str, str] = Field(default_factory=dict)
76+
77+
@field_validator("app_id", mode="before")
78+
@classmethod
79+
def normalize_app_id(cls, value: str) -> str:
80+
normalized = str(value or "").strip().lower()
81+
if not normalized:
82+
raise CustomException(400, "Invalid Request", "'app_id' cannot be empty.")
83+
return normalized
84+
85+
@field_validator("edition", mode="before")
86+
@classmethod
87+
def normalize_edition(cls, value: str) -> str:
88+
normalized = str(value or "").strip()
89+
if not normalized:
90+
raise CustomException(400, "Invalid Request", "'edition' cannot be empty.")
91+
return normalized
92+
93+
@field_validator("domain_name", mode="before")
94+
@classmethod
95+
def normalize_domain_name(cls, value: str) -> str:
96+
normalized = str(value or "").strip().lower()
97+
if not normalized:
98+
raise CustomException(400, "Invalid Request", "'domain_name' cannot be empty.")
99+
return normalized
100+
101+
102+
class SetupWizardInstallAcceptedResponse(BaseModel):
103+
tracking_id: str
104+
current_step: SetupWizardStep
105+
106+
107+
class SetupWizardInstallStatusResponse(BaseModel):
108+
status: SetupWizardInstallStatus
109+
current_step: SetupWizardStep
110+
installed_app_id: Optional[str] = None
111+
last_error: Optional[SetupWizardError] = None
112+
113+
114+
class SetupWizardCompleteResponse(BaseModel):
115+
current_step: SetupWizardStep
116+
installed_app_id: str
117+
completed: bool
118+
completed_at: str

apphub/src/services/app_manager.py

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1316,7 +1316,17 @@ def install_app(self,appInstall: appInstall, endpointId: int = None, tracked_app
13161316
except Exception:
13171317
portainerManager.remove_vloumes(app_id,endpointId)
13181318
else:
1319-
portainerManager.remove_vloumes(app_id,endpointId)
1319+
# create_stack_from_repository raised before returning stack_info,
1320+
# but Portainer may have created a stack record before the compose
1321+
# up failure. Look it up by name so we can clean it up completely.
1322+
try:
1323+
stack = portainerManager.get_stack_by_name(app_id, endpointId)
1324+
if stack and stack.get("Id"):
1325+
portainerManager.remove_stack_and_volumes(stack["Id"], endpointId)
1326+
else:
1327+
portainerManager.remove_vloumes(app_id, endpointId)
1328+
except Exception:
1329+
portainerManager.remove_vloumes(app_id, endpointId)
13201330
# modify app status: error
13211331
modify_app_information(app_uuid,e.details)
13221332
remove_installation_logs(app_uuid)
@@ -1331,7 +1341,14 @@ def install_app(self,appInstall: appInstall, endpointId: int = None, tracked_app
13311341
except Exception:
13321342
portainerManager.remove_vloumes(app_id,endpointId)
13331343
else:
1334-
portainerManager.remove_vloumes(app_id,endpointId)
1344+
try:
1345+
stack = portainerManager.get_stack_by_name(app_id, endpointId)
1346+
if stack and stack.get("Id"):
1347+
portainerManager.remove_stack_and_volumes(stack["Id"], endpointId)
1348+
else:
1349+
portainerManager.remove_vloumes(app_id, endpointId)
1350+
except Exception:
1351+
portainerManager.remove_vloumes(app_id, endpointId)
13351352
# modify app status: error
13361353
modify_app_information(app_uuid,"Create stack error")
13371354
remove_installation_logs(app_uuid)
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
from __future__ import annotations
2+
3+
import json
4+
from pathlib import Path
5+
from typing import Any
6+
7+
8+
REPO_ROOT = Path(__file__).resolve().parents[3]
9+
RUNTIME_BOOTSTRAP_PATH = Path("/websoft9/marketplace/bootstrap.json")
10+
REPO_BOOTSTRAP_PATH = REPO_ROOT / "marketplace" / "bootstrap.json"
11+
12+
13+
def get_marketplace_bootstrap_path() -> Path:
14+
if RUNTIME_BOOTSTRAP_PATH.exists() or RUNTIME_BOOTSTRAP_PATH.parent.exists():
15+
return RUNTIME_BOOTSTRAP_PATH
16+
return REPO_BOOTSTRAP_PATH
17+
18+
19+
def normalize_marketplace_locale(value: str | None) -> str:
20+
locale = str(value or "").strip().lower()
21+
return "zh-CN" if locale.startswith("zh") else "en"
22+
23+
24+
class MarketplaceBootstrapService:
25+
def __init__(self, file_path: Path | None = None):
26+
self.file_path = file_path or get_marketplace_bootstrap_path()
27+
28+
def read(self) -> dict[str, Any]:
29+
try:
30+
raw = self.file_path.read_text(encoding="utf-8")
31+
except FileNotFoundError:
32+
return {}
33+
except Exception:
34+
return {}
35+
36+
try:
37+
payload = json.loads(raw)
38+
except json.JSONDecodeError:
39+
return {}
40+
41+
if not isinstance(payload, dict):
42+
return {}
43+
44+
app_slug = str(payload.get("app_slug") or "").strip().lower()
45+
if not app_slug:
46+
return {}
47+
48+
return {
49+
"app_slug": app_slug,
50+
"default_locale": normalize_marketplace_locale(str(payload.get("default_locale") or "en")),
51+
}
52+
53+
def write(self, app_slug: str, default_locale: str) -> dict[str, str]:
54+
payload = {
55+
"app_slug": str(app_slug or "").strip().lower(),
56+
"default_locale": normalize_marketplace_locale(default_locale),
57+
}
58+
if not payload["app_slug"]:
59+
raise ValueError("app_slug cannot be empty")
60+
self.file_path.parent.mkdir(parents=True, exist_ok=True)
61+
self.file_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True), encoding="utf-8")
62+
return payload

0 commit comments

Comments
 (0)