Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
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
1 change: 1 addition & 0 deletions changes/7638.feature.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add `--public-mode`, `--fqdn-prefix`, `--tls-advertised`, and `--advertised-port` options to the TUI installer for public-facing deployments with wildcard domain support.
6 changes: 6 additions & 0 deletions src/ai/backend/install/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -414,6 +414,12 @@ def __init__(
self.install_variable = InstallVariable(
public_facing_address=args.public_facing_address,
accelerator=Accelerator(args.accelerator) if args.accelerator is not None else None,
fqdn_prefix=args.fqdn_prefix,
tls_advertised=args.tls_advertised,
advertised_port=args.advertised_port,
endpoint_protocol=args.endpoint_protocol,
frontend_mode=args.frontend_mode,
use_wildcard_binding=args.use_wildcard_binding,
)

def compose(self) -> ComposeResult:
Expand Down
50 changes: 49 additions & 1 deletion src/ai/backend/install/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
import click

from . import __version__
from .types import Accelerator, CliArgs, InstallModes
from .types import Accelerator, CliArgs, EndpointProtocol, FrontendMode, InstallModes


@click.command(
Expand Down Expand Up @@ -58,6 +58,42 @@
default="127.0.0.1",
help="Set public facing address for the Backend.AI server.",
)
@click.option(
"--fqdn-prefix",
type=str,
default=None,
help="FQDN prefix for generating domain names (e.g., '786cdf' generates 786cdf.app.backend.ai, 786cdf.apphub.backend.ai, etc.).",
)
@click.option(
"--tls-advertised",
is_flag=True,
default=False,
help="Advertise TLS endpoints to external clients.",
)
@click.option(
"--advertised-port",
type=int,
default=443,
help="Advertised port for public endpoints (default: 443).",
)
@click.option(
"--endpoint-protocol",
type=click.Choice([p.value for p in EndpointProtocol], case_sensitive=False),
default=None,
help="Force endpoint protocol in webserver (http or https). If not set, auto-detected.",
)
@click.option(
"--frontend-mode",
type=click.Choice([m.value for m in FrontendMode], case_sensitive=False),
default=FrontendMode.PORT.value,
help="App-proxy frontend mode: 'port' (default) or 'wildcard'.",
)
@click.option(
"--use-wildcard-binding",
is_flag=True,
default=False,
help="Use wildcard domain binding for app-proxy worker.",
)
@click.version_option(version=__version__)
@click.pass_context
def main(
Expand All @@ -68,6 +104,12 @@ def main(
non_interactive: bool,
headless: bool,
public_facing_address: str,
fqdn_prefix: str | None,
tls_advertised: bool,
advertised_port: int,
endpoint_protocol: str | None,
frontend_mode: str,
use_wildcard_binding: bool,
accelerator: str,
) -> None:
"""The installer"""
Expand All @@ -91,6 +133,12 @@ def main(
non_interactive=non_interactive,
public_facing_address=public_facing_address,
accelerator=accelerator,
fqdn_prefix=fqdn_prefix,
tls_advertised=tls_advertised,
advertised_port=advertised_port,
endpoint_protocol=EndpointProtocol(endpoint_protocol) if endpoint_protocol else None,
frontend_mode=FrontendMode(frontend_mode),
use_wildcard_binding=use_wildcard_binding,
)
app = InstallerApp(args)
app.run(headless=headless)
117 changes: 97 additions & 20 deletions src/ai/backend/install/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@
from .types import (
Accelerator,
DistInfo,
FrontendMode,
HalfstackConfig,
HostPortPair,
ImageSource,
Expand Down Expand Up @@ -601,14 +602,30 @@
conf_path = self.copy_config("webserver.conf")
halfstack = self.install_info.halfstack_config
service = self.install_info.service_config
endpoint_protocol = self.install_variable.endpoint_protocol
fqdn_prefix = self.install_variable.fqdn_prefix
storage_public_address = self.install_variable.storage_public_address
public_facing_address = self.install_variable.public_facing_address
assert halfstack.redis_addr is not None

# use FQDN if provided, otherwise use public_facing_address
if fqdn_prefix is not None:

Check warning

Code scanning / devskim

An HTTP-based URL without TLS was detected. Warning

Insecure URL
# With FQDN prefix, use public storage address with https
wsproxy_url = f"https://{storage_public_address}:5050"

Check warning

Code scanning / devskim

An HTTP-based URL without TLS was detected. Warning

Insecure URL
else:
# Without FQDN prefix, use public_facing_address with http
wsproxy_url = f"http://{public_facing_address}:5050"
# Use sed_in_place for dotted key wsproxy.url
self.sed_in_place(
conf_path,
re.compile(r'^wsproxy\.url\s*=\s*".*"', flags=re.MULTILINE),
f'wsproxy.url = "{wsproxy_url}"',
)

with conf_path.open("r") as fp:
data = tomlkit.load(fp)
appproxy_itable = tomlkit.inline_table()
appproxy_itable["url"] = (
f"http://{service.appproxy_coordinator_addr.face.host}:{service.appproxy_coordinator_addr.face.port}"
)
data["service"]["appproxy"] = appproxy_itable # type: ignore
if endpoint_protocol is not None:
data["service"]["force_endpoint_protocol"] = endpoint_protocol.value # type: ignore
data["api"][ # type: ignore
"endpoint"
] = f"http://{service.manager_addr.face.host}:{service.manager_addr.face.port}"
Expand Down Expand Up @@ -737,6 +754,14 @@
self.log.write(f"DB PORT = {halfstack.postgres_addr.face.port}")
self.log.write(f"API SECRET = {service.appproxy_api_secret}")

tls_advertised = self.install_variable.tls_advertised
advertised_port = self.install_variable.advertised_port
wildcard_domain = self.install_variable.wildcard_domain
public_facing_address = self.install_variable.public_facing_address
apphub_address = self.install_variable.apphub_address
app_address = self.install_variable.app_address
frontend_mode = self.install_variable.frontend_mode

with coord_conf.open("r") as fp:
data = tomlkit.load(fp)
data["db"]["type"] = "postgresql" # type: ignore[index]
Expand All @@ -747,38 +772,90 @@
data["db"]["max_overflow"] = 64 # type: ignore[index]
data["db"]["addr"]["host"] = halfstack.postgres_addr.face.host # type: ignore[index]
data["db"]["addr"]["port"] = halfstack.postgres_addr.face.port # type: ignore[index]
data["redis"]["host"] = halfstack.redis_addr.face.host # type: ignore
data["redis"]["port"] = halfstack.redis_addr.face.port # type: ignore
redis_addr_table = tomlkit.inline_table()
redis_addr_table["host"] = halfstack.redis_addr.face.host # type: ignore
redis_addr_table["port"] = halfstack.redis_addr.face.port # type: ignore
data["redis"]["addr"] = redis_addr_table # type: ignore
data["secrets"]["api_secret"] = service.appproxy_api_secret # type: ignore[index]
data["secrets"]["jwt_secret"] = service.appproxy_jwt_secret # type: ignore[index]
data["permit_hash"]["permit_hash_secret"] = service.appproxy_permit_hash_secret # type: ignore[index]
data["proxy_coordinator"]["bind_addr"]["host"] = ( # type: ignore[index]
service.appproxy_coordinator_addr.bind.host
)
data["permit_hash"]["secret"] = service.appproxy_permit_hash_secret # type: ignore[index]
data["proxy_coordinator"]["bind_addr"]["host"] = "0.0.0.0" # type: ignore[index]
data["proxy_coordinator"]["bind_addr"]["port"] = ( # type: ignore[index]
service.appproxy_coordinator_addr.bind.port
)
data["proxy_coordinator"]["advertised_addr"]["host"] = apphub_address # type: ignore[index]
data["proxy_coordinator"]["advertised_addr"]["port"] = ( # type: ignore[index]
service.appproxy_coordinator_addr.bind.port
)
if tls_advertised:
data["proxy_coordinator"]["tls_advertised"] = True # type: ignore[index]
data["proxy_coordinator"]["advertised_addr"]["port"] = advertised_port # type: ignore[index]
with coord_conf.open("w") as fp:
tomlkit.dump(data, fp)

# Worker
worker_conf = self.copy_config("app-proxy-worker.toml")
with worker_conf.open("r") as fp:
data = tomlkit.load(fp)
data["redis"]["host"] = halfstack.redis_addr.face.host # type: ignore
data["redis"]["port"] = halfstack.redis_addr.face.port # type: ignore
# Update redis addr inline table
redis_addr_table = tomlkit.inline_table()
redis_addr_table["host"] = halfstack.redis_addr.face.host # type: ignore
redis_addr_table["port"] = halfstack.redis_addr.face.port # type: ignore
data["redis"]["addr"] = redis_addr_table # type: ignore

data["proxy_worker"]["coordinator_endpoint"] = ( # type: ignore[index]
f"http://{service.appproxy_coordinator_addr.bind.host}:{service.appproxy_coordinator_addr.bind.port}"
)
data["proxy_worker"]["api_bind_addr"] = { # type: ignore[index]
"host": service.appproxy_worker_addr.bind.host,
"port": service.appproxy_worker_addr.bind.port,
}
data["proxy_worker"]["port_proxy"]["bind_port"] = service.appproxy_worker_addr.bind.port # type: ignore[index]
data["proxy_worker"]["port_proxy"]["bind_host"] = service.appproxy_worker_addr.bind.host # type: ignore[index]

# api_bind_addr as inline table
api_bind_addr_table = tomlkit.inline_table()
api_bind_addr_table["host"] = service.appproxy_worker_addr.bind.host
api_bind_addr_table["port"] = service.appproxy_worker_addr.bind.port
data["proxy_worker"]["api_bind_addr"] = api_bind_addr_table # type: ignore[index]

# api_advertised_addr as inline table
api_advertised_addr_table = tomlkit.inline_table()
api_advertised_addr_table["host"] = public_facing_address
api_advertised_addr_table["port"] = service.appproxy_worker_addr.bind.port
data["proxy_worker"]["api_advertised_addr"] = api_advertised_addr_table # type: ignore[index]

data["secrets"]["api_secret"] = service.appproxy_api_secret # type: ignore[index]
data["secrets"]["jwt_secret"] = service.appproxy_jwt_secret # type: ignore[index]
data["permit_hash"]["permit_hash_secret"] = service.appproxy_permit_hash_secret # type: ignore[index]
data["permit_hash"]["secret"] = service.appproxy_permit_hash_secret # type: ignore[index]

# advertise TLS to external clients
if tls_advertised:
data["proxy_worker"]["tls_advertised"] = True # type: ignore[index]

# set frontend mode (port or wildcard)
data["proxy_worker"]["frontend_mode"] = frontend_mode.value # type: ignore[index]

# configure based on frontend_mode
if frontend_mode == FrontendMode.WILDCARD:
# Remove port_proxy section for wildcard mode
if "port_proxy" in data["proxy_worker"]: # type: ignore[operator]
del data["proxy_worker"]["port_proxy"] # type: ignore[union-attr]

# Override api_advertised_addr with app_address and advertised_port
api_advertised_addr_table = tomlkit.inline_table()
api_advertised_addr_table["host"] = app_address
api_advertised_addr_table["port"] = advertised_port
data["proxy_worker"]["api_advertised_addr"] = api_advertised_addr_table # type: ignore[index]

# Add wildcard_domain section
if wildcard_domain:
wildcard_table = tomlkit.table()
wildcard_table["domain"] = wildcard_domain
bind_addr_table = tomlkit.inline_table()
bind_addr_table["host"] = "0.0.0.0"
bind_addr_table["port"] = 10250
Copy link

Copilot AI Jan 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The bind port for wildcard_domain is hardcoded to 10250. Consider whether this should be configurable or at least documented why this specific port is used. If this port conflicts with other services or needs to be different in certain deployments, it could cause issues.

Suggested change
bind_addr_table["port"] = 10250
bind_addr_table["port"] = service.appproxy_worker_addr.bind.port

Copilot uses AI. Check for mistakes.
wildcard_table["bind_addr"] = bind_addr_table
wildcard_table["advertised_port"] = advertised_port
wildcard_table.add(tomlkit.nl()) # Add newline before next section
data["proxy_worker"]["wildcard_domain"] = wildcard_table # type: ignore[index]
else:
# update port_proxy.advertised_host
data["proxy_worker"]["port_proxy"]["advertised_host"] = public_facing_address # type: ignore[index]
with worker_conf.open("w") as fp:
tomlkit.dump(data, fp)

Expand Down
46 changes: 46 additions & 0 deletions src/ai/backend/install/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,16 @@ class Platform(enum.StrEnum):
MACOS_X86_64 = "macos-x86_64"


class FrontendMode(enum.StrEnum):
PORT = "port"
WILDCARD = "wildcard"


class EndpointProtocol(enum.StrEnum):
HTTP = "http"
HTTPS = "https"


@dataclasses.dataclass()
class CliArgs:
mode: InstallModes | None
Expand All @@ -54,6 +64,12 @@ class CliArgs:
non_interactive: bool
public_facing_address: str
accelerator: Optional[str] = None
fqdn_prefix: Optional[str] = None
tls_advertised: bool = False
advertised_port: int = 443
endpoint_protocol: EndpointProtocol | None = None
frontend_mode: FrontendMode = FrontendMode.PORT
use_wildcard_binding: bool = False


class PrerequisiteError(RichCast, Exception):
Expand Down Expand Up @@ -185,3 +201,33 @@ class ServiceConfig:
class InstallVariable:
public_facing_address: str = "127.0.0.1"
accelerator: Optional[Accelerator] = None
fqdn_prefix: Optional[str] = None
tls_advertised: bool = False
advertised_port: int = 443
endpoint_protocol: EndpointProtocol | None = None
frontend_mode: FrontendMode = FrontendMode.PORT
use_wildcard_binding: bool = False

@property
def apphub_address(self) -> str:
if self.fqdn_prefix:
return f"{self.fqdn_prefix}.apphub.backend.ai"
return self.public_facing_address

@property
def app_address(self) -> str:
if self.fqdn_prefix:
return f"{self.fqdn_prefix}.app.backend.ai"
return self.public_facing_address

@property
def wildcard_domain(self) -> Optional[str]:
if self.fqdn_prefix:
return f".{self.fqdn_prefix}.app.backend.ai"
return None

@property
def storage_public_address(self) -> str:
if self.fqdn_prefix:
return f"{self.fqdn_prefix}.public.isla-sorna.backend.ai"
return self.public_facing_address
Loading