blob: 4a47a7317b74fe2ff93a4224a9793abf2f82c011 [file]
#!/usr/bin/env fuchsia-vendored-python
# Copyright 2026 The Fuchsia Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
"""Generic, schema-agnostic service profile management.
This module provides a reusable, protocol-based framework for managing build service
configuration profiles (e.g. Remote Build Execution, ResultStore, Siso).
A service profile system allows developers and automation to switch between different
infrastructure environments (e.g., production, development, untrusted sandbox)
without code modifications or manual environment juggling.
Architecture & Concepts:
-------------------------
1. ServiceProfile (Protocol):
A data class representing a validated configuration for a specific build service.
It must implement `from_dict()` for deserialization and `to_env_dict()` for
serializing its parameters to environment variables for child processes.
2. ServiceProfileSpec:
An immutable configuration specification defining filesystem paths, environment
variable names, and CLI descriptions for a given service.
3. ServiceProfileManager:
The orchestration engine that resolves active profile paths, loads and parses
configurations, persists developer profile selections into a single combined
JSON file in `.fx/config/`, and provides standard CLI subcommands (`list`,
`select`, `active`, `run`).
Resolution Precedence:
----------------------
When resolving the active profile, `ServiceProfileManager` checks sources in this order:
1. Environment Variable Override (`FX_BUILD_<SERVICE>_PROFILE`):
Highest precedence, allowing per-command or CI job overrides.
2. Single Active On-Disk Configuration (`.fx/config/<service>_profile.json`):
Set persistently by running `fx <service> profile select <name>`.
Contains both `"profile_name"` and the full copied profile dictionary.
3. Default Source Tree Profile (`build/<service>/profiles/default.json`):
Fallback when no sticky on-disk profile has been selected yet.
Example Usage:
--------------
```python
import dataclasses
from pathlib import Path
from build.scripts import service_profile
# 1. Define the service-specific profile schema satisfying ServiceProfile
@dataclasses.dataclass
class SisoProfile:
siso_project: str
auth_type: str = "google"
@classmethod
def from_dict(cls, data: service_profile.JSONObject) -> "SisoProfile":
return cls(
siso_project=str(data["siso_project"]),
auth_type=str(data.get("auth_type", "google")),
)
def to_env_dict(self) -> dict[str, str]:
return {
"SISO_PROJECT": self.siso_project,
"SISO_AUTH": self.auth_type,
}
# 2. Define the static filesystem and environment specification
_SPEC = service_profile.ServiceProfileSpec(
service_name="siso",
env_profile_var="FX_BUILD_SISO_PROFILE",
profiles_relpath=Path("build/siso/profiles"),
config_relpath=Path(".fx/config/siso_profile.json"),
description="Manage Siso configuration profiles.",
)
# 3. Instantiate the manager and export public interfaces
_MANAGER = service_profile.ServiceProfileManager(SisoProfile, _SPEC)
ProfilePaths = service_profile.ProfilePaths
resolve_paths = _MANAGER.resolve_paths
select = _MANAGER.select
load = _MANAGER.load
main = _MANAGER.main
```
"""
from __future__ import annotations
import argparse
import dataclasses
import json
import os
import sys
import typing
from collections.abc import Mapping, Sequence
from pathlib import Path
from typing import Any, Generic, TypeVar
class ServiceProfile(typing.Protocol):
"""Protocol defining the structural interface expected of service profile instances.
Any profile class managed by `ServiceProfileManager` must implement both
deserialization (`from_dict`) and subprocess environment serialization (`to_env_dict`).
No inheritance is required; compliance is verified structurally via duck typing.
"""
@classmethod
def from_dict(cls, data: JSONObject) -> typing.Self:
"""Deserializes a profile instance from a raw JSON dictionary.
Args:
data: Raw dictionary loaded from a profile JSON file.
Returns:
A validated instance of the profile class.
Raises:
ValueError: If required fields are missing or values are malformed.
"""
...
def to_env_dict(self) -> dict[str, str]:
"""Serializes profile properties to standard environment variables.
Used by `run` subcommands and child build tools to inject service parameters
into subprocess execution environments.
Returns:
Dictionary mapping environment variable names to string values.
"""
...
T = TypeVar("T", bound=ServiceProfile)
_JSONPrimitive = str | int | float | bool | None
JSONValue = _JSONPrimitive | dict[str, Any] | list[Any]
JSONObject = dict[str, JSONValue]
JSONArray = list[JSONValue]
DEFAULT_PROFILE_NAME = "default"
PROFILE_NAME_KEY = "profile_name"
@dataclasses.dataclass(frozen=True)
class ServiceProfileSpec:
"""Static configuration and metadata defining a service's profile system.
Attributes:
service_name: Canonical identifier for the service (e.g. "rbe", "resultstore").
Used in help messages and user-facing CLI status text.
env_profile_var: Environment variable name used for dynamic profile name overrides
(e.g., "FX_BUILD_RBE_PROFILE", "FX_BUILD_RS_PROFILE").
profiles_relpath: Path relative to the Fuchsia checkout root where available profile
template JSON files reside (e.g., `Path("build/rbe/profiles")`).
config_relpath: Path relative to the Fuchsia checkout root where the active selected
profile JSON is stored (e.g., `Path(".fx/config/rbe_profile.json")`).
description: Description of the service profile tool displayed in CLI help output.
"""
service_name: str
env_profile_var: str
profiles_relpath: Path
config_relpath: Path
description: str = ""
@dataclasses.dataclass
class ProfilePaths:
"""Encapsulates the resolved on-disk paths and metadata for an active profile.
Attributes:
active_name: The resolved active profile name (e.g., "default", "untrusted").
active_json: Absolute or workspace-relative path to the active profile JSON file.
available_json: Sorted list of all available profile template files in the
service's profiles directory.
"""
active_name: str
active_json: Path
available_json: list[Path]
@property
def active_inputs(self) -> list[Path]:
"""Returns the on-disk configuration files that define the active profile.
Suitable for build system dependency tracking (e.g., Ninja or Bazel inputs).
Because the active profile is stored in a single combined JSON file, this
always contains exactly one file path.
"""
return [self.active_json]
class ServiceProfileManager(Generic[T]):
"""Protocol-based, schema-agnostic manager for service configuration profiles.
Coordinates path resolution, template instantiation, single-file `.fx/config/`
persistence, and CLI subcommands.
Generic Parameter:
T: The profile data class, bounded by `ServiceProfile`. Inferred automatically
from the `profile_cls` constructor argument.
"""
def __init__(
self,
profile_cls: type[T],
spec: ServiceProfileSpec,
) -> None:
"""Initializes a new ServiceProfileManager.
Args:
profile_cls: Concrete class implementing `ServiceProfile`.
spec: Immutable specification defining paths and metadata.
"""
self.profile_cls = profile_cls
self.spec = spec
def resolve_paths(
self, fuchsia_dir: Path, environ: Mapping[str, str]
) -> ProfilePaths:
"""Resolves the active profile name, active JSON path, and available profiles.
Follows standard precedence:
1. Environment variable override (`self.spec.env_profile_var`).
2. Single active on-disk configuration file (`self.spec.config_relpath`).
3. Fallback default profile template (`self.spec.profiles_relpath / 'default.json'`).
Args:
fuchsia_dir: Root directory of the Fuchsia source checkout.
environ: Current execution environment dictionary.
Returns:
A `ProfilePaths` instance containing resolved paths and available templates.
"""
profiles_dir = fuchsia_dir / self.spec.profiles_relpath
config_path = fuchsia_dir / self.spec.config_relpath
# LINT.IfChange(service_profile_resolution)
# 1. Environment variable override has highest precedence
env_override = environ.get(self.spec.env_profile_var)
if env_override:
profile_file = profiles_dir / f"{env_override}.json"
if not profile_file.is_file():
raise FileNotFoundError(
f"Profile '{env_override}' specified by {self.spec.env_profile_var} does not exist at {profile_file}"
)
active_json = profile_file
active_name = env_override
# 2. Single active on-disk configuration file
elif config_path.is_file():
try:
data = json.loads(config_path.read_text(encoding="utf-8"))
active_name = (
data.get(PROFILE_NAME_KEY, DEFAULT_PROFILE_NAME)
if isinstance(data, dict)
else DEFAULT_PROFILE_NAME
)
except (json.JSONDecodeError, OSError):
active_name = DEFAULT_PROFILE_NAME
active_json = config_path
# 3. Fallback to default profile template in source tree
else:
active_name = DEFAULT_PROFILE_NAME
active_json = profiles_dir / f"{DEFAULT_PROFILE_NAME}.json"
# LINT.ThenChange(//build/bazel_sdk/bazel_rules_fuchsia/common/fuchsia_platform_build.bzl:service_profile_resolution)
available_json: list[Path] = []
if profiles_dir.is_dir():
available_json = sorted(profiles_dir.glob("*.json"))
return ProfilePaths(
active_name=active_name,
active_json=active_json,
available_json=available_json,
)
def read_active_name(
self, fuchsia_dir: Path, environ: Mapping[str, str]
) -> str:
"""Returns the active profile name."""
return self.resolve_paths(fuchsia_dir, environ).active_name
def select(self, fuchsia_dir: Path, name: str) -> None:
"""Selects a profile by copying its template and recording the name into the single JSON config.
Args:
fuchsia_dir: Root directory of the Fuchsia source checkout.
name: Profile name to select (must exist as `<name>.json` in profiles directory).
Raises:
FileNotFoundError: If the requested profile template does not exist.
ValueError: If the profile template contains invalid JSON.
"""
template_path = (
fuchsia_dir / self.spec.profiles_relpath / f"{name}.json"
)
if not template_path.is_file():
raise FileNotFoundError(
f"Profile '{name}' does not exist at {template_path}"
)
try:
data = json.loads(template_path.read_text(encoding="utf-8"))
if not isinstance(data, dict):
raise ValueError(
f"Profile '{name}' must contain a JSON dictionary object."
)
except json.JSONDecodeError as e:
raise ValueError(
f"Failed to parse profile JSON at {template_path}: {e}"
)
# Record the profile name directly into the single config file
data[PROFILE_NAME_KEY] = name
config_path = fuchsia_dir / self.spec.config_relpath
config_path.parent.mkdir(parents=True, exist_ok=True)
config_path.write_text(json.dumps(data, indent=2) + "\n")
def load(
self,
fuchsia_dir: Path,
environ: Mapping[str, str],
name: str | None = None,
) -> T:
"""Loads and parses the active profile or an explicitly named profile template.
Args:
fuchsia_dir: Root directory of the Fuchsia source checkout.
environ: Current execution environment dictionary.
name: Optional explicit profile name. If provided, loads `<name>.json`
from the profiles template directory directly. Otherwise, loads
the resolved active profile JSON.
Returns:
A validated instance of `self.profile_cls`.
Raises:
FileNotFoundError: If the target profile file does not exist.
ValueError: If profile parsing fails or required fields are invalid.
"""
if name:
template_path = (
fuchsia_dir / self.spec.profiles_relpath / f"{name}.json"
)
if not template_path.is_file():
raise FileNotFoundError(
f"Profile '{name}' does not exist at {template_path}"
)
target_path = template_path
else:
paths = self.resolve_paths(fuchsia_dir, environ)
target_path = paths.active_json
if not target_path.is_file():
raise FileNotFoundError(
f"Profile file does not exist at {target_path}. "
f"Run '{self.spec.service_name} profile select default' to initialize."
)
try:
data = json.loads(target_path.read_text(encoding="utf-8"))
if not isinstance(data, dict):
raise ValueError("Profile content must be a JSON object.")
if PROFILE_NAME_KEY not in data:
data[PROFILE_NAME_KEY] = target_path.stem
except json.JSONDecodeError as e:
raise ValueError(
f"Failed to read/parse {self.spec.service_name} profile at {target_path}: {e}"
)
return self.profile_cls.from_dict(data)
def _cmd_list(
self, args: argparse.Namespace, environ: Mapping[str, str]
) -> int:
paths = self.resolve_paths(args.fuchsia_dir, environ)
for p in paths.available_json:
print(p.stem)
if args.verbose:
content = p.read_text(encoding="utf-8").strip()
for line in content.splitlines():
print(f" {line}")
return 0
def _cmd_select(
self, args: argparse.Namespace, environ: Mapping[str, str]
) -> int:
if not args.name or "/" in args.name or "\\" in args.name:
print(
f"Error: Invalid profile name '{args.name}'.", file=sys.stderr
)
return 1
display_name = (
"ResultStore"
if self.spec.service_name == "resultstore"
else self.spec.service_name.upper()
)
try:
self.select(args.fuchsia_dir, args.name)
except FileNotFoundError:
print(
f"Error: Unknown {display_name} profile '{args.name}'.",
file=sys.stderr,
)
return 1
except ValueError as e:
print(
f"Error: Selected {display_name} profile '{args.name}' is invalid: {e}",
file=sys.stderr,
)
return 1
print(f"{display_name} profile set to: {args.name}")
return 0
def _cmd_active(
self, args: argparse.Namespace, environ: Mapping[str, str]
) -> int:
paths = self.resolve_paths(args.fuchsia_dir, environ)
print(paths.active_name)
if args.verbose and paths.active_json.is_file():
content = paths.active_json.read_text(encoding="utf-8").strip()
for line in content.splitlines():
print(f" {line}")
return 0
def _cmd_auth_type(
self, args: argparse.Namespace, environ: Mapping[str, str]
) -> int:
prof = self.load(args.fuchsia_dir, environ)
print(getattr(prof, "auth_type", "none"))
return 0
def _cmd_run(
self, args: argparse.Namespace, environ: Mapping[str, str]
) -> int:
prof = self.load(args.fuchsia_dir, environ, name=args.profile)
new_env = dict(environ)
new_env.update(prof.to_env_dict())
cmd = args.cmd_args
if cmd and cmd[0] == "--":
cmd = cmd[1:]
if not cmd:
print("Error: No command specified to run.", file=sys.stderr)
return 1
os.execvpe(cmd[0], cmd, new_env)
return 0
def _main_argparser(
self, default_fuchsia_dir: Path | None = None
) -> argparse.ArgumentParser:
"""Constructs and returns the argument parser for the CLI."""
description = (
self.spec.description
or f"Manage {self.spec.service_name} configuration profiles."
)
parser = argparse.ArgumentParser(description=description)
if default_fuchsia_dir is None:
default_fuchsia_dir = Path(__file__).resolve().parent.parent.parent
parser.add_argument(
"--fuchsia-dir",
type=Path,
default=default_fuchsia_dir,
help="Fuchsia checkout root.",
)
subparsers = parser.add_subparsers(dest="subcommand", required=False)
# list
parser_list = subparsers.add_parser(
"list", help=f"List available {self.spec.service_name} profiles."
)
parser_list.add_argument(
"-v",
"--verbose",
action="store_true",
help="Show detailed configuration for each profile.",
)
parser_list.set_defaults(func=self._cmd_list)
# select
parser_select = subparsers.add_parser(
"select",
help=f"Select a {self.spec.service_name} profile as the active profile.",
)
parser_select.add_argument(
"name",
type=str,
help="Profile name to select.",
)
parser_select.set_defaults(func=self._cmd_select)
# active
parser_active = subparsers.add_parser(
"active",
help=f"Print the active {self.spec.service_name} profile name.",
)
parser_active.add_argument(
"-v",
"--verbose",
action="store_true",
help="Show detailed configuration of the active profile.",
)
parser_active.set_defaults(func=self._cmd_active)
# auth-type
parser_auth_type = subparsers.add_parser(
"auth-type",
help=f"Print the active {self.spec.service_name} profile's auth_type.",
)
parser_auth_type.set_defaults(func=self._cmd_auth_type)
# run
parser_run = subparsers.add_parser(
"run",
help=f"Run a command under the context of a {self.spec.service_name} profile.",
)
parser_run.add_argument(
"-p",
"--profile",
type=str,
help="Profile name to load context from (falls back to active profile).",
)
parser_run.add_argument(
"cmd_args",
nargs=argparse.REMAINDER,
help="Command and arguments to execute.",
)
parser_run.set_defaults(func=self._cmd_run)
return parser
def main(self, argv: Sequence[str], environ: Mapping[str, str]) -> int:
"""Standard CLI entry point implementing list, select, active, run subcommands."""
default_fuchsia_dir = (
Path(environ["FUCHSIA_DIR"]) if "FUCHSIA_DIR" in environ else None
)
parser = self._main_argparser(default_fuchsia_dir=default_fuchsia_dir)
args = parser.parse_args(argv)
if not args.subcommand:
args.verbose = False
return self._cmd_active(args, environ)
return args.func(args, environ)