Repository: NVIDIA/NVFlare
Pull request #3280: Support custom participant types and custom roles in Provision
Author: login: yanchengnv
Created: 2025-03-05T20:51:20Z | Merged: 2025-03-07T19:09:22Z

Fixes # .

### Description

This PR adds back support of custom participant types and custom roles to the Provision system. These features were in previous versions, but got removed accidentally during Provision refactoring.

Also added support of client capacity in StaticFileBuilder. This used to be only available for dashboard in previous versions. 

Also improve the handling of race condition that SJ is not ready when CJ tries to sync.


### Types of changes
<!--- Put an `x` in all the boxes that apply, and remove the not applicable items -->
- [x] Non-breaking change (fix or new feature that would not break existing functionality).
- [ ] Breaking change (fix or new feature that would cause existing functionality to change).
- [ ] New tests added to cover the changes.
- [ ] Quick tests passed locally by running `./runtest.sh`.
- [ ] In-line docstrings updated.
- [ ] Documentation updated.



Diff:
diff --git a/nvflare/apis/utils/format_check.py b/nvflare/apis/utils/format_check.py
index 225f7643da..41c11ea600 100644
--- a/nvflare/apis/utils/format_check.py
+++ b/nvflare/apis/utils/format_check.py
@@ -26,6 +26,7 @@
     "admin": r"^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}$",
     "email": r"^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}$",
     "org": r"^[A-Za-z0-9_]+$",
+    "simple_name": r"^[A-Za-z0-9_]+$",
 }
 
 
diff --git a/nvflare/dashboard/application/blob.py b/nvflare/dashboard/application/blob.py
index c816cdda9f..1514f48f0e 100644
--- a/nvflare/dashboard/application/blob.py
+++ b/nvflare/dashboard/application/blob.py
@@ -96,7 +96,7 @@ def _gen_kit(download_key, prepare_target_cb=None, **cb_kwargs):
 
         # the root key is protected by password
         root_pri_key = deserialize_ca_key(project.root_key)
-        proj_props = {"api_version": 3}
+        proj_props = {PropKey.API_VERSION: 3}
         if project.project_props:
             proj_props.update(json.loads(project.project_props))
 
@@ -113,9 +113,9 @@ def _gen_kit(download_key, prepare_target_cb=None, **cb_kwargs):
         server_name = project.server1
 
         server_props = {
-            "fed_learn_port": fl_port,
-            "admin_port": admin_port,
-            "default_host": server_name,
+            PropKey.FED_LEARN_PORT: fl_port,
+            PropKey.ADMIN_PORT: admin_port,
+            PropKey.DEFAULT_HOST: server_name,
         }
         if project.server_props:
             server_props.update(json.loads(project.server_props))
@@ -152,6 +152,10 @@ def _prepare_client(prov_project: ProvProject, client_id):
         props = json.loads(client.props)
     else:
         props = {}
+
+    if client.capacity:
+        props[PropKey.CAPACITY] = json.loads(client.capacity)
+
     return prov_project.add_client(name=client.name, org=client.organization.name, props=props)
 
 
diff --git a/nvflare/dashboard/application/models.py b/nvflare/dashboard/application/models.py
index f07425ae1f..0b2273448b 100644
--- a/nvflare/dashboard/application/models.py
+++ b/nvflare/dashboard/application/models.py
@@ -38,16 +38,6 @@ class Role(CommonMixin, db.Model):
     pass
 
 
-class Capacity(db.Model):
-    id = db.Column(db.Integer, primary_key=True)
-    capacity = db.Column(db.String(1024), default="")
-    created_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
-    updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
-
-    def asdict(self):
-        return {c.name: getattr(self, c.name) for c in self.__table__.columns}
-
-
 def _fix_props(obj, table_dict: dict, column: str):
     value = getattr(obj, column)
     if value:
@@ -89,14 +79,13 @@ def asdict(self):
 
 
 class Client(CommonMixin, db.Model):
-    capacity_id = db.Column(db.Integer, db.ForeignKey("capacity.id"), nullable=False)
     name = db.Column(db.String(512), unique=True)
     organization_id = db.Column(db.Integer, db.ForeignKey("organization.id"), nullable=False)
-    capacity = db.relationship("Capacity", backref="clients")
     organization = db.relationship("Organization", backref="clients")
     creator_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False)
     approval_state = db.Column(db.Integer, default=0)
     download_count = db.Column(db.Integer, default=0)
+    capacity = db.Column(db.String(2048), default="")  # JSON string
     props = db.Column(db.String(2048), default="")  # additional properties - JSON string
 
     def asdict(self):
@@ -104,10 +93,10 @@ def asdict(self):
         table_dict.update(
             {
                 "organization": self.organization.name,
-                "capacity": json.loads(self.capacity.capacity),
             }
         )
         _fix_props(self, table_dict, "props")
+        _fix_props(self, table_dict, "capacity")
         return table_dict
 
 
diff --git a/nvflare/dashboard/application/store.py b/nvflare/dashboard/application/store.py
index f5788f2260..707f677634 100644
--- a/nvflare/dashboard/application/store.py
+++ b/nvflare/dashboard/application/store.py
@@ -18,7 +18,7 @@
 from werkzeug.security import check_password_hash, generate_password_hash
 
 from .cert import Entity, make_root_cert
-from .models import Capacity, Client, Organization, Project, Role, User, db
+from .models import Client, Organization, Project, Role, User, db
 
 log = logging.getLogger(__name__)
 
@@ -167,16 +167,15 @@ def create_client(cls, req, creator):
         creator_id = User.query.filter_by(email=creator).first().id
         name = req.get("name")
         organization = req.get("organization", "")
-        capacity = req.get("capacity")
         description = req.get("description", "")
         org = get_or_create(db.session, Organization, name=organization)
-        cap = None
-        if capacity is not None:
-            cap = get_or_create(db.session, Capacity, capacity=json.dumps(capacity))
         client = Client(name=name, description=description, creator_id=creator_id)
         client.organization_id = org.id
-        if cap:
-            client.capacity_id = cap.id
+
+        capacity = req.get("capacity")
+        if capacity:
+            client.capacity = json.dumps(capacity)
+
         props = req.get("props")
         if props:
             client.props = json.dumps(props)
@@ -223,10 +222,8 @@ def patch_client_by_project_admin(cls, id, req):
             client.organization_id = org.id
 
         capacity = req.pop("capacity", None)
-        if capacity is not None:
-            capacity = json.dumps(capacity)
-            cap = get_or_create(db.session, Capacity, capacity=capacity)
-            client.capacity_id = cap.id
+        if capacity:
+            client.capacity = json.dumps(capacity)
 
         props = req.pop("props", None)
         if props:
@@ -254,10 +251,8 @@ def patch_client_by_creator(cls, id, req):
             client.organization_id = org.id
 
         capacity = req.pop("capacity", None)
-        if capacity is not None:
-            capacity = json.dumps(capacity)
-            cap = get_or_create(db.session, Capacity, capacity=capacity)
-            client.capacity_id = cap.id
+        if capacity:
+            client.capacity = json.dumps(capacity)
 
         props = req.pop("props", None)
         if props:
diff --git a/nvflare/lighter/constants.py b/nvflare/lighter/constants.py
index 434d58b33b..f6cd7e6565 100644
--- a/nvflare/lighter/constants.py
+++ b/nvflare/lighter/constants.py
@@ -29,9 +29,20 @@ class ParticipantType:
     RELAY = "relay"
 
 
+DEFINED_PARTICIPANT_TYPES = [
+    ParticipantType.SERVER,
+    ParticipantType.CLIENT,
+    ParticipantType.ADMIN,
+    ParticipantType.OVERSEER,
+    ParticipantType.RELAY,
+]
+
+
 class PropKey:
     API_VERSION = "api_version"
     NAME = "name"
+    TYPE = "type"
+    ORG = "org"
     HOST = "host"
     DESCRIPTION = "description"
     ROLE = "role"
@@ -50,6 +61,9 @@ class PropKey:
     CUSTOM_CA_CERT = "custom_ca_cert"
     SCHEME = "scheme"
     RUN_IN_DOCKER = "run_in_docker"
+    CAPACITY = "capacity"
+    NUM_GPUS = "num_of_gpus"
+    GPU_MEM = "mem_per_gpu_in_GiB"
 
     # the following are internal properties added by builders during provision
     PARENT = "parent"
@@ -91,6 +105,9 @@ class AdminRole:
     MEMBER = "member"
 
 
+DEFINED_ROLES = [AdminRole.PROJECT_ADMIN, AdminRole.ORG_ADMIN, AdminRole.LEAD, AdminRole.MEMBER]
+
+
 class OverseerRole:
     SERVER = "server"
     CLIENT = "client"
diff --git a/nvflare/lighter/entity.py b/nvflare/lighter/entity.py
index a2bec1bcfa..e49bf38958 100644
--- a/nvflare/lighter/entity.py
+++ b/nvflare/lighter/entity.py
@@ -11,11 +11,11 @@
 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 # See the License for the specific language governing permissions and
 # limitations under the License.
-from typing import Any, Optional
+from typing import Any, List, Optional, Union
 
 from nvflare.apis.utils.format_check import name_check
 
-from .constants import AdminRole, ConnSecurity, ParticipantType, PropKey
+from .constants import DEFINED_PARTICIPANT_TYPES, DEFINED_ROLES, ConnSecurity, ParticipantType, PropKey
 
 
 class ListeningHost:
@@ -64,9 +64,11 @@ def _check_host_names(scope: str, prop_key: str, value):
 
 
 def _check_admin_role(scope: str, prop_key: str, value):
-    valid_roles = [AdminRole.PROJECT_ADMIN, AdminRole.ORG_ADMIN, AdminRole.LEAD, AdminRole.MEMBER]
-    if value not in valid_roles:
-        raise ValueError(f"bad value for {prop_key} '{value}' in {scope}: must be one of {valid_roles}")
+    if not isinstance(value, str):
+        raise ValueError(f"bad value for {prop_key} '{value}' in {scope}: must be str but got {type(value)}")
+
+    if not value:
+        raise ValueError(f"empty value for {prop_key} '{value}' in {scope}")
 
 
 def parse_connect_to(value, scope=None, prop_key=None) -> ConnectTo:
@@ -111,8 +113,11 @@ def _check_connect_to(scope: str, prop_key: str, value):
 
 
 def _check_conn_security(scope: str, prop_key: str, value):
+    if not isinstance(value, str):
+        raise ValueError(f"bad value for {prop_key} '{value}' in {scope}: must be a str but got {type(value)}")
+
     valid_conn_secs = [ConnSecurity.CLEAR, ConnSecurity.MTLS, ConnSecurity.TLS]
-    if value not in valid_conn_secs:
+    if value.lower() not in valid_conn_secs:
         raise ValueError(f"bad value for {prop_key} '{value}' in {scope}: must be one of {valid_conn_secs}")
 
 
@@ -234,14 +239,35 @@ def __init__(self, type: str, name: str, org: str, props: dict = None, project:
         """
         Entity.__init__(self, f"{type}::{name}", name, props, parent=project)
 
-        err, reason = name_check(name, type)
-        if err:
-            raise ValueError(reason)
+        if type in DEFINED_PARTICIPANT_TYPES:
+            err, reason = name_check(name, type)
+            if err:
+                raise ValueError(reason)
+        else:
+            err, reason = name_check(type, "simple_name")
+            if err:
+                raise ValueError(reason)
+            print(f"Warning: participant type '{type}' of {name} is not a defined type {DEFINED_PARTICIPANT_TYPES}")
 
         err, reason = name_check(org, "org")
         if err:
             raise ValueError(reason)
 
+        if type == ParticipantType.ADMIN:
+            if not props:
+                raise ValueError(f"missing role for admin '{name}'")
+
+            role = props.get(PropKey.ROLE)
+            if not role:
+                raise ValueError(f"missing role for admin '{name}'")
+
+            err, reason = name_check(role, "simple_name")
+            if err:
+                raise ValueError(f"bad role value '{role}' for admin '{name}': {reason}")
+
+            if role not in DEFINED_ROLES:
+                print(f"Warning: '{role}' of admin '{name}' is not a defined role {DEFINED_ROLES}")
+
         self.type = type
         self.org = org
         self.subject = name
@@ -261,6 +287,11 @@ def get_default_host(self) -> str:
             return self.name
 
     def get_listening_host(self) -> Optional[ListeningHost]:
+        """Get listening host property of the participant
+
+        Returns: a ListeningHost object, or None if the property is not defined.
+
+        """
         h = self.get_prop(PropKey.LISTENING_HOST)
         if not h:
             return None
@@ -284,6 +315,11 @@ def get_listening_host(self) -> Optional[ListeningHost]:
         return lh
 
     def get_connect_to(self) -> Optional[ConnectTo]:
+        """Get the connect_to property of the participant
+
+        Returns: a ConnectTo object
+
+        """
         h = self.get_prop(PropKey.CONNECT_TO)
         if not h:
             return None
@@ -291,20 +327,38 @@ def get_connect_to(self) -> Optional[ConnectTo]:
             return parse_connect_to(h)
 
 
-def participant_from_dict(participant_def: dict, project=None) -> Participant:
-    name = participant_def.pop("name", None)
-    if not name:
-        raise ValueError("missing participant name")
+def _must_get(d: dict, key: str):
+    """Must get property of the specified key from the dict
 
-    type = participant_def.pop("type", None)
-    if not type:
-        raise ValueError("missing participant type")
+    Args:
+        d: the dict that contains participant properties
+        key: key of the property to get
+
+    Returns: the value of the property. If the property does not exist, ValueError exception is raised.
 
-    org = participant_def.pop("org", None)
-    if org:
-        raise ValueError("missing participant org")
+    """
+    v = d.pop(key, None)
+    if not v:
+        raise ValueError(f"missing participant {key}")
+    return v
 
-    return Participant(type=type, name=name, org=org, props=participant_def, project=project)
+
+def participant_from_dict(participant_def: dict) -> Participant:
+    """Create a Participant from a dict that contains participant property definitions.
+
+    Args:
+        participant_def: the dict that contains participant definition
+
+    Returns: a Participant object
+
+    """
+    if not isinstance(participant_def, dict):
+        raise ValueError(f"participant_def must be dict but got {type(participant_def)}")
+
+    name = _must_get(participant_def, PropKey.NAME)
+    t = _must_get(participant_def, PropKey.TYPE)
+    org = _must_get(participant_def, PropKey.ORG)
+    return Participant(type=t, name=name, org=org, props=participant_def)
 
 
 class Project(Entity):
@@ -343,10 +397,8 @@ def __init__(
         self.root_private_key = root_private_key
         self.server = None
         self.overseer = None
-        self.clients = []
-        self.admins = []
-        self.relays = []
-        self.all_names = {}
+        self._participants_by_types = {}  # participant type => list of participants
+        self._all_names = {}  # name => participant
 
         if participants:
             if not isinstance(participants, list):
@@ -355,31 +407,22 @@ def __init__(
             for p in participants:
                 if not isinstance(p, Participant):
                     raise ValueError(f"bad item in participants: must be Participant but got {type(p)}")
+                self.add_participant(p)
 
-                if p.type == ParticipantType.SERVER:
-                    self.set_server(p.name, p.org, p.props)
-                elif p.type == ParticipantType.ADMIN:
-                    self.add_admin(p.name, p.org, p.props)
-                elif p.type == ParticipantType.CLIENT:
-                    self.add_client(p.name, p.org, p.props)
-                elif p.type == ParticipantType.OVERSEER:
-                    self.set_overseer(p.name, p.org, p.props)
-                else:
-                    raise ValueError(f"invalid value for ParticipantType: {p.type}")
-
-    def _check_unique_name(self, name: str):
-        if name in self.all_names:
-            raise ValueError(f"the project {self.name} already has a participant with the name '{name}'")
-
-    def set_server(self, name: str, org: str, props: dict):
-        if self.server:
-            raise ValueError(f"project {self.name} already has a server defined")
-        self._check_unique_name(name)
-        self.server = Participant(ParticipantType.SERVER, name, org, props, self)
-        self.all_names[name] = True
-        return self.server
+    def set_server(self, name: str, org: str, props: dict) -> Participant:
+        """Set the server of the project.
+
+        Args:
+            name: name of the server.
+            org: org of the server
+            props: additional server properties.
 
-    def get_server(self):
+        Returns: a Participant object for the server
+
+        """
+        return self.add_participant(Participant(ParticipantType.SERVER, name, org, props))
+
+    def get_server(self) -> Optional[Participant]:
         """Get the server definition. Only one server is supported!
 
         Returns: server participant
@@ -387,14 +430,10 @@ def get_server(self):
         """
         return self.server
 
-    def set_overseer(self, name: str, org: str, props: dict):
-        if self.overseer:
-            raise ValueError(f"project {self.name} already has an overseer defined")
-        self._check_unique_name(name)
-        self.overseer = Participant(ParticipantType.OVERSEER, name, org, props, self)
-        self.all_names[name] = True
+    def set_overseer(self, name: str, org: str, props: dict) -> Participant:
+        return self.add_participant(Participant(ParticipantType.OVERSEER, name, org, props))
 
-    def get_overseer(self):
+    def get_overseer(self) -> Optional[Participant]:
         """Get the overseer definition. Only one overseer is supported!
 
         Returns: overseer participant
@@ -402,67 +441,131 @@ def get_overseer(self):
         """
         return self.overseer
 
-    def add_participant(self, participant: Participant):
-        self._check_unique_name(participant.name)
+    def add_participant(self, participant: Participant) -> Participant:
+        """Add a participant to the project.
+        Before adding the participant, this method checks the following conditions:
+        - All participants in the project must have unique names
+        - Only one server is allowed in the project
+        - Only one overseer is allowed in the project
+        - Role must be specified for admin type of participant
+
+        Args:
+            participant: the participant to be added.
+
+        Returns: the participant object added.
+
+        """
+        if participant.name in self._all_names:
+            raise ValueError(f"the project {self.name} already has a participant with the name '{participant.name}'")
+
         participant.parent = self
-        if participant.type == ParticipantType.CLIENT:
-            self.clients.append(participant)
-        elif participant.type == ParticipantType.RELAY:
-            self.relays.append(participant)
-        elif participant.type == ParticipantType.SERVER:
+        if participant.type == ParticipantType.SERVER:
             if self.server:
                 raise ValueError(f"cannot add participant {participant.name} as server - server already exists")
             self.server = participant
-        elif participant.type == ParticipantType.ADMIN:
-            self.admins.append(participant)
         elif participant.type == ParticipantType.OVERSEER:
             if self.overseer:
                 raise ValueError(f"cannot add participant {participant.name} as overseer - overseer already exists")
             self.overseer = participant
-        else:
-            raise ValueError(f"invalid participant type: {participant.type}")
-        self.all_names[participant.name] = True
-
-    def add_client(self, name: str, org: str, props: dict):
-        self._check_unique_name(name)
-        client = Participant(ParticipantType.CLIENT, name, org, props, self)
-        self.clients.append(client)
-        self.all_names[name] = True
-        return client
-
-    def get_clients(self):
-        return self.clients
-
-    def add_relay(self, name: str, org: str, props: dict):
-        self._check_unique_name(name)
-        self.relays.append(Participant(ParticipantType.RELAY, name, org, props, self))
-        self.all_names[name] = True
-
-    def get_relays(self):
-        return self.relays
-
-    def add_admin(self, name: str, org: str, props: dict):
-        self._check_unique_name(name)
-        admin = Participant(ParticipantType.ADMIN, name, org, props, self)
-        role = admin.get_prop(PropKey.ROLE)
-        if not role:
-            raise ValueError(f"missing role in admin '{name}'")
-        self.admins.append(admin)
-        self.all_names[name] = True
-        return admin
-
-    def get_admins(self):
-        return self.admins
-
-    def get_all_participants(self):
-        result = []
-        if self.server:
-            result.append(self.server)
 
-        if self.overseer:
-            result.append(self.overseer)
+        participants = self._participants_by_types.get(participant.type)
+        if not participants:
+            participants = []
+            self._participants_by_types[participant.type] = participants
+        participants.append(participant)
+        self._all_names[participant.name] = participant
+        return participant
 
-        result.extend(self.clients)
-        result.extend(self.relays)
-        result.extend(self.admins)
+    def add_client(self, name: str, org: str, props: dict) -> Participant:
+        """Add a client to the project
+
+        Args:
+            name: name of the client
+            org: org of the client
+            props: additional properties of the client
+
+        Returns: the Participant object of the client
+
+        """
+        return self.add_participant(Participant(ParticipantType.CLIENT, name, org, props))
+
+    def get_clients(self) -> List[Participant]:
+        """Get all clients of the project
+
+        Returns: a list of clients
+
+        """
+        return self.get_all_participants(ParticipantType.CLIENT)
+
+    def add_relay(self, name: str, org: str, props: dict) -> Participant:
+        """Add a relay to the project
+
+        Args:
+            name: name of the relay
+            org: org of the relay
+            props: additional properties of the relay
+
+        Returns: the relay Participant object
+
+        """
+        return self.add_participant(Participant(ParticipantType.RELAY, name, org, props))
+
+    def get_relays(self) -> List[Participant]:
+        """Get all relays of the project
+
+        Returns: the list of relays of the project
+
+        """
+        return self.get_all_participants(ParticipantType.RELAY)
+
+    def add_admin(self, name: str, org: str, props: dict) -> Participant:
+        """Add an admin user to the project
+
+        Args:
+            name: name of the admin user.
+            org: org of the admin user.
+            props: properties of the user definition
+
+        Returns: a Participant object of the admin user
+
+        """
+        return self.add_participant(Participant(ParticipantType.ADMIN, name, org, props))
+
+    def get_admins(self) -> List[Participant]:
+        """Get the list of admin users
+
+        Returns: list of admin users
+
+        """
+        return self.get_all_participants(ParticipantType.ADMIN)
+
+    def get_all_participants(self, types: Union[None, str, List[str]] = None):
+        """Get all participants of the project of specified types.
+
+        Args:
+            types: types of the participants to be returned.
+
+        Returns: all participants of the project of specified types.
+            If 'types' is not specified (None), it returns all participants of the project;
+            If 'types' is a str, it is treated as a single type and participants of this type is returned;
+            If 'types' is a list of types, participants of these types are returned;
+
+        """
+        if not types:
+            # get all types
+            return list(self._all_names.values())
+
+        if isinstance(types, str):
+            types = [types]
+        elif not isinstance(types, list):
+            raise ValueError(f"types must be a str or List[str] but got {type(types)}")
+
+        result = []
+        processed_types = []  # in case 'types' contains duplicates
+        for t in types:
+            if t not in processed_types:
+                ps = self._participants_by_types.get(t)
+                if ps:
+                    result.extend(ps)
+                processed_types.append(t)
         return result
diff --git a/nvflare/lighter/impl/static_file.py b/nvflare/lighter/impl/static_file.py
index ad410265bf..d6a58b060b 100644
--- a/nvflare/lighter/impl/static_file.py
+++ b/nvflare/lighter/impl/static_file.py
@@ -298,10 +298,23 @@ def _build_client(self, client: Participant, ctx: ProvisionContext):
 
         ctx.build_from_template(dest_dir, TemplateSectionKey.LOG_CONFIG, ProvFileName.LOG_CONFIG_DEFAULT)
 
+        num_gpus = 0
+        gpu_mem = 0
+        capacity = client.get_prop(PropKey.CAPACITY)
+        if capacity:
+            num_gpus = capacity.get(PropKey.NUM_GPUS, 0)
+            gpu_mem = capacity.get(PropKey.GPU_MEM, 0)
+
+        replacement_dict = {
+            "num_gpus": num_gpus,
+            "gpu_mem": gpu_mem,
+        }
+
         ctx.build_from_template(
             dest_dir,
             TemplateSectionKey.LOCAL_CLIENT_RESOURCES,
             ProvFileName.RESOURCES_JSON_DEFAULT,
+            replacement=replacement_dict,
             content_modify_cb=self._modify_error_sender,
             client=client,
         )
@@ -860,14 +873,17 @@ def finalize(self, project: Project, ctx: ProvisionContext):
                     temp_section=TemplateSectionKey.COMM_CONFIG,
                     file_name=ProvFileName.COMM_CONFIG,
                     replacement=replacement_dict,
-                    content_modify_cb=self._remove_undefined_port,
+                    content_modify_cb=_remove_undefined_port,
                 )
 
         # create start_all.sh
         self._create_start_all(project, ctx)
 
     @staticmethod
-    def _create_start_all(project: Project, ctx: ProvisionContext):
+    def _append(content: str, participant: Participant) -> str:
+        return content + f"./{participant.name}/startup/start.sh\n"
+
+    def _create_start_all(self, project: Project, ctx: ProvisionContext):
         """Create the start_all.sh script to be used for starting all sites (server, relays and clients).
         This is a convenience script and not part of any site's startup kit.
 
@@ -880,8 +896,8 @@ def _create_start_all(project: Project, ctx: ProvisionContext):
         """
         content = "#!/usr/bin/env bash\n"
 
-        server = ctx.get_project().get_server()
-        content += f"./{server.name}/startup/start.sh\n"
+        server = project.get_server()
+        content = self._append(content, server)
 
         # include all relays
         relays = project.get_relays()
@@ -889,40 +905,41 @@ def _create_start_all(project: Project, ctx: ProvisionContext):
             # sort relays based on their FQCNs
             relays.sort(key=lambda x: len(x.get_prop(PropKey.FQCN)))
             for r in relays:
-                content += f"./{r.name}/startup/start.sh\n"
+                content = self._append(content, r)
 
         # include all clients
         for c in project.get_clients():
-            content += f"./{c.name}/startup/start.sh\n"
+            content = self._append(content, c)
 
         utils.write(os.path.join(ctx.get_wip_dir(), "start_all.sh"), content, "t", exe=True)
 
-    def _remove_undefined_port(self, section: str) -> str:
-        """This is the callback for checking and removing undefined port number for comm_config.
-        Since the templating system does not allow conditional args, each arg must have a value when
-        generating the section from the template. We used port 0 to represent undefined port number.
-        We must remove undefined port number from comm_config; otherwise Flare wouldn't work in run time.
 
-        Args:
-            section: the section data to be checked
+def _remove_undefined_port(section: str) -> str:
+    """This is the callback for checking and removing undefined port number for comm_config.
+    Since the templating system does not allow conditional args, each arg must have a value when
+    generating the section from the template. We used port 0 to represent undefined port number.
+    We must remove undefined port number from comm_config; otherwise Flare wouldn't work in run time.
 
-        Returns: modified section data
+    Args:
+        section: the section data to be checked
 
-        """
-        # section is JSON string - convert to dict for easy check and modification
-        section_dict = json.loads(section)
-        resources = section_dict.get("internal", {}).get("resources")
-        if resources:
-            port = resources.get(PropKey.PORT)
-            if port is None or port == 0:
-                # this is undefined port - remove it
-                resources.pop(PropKey.PORT, None)
-
-            # convert dict back to JSON string
-            return json.dumps(section_dict, indent=2)
-        else:
-            # no change
-            return section
+    Returns: modified section data
+
+    """
+    # section is JSON string - convert to dict for easy check and modification
+    section_dict = json.loads(section)
+    resources = section_dict.get("internal", {}).get("resources")
+    if resources:
+        port = resources.get(PropKey.PORT)
+        if port is None or port == 0:
+            # this is undefined port - remove it
+            resources.pop(PropKey.PORT, None)
+
+        # convert dict back to JSON string
+        return json.dumps(section_dict, indent=2)
+    else:
+        # no change
+        return section
 
 
 def check_parent(c: Participant, path: list):
diff --git a/nvflare/lighter/provision.py b/nvflare/lighter/provision.py
index ae8160d7f3..c64adf8722 100644
--- a/nvflare/lighter/provision.py
+++ b/nvflare/lighter/provision.py
@@ -22,7 +22,8 @@
 from typing import Optional
 
 from nvflare.fuel.utils.class_utils import instantiate_class
-from nvflare.lighter.constants import ParticipantType, PropKey
+from nvflare.lighter.constants import PropKey
+from nvflare.lighter.entity import participant_from_dict
 from nvflare.lighter.provisioner import Provisioner
 from nvflare.lighter.spec import Project
 from nvflare.lighter.utils import load_yaml
@@ -130,13 +131,6 @@ def prepare_builders(project_dict):
     return builders
 
 
-def _must_get(participant_def: dict, key: str):
-    v = participant_def.get(key)
-    if not v:
-        raise ValueError(f"missing property '{key}' from participant definition")
-    return v
-
-
 def prepare_project(project_dict, add_user_file_path=None, add_client_file_path=None):
     api_version = project_dict.get(PropKey.API_VERSION)
     if api_version not in [3]:
@@ -157,21 +151,7 @@ def prepare_project(project_dict, add_user_file_path=None, add_client_file_path=
         add_extra_clients(add_client_file_path, participant_defs)
 
     for p in participant_defs:
-        participant_type = _must_get(p, "type")
-        name = _must_get(p, "name")
-        org = _must_get(p, "org")
-        if participant_type == ParticipantType.SERVER:
-            project.set_server(name, org, props=p)
-        elif participant_type == ParticipantType.CLIENT:
-            project.add_client(name, org, p)
-        elif participant_type == ParticipantType.RELAY:
-            project.add_relay(name, org, p)
-        elif participant_type == ParticipantType.ADMIN:
-            project.add_admin(name, org, p)
-        elif participant_type == ParticipantType.OVERSEER:
-            project.set_overseer(name, org, p)
-        else:
-            raise ValueError(f"invalid participant_type '{participant_type}'")
+        project.add_participant(participant_from_dict(p))
     return project
 
 
diff --git a/nvflare/lighter/templates/master_template.yml b/nvflare/lighter/templates/master_template.yml
index d596a44b9d..459c6118d3 100644
--- a/nvflare/lighter/templates/master_template.yml
+++ b/nvflare/lighter/templates/master_template.yml
@@ -84,8 +84,8 @@ local_client_resources: |
         "id": "resource_manager",
         "path": "nvflare.app_common.resource_managers.gpu_resource_manager.GPUResourceManager",
         "args": {
-          "num_of_gpus": 0,
-          "mem_per_gpu_in_GiB": 0
+          "num_of_gpus": {~~num_gpus~~},
+          "mem_per_gpu_in_GiB": {~~gpu_mem~~}
         }
       },
       {
diff --git a/nvflare/private/fed/server/server_command_agent.py b/nvflare/private/fed/server/server_command_agent.py
index 995de0df9a..79a8b698d4 100644
--- a/nvflare/private/fed/server/server_command_agent.py
+++ b/nvflare/private/fed/server/server_command_agent.py
@@ -112,19 +112,17 @@ def aux_communicate(self, request: CellMessage) -> CellMessage:
                 make_reply(ReturnCode.AUTHENTICATION_ERROR, error, None)
 
             engine = fl_ctx.get_engine()
-            reply = engine.dispatch(topic=topic, request=data, fl_ctx=fl_ctx)
+            if not engine:
+                # I (SJ) cannot process this request because my engine is not set yet.
+                # This happens only when a CJ became ready quickly and send runner_sync request to the SJ.
+                # I'll simply tell it that my service is not available, and it will retry.
+                return make_reply(ReturnCode.SERVICE_UNAVAILABLE)
 
-            self.logger.debug("Before gen_new_peer_ctx")
-            shared_fl_ctx = gen_new_peer_ctx(fl_ctx)
-            self.logger.debug("After gen_new_peer_ctx")
-            reply.set_peer_context(shared_fl_ctx)
-
-            if reply is not None:
-                return_message = new_cell_message({}, reply)
-                return_message.set_header(MessageHeaderKey.RETURN_CODE, ReturnCode.OK)
-            else:
-                return_message = new_cell_message({}, None)
-            return return_message
+            reply = engine.dispatch(topic=topic, request=data, fl_ctx=fl_ctx)
+            if reply:
+                shared_fl_ctx = gen_new_peer_ctx(fl_ctx)
+                reply.set_peer_context(shared_fl_ctx)
+            return new_cell_message({MessageHeaderKey.RETURN_CODE: ReturnCode.OK}, reply)
 
     def shutdown(self):
         self.asked_to_stop = True
diff --git a/tests/unit_test/lighter/participant_test.py b/tests/unit_test/lighter/participant_test.py
index cb0b3d8339..29942d5d5f 100644
--- a/tests/unit_test/lighter/participant_test.py
+++ b/tests/unit_test/lighter/participant_test.py
@@ -45,7 +45,7 @@ def test_invalid_org(self, invalid_org):
 
     @pytest.mark.parametrize(
         "invalid_type",
-        [("type1"), ("type@"), ("type!"), ("type~"), ("gateway"), ("firewall")],
+        [("type@"), ("type!"), ("type~")],
     )
     def test_invalid_type(self, invalid_type):
         with pytest.raises(ValueError):
diff --git a/tests/unit_test/lighter/project_test.py b/tests/unit_test/lighter/project_test.py
index f462c954ec..ebce87826b 100644
--- a/tests/unit_test/lighter/project_test.py
+++ b/tests/unit_test/lighter/project_test.py
@@ -29,13 +29,13 @@ class TestProject:
     def test_single_server(self):
         p1 = Participant(name="server1", org="org", type="server")
         p2 = Participant(name="server2", org="org", type="server")
-        with pytest.raises(ValueError, match=r".* already has a server defined"):
+        with pytest.raises(ValueError, match=r".* server already exists"):
             _ = Project("name", "description", [p1, p2])
 
     def test_single_overseer(self):
         p1 = Participant(name="name1", org="org", type="overseer")
         p2 = Participant(name="name2", org="org", type="overseer")
-        with pytest.raises(ValueError, match=r".* already has an overseer defined"):
+        with pytest.raises(ValueError, match=r".* overseer already exists"):
             _ = Project("name", "description", [p1, p2])
 
     def test_get_clients(self):
@@ -55,14 +55,13 @@ def test_get_admins(self):
         assert all(c[i].name == p[i].name and c[i].org == p[i].org for i in range(len(p)))
 
     def test_admin_role_required(self):
-        p = create_participants(type="admin", number=3, org="org", name="admin@nvidia.com")
         with pytest.raises(ValueError, match=r"missing role *."):
-            _ = Project("name", "description", p)
+            create_participants(type="admin", number=3, org="org", name="admin@nvidia.com")
 
     def test_bad_admin_role(self):
-        with pytest.raises(ValueError, match=r"bad value for role *."):
+        with pytest.raises(ValueError, match=r"bad role value *."):
             _ = create_participants(
-                type="admin", number=3, org="org", name="admin@nvidia.com", props={"role": "invalid"}
+                type="admin", number=3, org="org", name="admin@nvidia.com", props={"role": "invalid~~"}
             )
 
     @pytest.mark.parametrize(
diff --git a/tests/unit_test/lighter/provision_test.py b/tests/unit_test/lighter/provision_test.py
index 82a4951de1..7f3ebef27a 100644
--- a/tests/unit_test/lighter/provision_test.py
+++ b/tests/unit_test/lighter/provision_test.py
@@ -34,5 +34,5 @@ def test_prepare_project(self):
             ],
         }
 
-        with pytest.raises(ValueError, match=".* already has a server defined"):
+        with pytest.raises(ValueError, match=".* server already exists"):
             prepare_project(project_dict=project_config)


Reviews:
user: login: YuanTingHsieh
body: Mostly LGTM
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-06T22:31:34Z
user: login: yanchengnv
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-06T23:13:39Z
user: login: yanchengnv
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-06T23:14:45Z
user: login: nvkevlu
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-03-07T02:41:52Z
user: login: yanchengnv
body: Reviewed
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-07T17:22:46Z
user: login: YuanTingHsieh
body: Thanks for the clarification, LGTM
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-03-07T19:09:34Z

Review comments:
diff_hunk: @@ -254,10 +251,9 @@ def patch_client_by_creator(cls, id, req):
             client.organization_id = org.id
 
         capacity = req.pop("capacity", None)
-        if capacity is not None:
-            capacity = json.dumps(capacity)
-            cap = get_or_create(db.session, Capacity, capacity=capacity)
-            client.capacity_id = cap.id
+        if capacity:
+            print(f"from creator: {capacity=}")
path: nvflare/dashboard/application/store.py
user: login: YuanTingHsieh
body: Do we need this print or this is for debugging purpose
created_at: 2025-03-06T22:06:58Z
author_association: COLLABORATOR
diff_hunk: @@ -45,7 +45,7 @@ def test_invalid_org(self, invalid_org):
 
     @pytest.mark.parametrize(
         "invalid_type",
-        [("type1"), ("type@"), ("type!"), ("type~"), ("gateway"), ("firewall")],
+        [("type@"), ("type!"), ("type~")],
path: tests/unit_test/lighter/participant_test.py
user: login: YuanTingHsieh
body: so we allow ("gateway"), ("firewall") as type right now?
created_at: 2025-03-06T22:12:13Z
author_association: COLLABORATOR
diff_hunk: @@ -112,19 +112,17 @@ def aux_communicate(self, request: CellMessage) -> CellMessage:
                 make_reply(ReturnCode.AUTHENTICATION_ERROR, error, None)
 
             engine = fl_ctx.get_engine()
-            reply = engine.dispatch(topic=topic, request=data, fl_ctx=fl_ctx)
+            if not engine:
+                # I (SJ) cannot process this request because my engine is not set yet.
+                # This happens only when a CJ became ready quickly and send runner_sync request to the SJ.
+                # I'll simply tell it that my service is not available, and it will retry.
+                return make_reply(ReturnCode.SERVICE_UNAVAILABLE)
 
-            self.logger.debug("Before gen_new_peer_ctx")
-            shared_fl_ctx = gen_new_peer_ctx(fl_ctx)
-            self.logger.debug("After gen_new_peer_ctx")
-            reply.set_peer_context(shared_fl_ctx)
-
-            if reply is not None:
-                return_message = new_cell_message({}, reply)
-                return_message.set_header(MessageHeaderKey.RETURN_CODE, ReturnCode.OK)
-            else:
-                return_message = new_cell_message({}, None)
-            return return_message
+            reply = engine.dispatch(topic=topic, request=data, fl_ctx=fl_ctx)
+            if reply:
+                shared_fl_ctx = gen_new_peer_ctx(fl_ctx)
+                reply.set_peer_context(shared_fl_ctx)
+            return new_cell_message({MessageHeaderKey.RETURN_CODE: ReturnCode.OK}, reply)
path: nvflare/private/fed/server/server_command_agent.py
user: login: YuanTingHsieh
body: if reply is None, we will return a cell message with "ReturnCode.OK" is that what we want?


created_at: 2025-03-06T22:14:06Z
author_association: COLLABORATOR
diff_hunk: @@ -284,27 +315,50 @@ def get_listening_host(self) -> Optional[ListeningHost]:
         return lh
 
     def get_connect_to(self) -> Optional[ConnectTo]:
+        """Get the connect_to property of the participant
+
+        Returns: a ConnectTo object
+
+        """
         h = self.get_prop(PropKey.CONNECT_TO)
         if not h:
             return None
         else:
             return parse_connect_to(h)
 
 
-def participant_from_dict(participant_def: dict, project=None) -> Participant:
-    name = participant_def.pop("name", None)
-    if not name:
-        raise ValueError("missing participant name")
+def _must_get(d: dict, key: str):
+    """Must get property of the specified key from the dict
 
-    type = participant_def.pop("type", None)
-    if not type:
-        raise ValueError("missing participant type")
+    Args:
+        d: the dict that contains participant properties
+        key: key of the property to get
+
+    Returns: the value of the property. If the property does not exist, ValueError exception is raised.
 
-    org = participant_def.pop("org", None)
-    if org:
-        raise ValueError("missing participant org")
+    """
+    v = d.pop(key, None)
+    if not v:
+        raise ValueError(f"missing participant {key}")
+    return v
path: nvflare/lighter/entity.py
user: login: YuanTingHsieh
body: this ```_must_get``` method is equivalent as just:

v = d.pop(key)

just that using pop will raise "KeyError" 
created_at: 2025-03-06T22:21:38Z
author_association: COLLABORATOR
diff_hunk: @@ -284,27 +315,50 @@ def get_listening_host(self) -> Optional[ListeningHost]:
         return lh
 
     def get_connect_to(self) -> Optional[ConnectTo]:
+        """Get the connect_to property of the participant
+
+        Returns: a ConnectTo object
+
+        """
         h = self.get_prop(PropKey.CONNECT_TO)
         if not h:
             return None
         else:
             return parse_connect_to(h)
 
 
-def participant_from_dict(participant_def: dict, project=None) -> Participant:
-    name = participant_def.pop("name", None)
-    if not name:
-        raise ValueError("missing participant name")
+def _must_get(d: dict, key: str):
+    """Must get property of the specified key from the dict
 
-    type = participant_def.pop("type", None)
-    if not type:
-        raise ValueError("missing participant type")
+    Args:
+        d: the dict that contains participant properties
+        key: key of the property to get
+
+    Returns: the value of the property. If the property does not exist, ValueError exception is raised.
 
-    org = participant_def.pop("org", None)
-    if org:
-        raise ValueError("missing participant org")
+    """
+    v = d.pop(key, None)
+    if not v:
+        raise ValueError(f"missing participant {key}")
+    return v
 
-    return Participant(type=type, name=name, org=org, props=participant_def, project=project)
+
+def participant_from_dict(participant_def: dict) -> Participant:
+    """Create a Participant from a dict that contains participant property definitions.
+
+    Args:
+        participant_def: the dict that contains participant definition
+
+    Returns: a Participant object
+
+    """
+    if not isinstance(participant_def, dict):
+        raise ValueError(f"participant_def must be dict but got {type(participant_def)}")
+
+    name = _must_get(participant_def, PropKey.NAME)
path: nvflare/lighter/entity.py
user: login: YuanTingHsieh
body: we can use ``participant_def.pop(PropKey.NAME)`` instead
created_at: 2025-03-06T22:22:23Z
author_association: COLLABORATOR
diff_hunk: @@ -355,114 +407,165 @@ def __init__(
             for p in participants:
                 if not isinstance(p, Participant):
                     raise ValueError(f"bad item in participants: must be Participant but got {type(p)}")
+                self.add_participant(p)
 
-                if p.type == ParticipantType.SERVER:
-                    self.set_server(p.name, p.org, p.props)
-                elif p.type == ParticipantType.ADMIN:
-                    self.add_admin(p.name, p.org, p.props)
-                elif p.type == ParticipantType.CLIENT:
-                    self.add_client(p.name, p.org, p.props)
-                elif p.type == ParticipantType.OVERSEER:
-                    self.set_overseer(p.name, p.org, p.props)
-                else:
-                    raise ValueError(f"invalid value for ParticipantType: {p.type}")
-
-    def _check_unique_name(self, name: str):
-        if name in self.all_names:
-            raise ValueError(f"the project {self.name} already has a participant with the name '{name}'")
-
-    def set_server(self, name: str, org: str, props: dict):
-        if self.server:
-            raise ValueError(f"project {self.name} already has a server defined")
-        self._check_unique_name(name)
-        self.server = Participant(ParticipantType.SERVER, name, org, props, self)
-        self.all_names[name] = True
-        return self.server
+    def set_server(self, name: str, org: str, props: dict) -> Participant:
+        """Set the server of the project.
+
+        Args:
+            name: name of the server.
+            org: org of the server
+            props: additional server properties.
 
-    def get_server(self):
+        Returns: a Participant object for the server
+
+        """
+        return self.add_participant(Participant(ParticipantType.SERVER, name, org, props))
+
+    def get_server(self) -> Optional[Participant]:
         """Get the server definition. Only one server is supported!
 
         Returns: server participant
 
         """
         return self.server
 
-    def set_overseer(self, name: str, org: str, props: dict):
-        if self.overseer:
-            raise ValueError(f"project {self.name} already has an overseer defined")
-        self._check_unique_name(name)
-        self.overseer = Participant(ParticipantType.OVERSEER, name, org, props, self)
-        self.all_names[name] = True
+    def set_overseer(self, name: str, org: str, props: dict) -> Participant:
+        return self.add_participant(Participant(ParticipantType.OVERSEER, name, org, props))
 
-    def get_overseer(self):
+    def get_overseer(self) -> Optional[Participant]:
         """Get the overseer definition. Only one overseer is supported!
 
         Returns: overseer participant
 
         """
         return self.overseer
 
-    def add_participant(self, participant: Participant):
-        self._check_unique_name(participant.name)
+    def add_participant(self, participant: Participant) -> Participant:
+        """Add a participant to the project.
+        Before adding the participant, this method checks the following conditions:
+        - All participants in the project must have unique names
+        - Only one server is allowed in the project
+        - Only one overseer is allowed in the project
+        - Role must be specified for admin type of participant
+
+        Args:
+            participant: the participant to be added.
+
+        Returns: the participant object added.
+
+        """
+        if participant.name in self._all_names:
+            raise ValueError(f"the project {self.name} already has a participant with the name '{participant.name}'")
+
         participant.parent = self
-        if participant.type == ParticipantType.CLIENT:
-            self.clients.append(participant)
-        elif participant.type == ParticipantType.RELAY:
-            self.relays.append(participant)
-        elif participant.type == ParticipantType.SERVER:
+        if participant.type == ParticipantType.SERVER:
             if self.server:
                 raise ValueError(f"cannot add participant {participant.name} as server - server already exists")
             self.server = participant
-        elif participant.type == ParticipantType.ADMIN:
-            self.admins.append(participant)
         elif participant.type == ParticipantType.OVERSEER:
             if self.overseer:
                 raise ValueError(f"cannot add participant {participant.name} as overseer - overseer already exists")
             self.overseer = participant
-        else:
-            raise ValueError(f"invalid participant type: {participant.type}")
-        self.all_names[participant.name] = True
-
-    def add_client(self, name: str, org: str, props: dict):
-        self._check_unique_name(name)
-        client = Participant(ParticipantType.CLIENT, name, org, props, self)
-        self.clients.append(client)
-        self.all_names[name] = True
-        return client
-
-    def get_clients(self):
-        return self.clients
-
-    def add_relay(self, name: str, org: str, props: dict):
-        self._check_unique_name(name)
-        self.relays.append(Participant(ParticipantType.RELAY, name, org, props, self))
-        self.all_names[name] = True
-
-    def get_relays(self):
-        return self.relays
-
-    def add_admin(self, name: str, org: str, props: dict):
-        self._check_unique_name(name)
-        admin = Participant(ParticipantType.ADMIN, name, org, props, self)
-        role = admin.get_prop(PropKey.ROLE)
-        if not role:
-            raise ValueError(f"missing role in admin '{name}'")
-        self.admins.append(admin)
-        self.all_names[name] = True
-        return admin
-
-    def get_admins(self):
-        return self.admins
-
-    def get_all_participants(self):
-        result = []
-        if self.server:
-            result.append(self.server)
 
-        if self.overseer:
-            result.append(self.overseer)
+        participants = self._participants_by_types.get(participant.type)
+        if not participants:
+            participants = []
+            self._participants_by_types[participant.type] = participants
+        participants.append(participant)
+        self._all_names[participant.name] = participant
+        return participant
 
-        result.extend(self.clients)
-        result.extend(self.relays)
-        result.extend(self.admins)
+    def add_client(self, name: str, org: str, props: dict) -> Participant:
+        """Add a client to the project
+
+        Args:
+            name: name of the client
+            org: org of the client
+            props: additional properties of the client
+
+        Returns: the Participant object of the client
+
+        """
+        return self.add_participant(Participant(ParticipantType.CLIENT, name, org, props))
+
+    def get_clients(self) -> List[Participant]:
+        """Get all clients of the project
+
+        Returns: a list of clients
+
+        """
+        return self.get_all_participants(ParticipantType.CLIENT)
+
+    def add_relay(self, name: str, org: str, props: dict) -> Participant:
+        """Add a relay to the project
+
+        Args:
+            name: name of the relay
+            org: org of the relay
+            props: additional properties of the relay
+
+        Returns: the relay Participant object
+
+        """
+        return self.add_participant(Participant(ParticipantType.RELAY, name, org, props))
+
+    def get_relays(self) -> List[Participant]:
+        """Get all relays of the project
+
+        Returns: the list of relays of the project
+
+        """
+        return self.get_all_participants(ParticipantType.RELAY)
+
+    def add_admin(self, name: str, org: str, props: dict) -> Participant:
+        """Add an admin user to the project
+
+        Args:
+            name: name of the admin user.
+            org: org of the admin user.
+            props: properties of the user definition
+
+        Returns: a Participant object of the admin user
+
+        """
+        return self.add_participant(Participant(ParticipantType.ADMIN, name, org, props))
+
+    def get_admins(self) -> List[Participant]:
+        """Get the list of admin users
+
+        Returns: list of admin users
+
+        """
+        return self.get_all_participants(ParticipantType.ADMIN)
+
+    def get_all_participants(self, types: Union[None, str, List[str]] = None):
+        """Get all participants of the project of specified types.
+
+        Args:
+            types: types of the participants to be returned.
+
+        Returns: all participants of the project of specified types.
+            If 'types' is not specified (None), it returns all participants of the project;
+            If 'types' is a str, it is treated as a single type and participants of this type is returned;
+            If 'types' is a list of types, participants of these types are returned;
+
+        """
+        if not types:
+            # get all types
+            return list(self._all_names.values())
path: nvflare/lighter/entity.py
user: login: YuanTingHsieh
body: if the ``self._all_names = a={"clients": [1,2,3], "admin": [4,5]}``

this ``list(self._all_names.values())``

will give a nested list:

``[[1, 2, 3], [4, 5]]``

Do we want a flat list?
``[1,2,3,4,5]``
created_at: 2025-03-06T22:28:10Z
author_association: COLLABORATOR
diff_hunk: @@ -860,14 +873,17 @@ def finalize(self, project: Project, ctx: ProvisionContext):
                     temp_section=TemplateSectionKey.COMM_CONFIG,
                     file_name=ProvFileName.COMM_CONFIG,
                     replacement=replacement_dict,
-                    content_modify_cb=self._remove_undefined_port,
+                    content_modify_cb=_remove_undefined_port,
                 )
 
         # create start_all.sh
         self._create_start_all(project, ctx)
 
     @staticmethod
-    def _create_start_all(project: Project, ctx: ProvisionContext):
+    def _append(content: str, participant: Participant) -> str:
+        return content + f"./{participant.name}/startup/start.sh\n"
+
+    def _create_start_all(self, project: Project, ctx: ProvisionContext):
path: nvflare/lighter/impl/static_file.py
user: login: YuanTingHsieh
body: since _append is a staticmethod, this method can still remain static if we want to, we can call "StaticFileBuilder._append"
created_at: 2025-03-06T22:31:01Z
author_association: COLLABORATOR
diff_hunk: @@ -254,10 +251,9 @@ def patch_client_by_creator(cls, id, req):
             client.organization_id = org.id
 
         capacity = req.pop("capacity", None)
-        if capacity is not None:
-            capacity = json.dumps(capacity)
-            cap = get_or_create(db.session, Capacity, capacity=capacity)
-            client.capacity_id = cap.id
+        if capacity:
+            print(f"from creator: {capacity=}")
path: nvflare/dashboard/application/store.py
user: login: yanchengnv
body: Oh, this is just for debug. Will remove.
created_at: 2025-03-06T23:13:39Z
author_association: COLLABORATOR
diff_hunk: @@ -45,7 +45,7 @@ def test_invalid_org(self, invalid_org):
 
     @pytest.mark.parametrize(
         "invalid_type",
-        [("type1"), ("type@"), ("type!"), ("type~"), ("gateway"), ("firewall")],
+        [("type@"), ("type!"), ("type~")],
path: tests/unit_test/lighter/participant_test.py
user: login: yanchengnv
body: Yes. This is a request from some customers so that they can use our Provision tool to provision their participant types. And this is one of the main purposes of this PR.
created_at: 2025-03-06T23:14:45Z
author_association: COLLABORATOR
diff_hunk: @@ -112,19 +112,17 @@ def aux_communicate(self, request: CellMessage) -> CellMessage:
                 make_reply(ReturnCode.AUTHENTICATION_ERROR, error, None)
 
             engine = fl_ctx.get_engine()
-            reply = engine.dispatch(topic=topic, request=data, fl_ctx=fl_ctx)
+            if not engine:
+                # I (SJ) cannot process this request because my engine is not set yet.
+                # This happens only when a CJ became ready quickly and send runner_sync request to the SJ.
+                # I'll simply tell it that my service is not available, and it will retry.
+                return make_reply(ReturnCode.SERVICE_UNAVAILABLE)
 
-            self.logger.debug("Before gen_new_peer_ctx")
-            shared_fl_ctx = gen_new_peer_ctx(fl_ctx)
-            self.logger.debug("After gen_new_peer_ctx")
-            reply.set_peer_context(shared_fl_ctx)
-
-            if reply is not None:
-                return_message = new_cell_message({}, reply)
-                return_message.set_header(MessageHeaderKey.RETURN_CODE, ReturnCode.OK)
-            else:
-                return_message = new_cell_message({}, None)
-            return return_message
+            reply = engine.dispatch(topic=topic, request=data, fl_ctx=fl_ctx)
+            if reply:
+                shared_fl_ctx = gen_new_peer_ctx(fl_ctx)
+                reply.set_peer_context(shared_fl_ctx)
+            return new_cell_message({MessageHeaderKey.RETURN_CODE: ReturnCode.OK}, reply)
path: nvflare/private/fed/server/server_command_agent.py
user: login: yanchengnv
body: Yes, that is the same as the current behavior, except that currently it's not done right (missing return code).
created_at: 2025-03-06T23:17:02Z
author_association: COLLABORATOR
diff_hunk: @@ -284,27 +315,50 @@ def get_listening_host(self) -> Optional[ListeningHost]:
         return lh
 
     def get_connect_to(self) -> Optional[ConnectTo]:
+        """Get the connect_to property of the participant
+
+        Returns: a ConnectTo object
+
+        """
         h = self.get_prop(PropKey.CONNECT_TO)
         if not h:
             return None
         else:
             return parse_connect_to(h)
 
 
-def participant_from_dict(participant_def: dict, project=None) -> Participant:
-    name = participant_def.pop("name", None)
-    if not name:
-        raise ValueError("missing participant name")
+def _must_get(d: dict, key: str):
+    """Must get property of the specified key from the dict
 
-    type = participant_def.pop("type", None)
-    if not type:
-        raise ValueError("missing participant type")
+    Args:
+        d: the dict that contains participant properties
+        key: key of the property to get
+
+    Returns: the value of the property. If the property does not exist, ValueError exception is raised.
 
-    org = participant_def.pop("org", None)
-    if org:
-        raise ValueError("missing participant org")
+    """
+    v = d.pop(key, None)
+    if not v:
+        raise ValueError(f"missing participant {key}")
+    return v
path: nvflare/lighter/entity.py
user: login: yanchengnv
body: That's right, this is only to have a nicer error message.
created_at: 2025-03-06T23:18:06Z
author_association: COLLABORATOR
diff_hunk: @@ -284,27 +315,50 @@ def get_listening_host(self) -> Optional[ListeningHost]:
         return lh
 
     def get_connect_to(self) -> Optional[ConnectTo]:
+        """Get the connect_to property of the participant
+
+        Returns: a ConnectTo object
+
+        """
         h = self.get_prop(PropKey.CONNECT_TO)
         if not h:
             return None
         else:
             return parse_connect_to(h)
 
 
-def participant_from_dict(participant_def: dict, project=None) -> Participant:
-    name = participant_def.pop("name", None)
-    if not name:
-        raise ValueError("missing participant name")
+def _must_get(d: dict, key: str):
+    """Must get property of the specified key from the dict
 
-    type = participant_def.pop("type", None)
-    if not type:
-        raise ValueError("missing participant type")
+    Args:
+        d: the dict that contains participant properties
+        key: key of the property to get
+
+    Returns: the value of the property. If the property does not exist, ValueError exception is raised.
 
-    org = participant_def.pop("org", None)
-    if org:
-        raise ValueError("missing participant org")
+    """
+    v = d.pop(key, None)
+    if not v:
+        raise ValueError(f"missing participant {key}")
+    return v
 
-    return Participant(type=type, name=name, org=org, props=participant_def, project=project)
+
+def participant_from_dict(participant_def: dict) -> Participant:
+    """Create a Participant from a dict that contains participant property definitions.
+
+    Args:
+        participant_def: the dict that contains participant definition
+
+    Returns: a Participant object
+
+    """
+    if not isinstance(participant_def, dict):
+        raise ValueError(f"participant_def must be dict but got {type(participant_def)}")
+
+    name = _must_get(participant_def, PropKey.NAME)
path: nvflare/lighter/entity.py
user: login: yanchengnv
body: See my comment above
created_at: 2025-03-06T23:18:23Z
author_association: COLLABORATOR
diff_hunk: @@ -355,114 +407,165 @@ def __init__(
             for p in participants:
                 if not isinstance(p, Participant):
                     raise ValueError(f"bad item in participants: must be Participant but got {type(p)}")
+                self.add_participant(p)
 
-                if p.type == ParticipantType.SERVER:
-                    self.set_server(p.name, p.org, p.props)
-                elif p.type == ParticipantType.ADMIN:
-                    self.add_admin(p.name, p.org, p.props)
-                elif p.type == ParticipantType.CLIENT:
-                    self.add_client(p.name, p.org, p.props)
-                elif p.type == ParticipantType.OVERSEER:
-                    self.set_overseer(p.name, p.org, p.props)
-                else:
-                    raise ValueError(f"invalid value for ParticipantType: {p.type}")
-
-    def _check_unique_name(self, name: str):
-        if name in self.all_names:
-            raise ValueError(f"the project {self.name} already has a participant with the name '{name}'")
-
-    def set_server(self, name: str, org: str, props: dict):
-        if self.server:
-            raise ValueError(f"project {self.name} already has a server defined")
-        self._check_unique_name(name)
-        self.server = Participant(ParticipantType.SERVER, name, org, props, self)
-        self.all_names[name] = True
-        return self.server
+    def set_server(self, name: str, org: str, props: dict) -> Participant:
+        """Set the server of the project.
+
+        Args:
+            name: name of the server.
+            org: org of the server
+            props: additional server properties.
 
-    def get_server(self):
+        Returns: a Participant object for the server
+
+        """
+        return self.add_participant(Participant(ParticipantType.SERVER, name, org, props))
+
+    def get_server(self) -> Optional[Participant]:
         """Get the server definition. Only one server is supported!
 
         Returns: server participant
 
         """
         return self.server
 
-    def set_overseer(self, name: str, org: str, props: dict):
-        if self.overseer:
-            raise ValueError(f"project {self.name} already has an overseer defined")
-        self._check_unique_name(name)
-        self.overseer = Participant(ParticipantType.OVERSEER, name, org, props, self)
-        self.all_names[name] = True
+    def set_overseer(self, name: str, org: str, props: dict) -> Participant:
+        return self.add_participant(Participant(ParticipantType.OVERSEER, name, org, props))
 
-    def get_overseer(self):
+    def get_overseer(self) -> Optional[Participant]:
         """Get the overseer definition. Only one overseer is supported!
 
         Returns: overseer participant
 
         """
         return self.overseer
 
-    def add_participant(self, participant: Participant):
-        self._check_unique_name(participant.name)
+    def add_participant(self, participant: Participant) -> Participant:
+        """Add a participant to the project.
+        Before adding the participant, this method checks the following conditions:
+        - All participants in the project must have unique names
+        - Only one server is allowed in the project
+        - Only one overseer is allowed in the project
+        - Role must be specified for admin type of participant
+
+        Args:
+            participant: the participant to be added.
+
+        Returns: the participant object added.
+
+        """
+        if participant.name in self._all_names:
+            raise ValueError(f"the project {self.name} already has a participant with the name '{participant.name}'")
+
         participant.parent = self
-        if participant.type == ParticipantType.CLIENT:
-            self.clients.append(participant)
-        elif participant.type == ParticipantType.RELAY:
-            self.relays.append(participant)
-        elif participant.type == ParticipantType.SERVER:
+        if participant.type == ParticipantType.SERVER:
             if self.server:
                 raise ValueError(f"cannot add participant {participant.name} as server - server already exists")
             self.server = participant
-        elif participant.type == ParticipantType.ADMIN:
-            self.admins.append(participant)
         elif participant.type == ParticipantType.OVERSEER:
             if self.overseer:
                 raise ValueError(f"cannot add participant {participant.name} as overseer - overseer already exists")
             self.overseer = participant
-        else:
-            raise ValueError(f"invalid participant type: {participant.type}")
-        self.all_names[participant.name] = True
-
-    def add_client(self, name: str, org: str, props: dict):
-        self._check_unique_name(name)
-        client = Participant(ParticipantType.CLIENT, name, org, props, self)
-        self.clients.append(client)
-        self.all_names[name] = True
-        return client
-
-    def get_clients(self):
-        return self.clients
-
-    def add_relay(self, name: str, org: str, props: dict):
-        self._check_unique_name(name)
-        self.relays.append(Participant(ParticipantType.RELAY, name, org, props, self))
-        self.all_names[name] = True
-
-    def get_relays(self):
-        return self.relays
-
-    def add_admin(self, name: str, org: str, props: dict):
-        self._check_unique_name(name)
-        admin = Participant(ParticipantType.ADMIN, name, org, props, self)
-        role = admin.get_prop(PropKey.ROLE)
-        if not role:
-            raise ValueError(f"missing role in admin '{name}'")
-        self.admins.append(admin)
-        self.all_names[name] = True
-        return admin
-
-    def get_admins(self):
-        return self.admins
-
-    def get_all_participants(self):
-        result = []
-        if self.server:
-            result.append(self.server)
 
-        if self.overseer:
-            result.append(self.overseer)
+        participants = self._participants_by_types.get(participant.type)
+        if not participants:
+            participants = []
+            self._participants_by_types[participant.type] = participants
+        participants.append(participant)
+        self._all_names[participant.name] = participant
+        return participant
 
-        result.extend(self.clients)
-        result.extend(self.relays)
-        result.extend(self.admins)
+    def add_client(self, name: str, org: str, props: dict) -> Participant:
+        """Add a client to the project
+
+        Args:
+            name: name of the client
+            org: org of the client
+            props: additional properties of the client
+
+        Returns: the Participant object of the client
+
+        """
+        return self.add_participant(Participant(ParticipantType.CLIENT, name, org, props))
+
+    def get_clients(self) -> List[Participant]:
+        """Get all clients of the project
+
+        Returns: a list of clients
+
+        """
+        return self.get_all_participants(ParticipantType.CLIENT)
+
+    def add_relay(self, name: str, org: str, props: dict) -> Participant:
+        """Add a relay to the project
+
+        Args:
+            name: name of the relay
+            org: org of the relay
+            props: additional properties of the relay
+
+        Returns: the relay Participant object
+
+        """
+        return self.add_participant(Participant(ParticipantType.RELAY, name, org, props))
+
+    def get_relays(self) -> List[Participant]:
+        """Get all relays of the project
+
+        Returns: the list of relays of the project
+
+        """
+        return self.get_all_participants(ParticipantType.RELAY)
+
+    def add_admin(self, name: str, org: str, props: dict) -> Participant:
+        """Add an admin user to the project
+
+        Args:
+            name: name of the admin user.
+            org: org of the admin user.
+            props: properties of the user definition
+
+        Returns: a Participant object of the admin user
+
+        """
+        return self.add_participant(Participant(ParticipantType.ADMIN, name, org, props))
+
+    def get_admins(self) -> List[Participant]:
+        """Get the list of admin users
+
+        Returns: list of admin users
+
+        """
+        return self.get_all_participants(ParticipantType.ADMIN)
+
+    def get_all_participants(self, types: Union[None, str, List[str]] = None):
+        """Get all participants of the project of specified types.
+
+        Args:
+            types: types of the participants to be returned.
+
+        Returns: all participants of the project of specified types.
+            If 'types' is not specified (None), it returns all participants of the project;
+            If 'types' is a str, it is treated as a single type and participants of this type is returned;
+            If 'types' is a list of types, participants of these types are returned;
+
+        """
+        if not types:
+            # get all types
+            return list(self._all_names.values())
path: nvflare/lighter/entity.py
user: login: yanchengnv
body: No, self._all_names is a simple dict of: str => Participant. The key is participant name, and the value is a single participant object. So list(self._all_names.values()) simply casts "values" to a list.

What you discussed here is self._participants_by_types, which is participant type => list of participants of that type.
created_at: 2025-03-06T23:22:51Z
author_association: COLLABORATOR
diff_hunk: @@ -860,14 +873,17 @@ def finalize(self, project: Project, ctx: ProvisionContext):
                     temp_section=TemplateSectionKey.COMM_CONFIG,
                     file_name=ProvFileName.COMM_CONFIG,
                     replacement=replacement_dict,
-                    content_modify_cb=self._remove_undefined_port,
+                    content_modify_cb=_remove_undefined_port,
                 )
 
         # create start_all.sh
         self._create_start_all(project, ctx)
 
     @staticmethod
-    def _create_start_all(project: Project, ctx: ProvisionContext):
+    def _append(content: str, participant: Participant) -> str:
+        return content + f"./{participant.name}/startup/start.sh\n"
+
+    def _create_start_all(self, project: Project, ctx: ProvisionContext):
path: nvflare/lighter/impl/static_file.py
user: login: yanchengnv
body: We could, but calling self.XXX saves you a few key strokes.  :-)
created_at: 2025-03-06T23:23:39Z
author_association: COLLABORATOR

PR comments:
user: login: yanchengnv
created_at: 2025-03-06T19:38:08Z
body: /build

author_association: COLLABORATOR
user: login: yanchengnv
created_at: 2025-03-06T19:38:57Z
body: /build
author_association: COLLABORATOR
user: login: yanchengnv
created_at: 2025-03-07T18:54:08Z
body: /build
author_association: COLLABORATOR