Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
977c32e
refactor(framework): Simplify run-started extension hook
tanertopal Aug 24, 2026
0a3209d
feat(framework): Propagate run source attribution
tanertopal Aug 25, 2026
d4aa97d
fix(framework): Avoid inferring HTTP run source
tanertopal Aug 25, 2026
a585da6
Merge main into codex/run-source-best-effort
tanertopal Aug 25, 2026
83c3bf2
Potential fix for pull request finding
tanertopal Aug 25, 2026
6153c40
fix(framework): Derive run sources from literal
tanertopal Aug 25, 2026
ce72920
Merge remote-tracking branch 'origin/codex/run-source-best-effort' in…
tanertopal Aug 25, 2026
03eee2e
fix(framework): Do not infer gRPC run source
tanertopal Aug 25, 2026
6be5460
Apply suggestion from @panh99
panh99 Aug 25, 2026
a2e3a51
Merge branch 'main' into codex/run-source-best-effort
tanertopal Aug 25, 2026
445b393
Merge branch 'main' into codex/run-source-best-effort
tanertopal Aug 25, 2026
5714621
Simplify run source resolution fallback
tanertopal Aug 25, 2026
c1900f5
Extract run source request dependency
tanertopal Aug 25, 2026
cf7c3ab
Merge branch 'main' into codex/run-source-best-effort
smoroso Aug 26, 2026
4dc6085
Merge branch 'main' into codex/run-source-best-effort
tanertopal Aug 26, 2026
cc42b67
Merge branch 'main' into codex/run-source-best-effort
flwrmachine Aug 26, 2026
d9fed81
Update framework/py/flwr/superlink/servicer/control/control_servicer.py
tanertopal Aug 26, 2026
2db4dfb
Update framework/py/flwr/superlink/servicer/control/control_servicer.py
tanertopal Aug 26, 2026
20353c4
Refactor run-source attribution utilities
tanertopal Aug 26, 2026
f01b57c
Fix run-source import ordering
tanertopal Aug 26, 2026
7864abc
Configure StartRun test metadata
tanertopal Aug 26, 2026
a340d11
Configure remaining StartRun test contexts
tanertopal Aug 26, 2026
6554dce
Bound E2E service cleanup
tanertopal Aug 26, 2026
073d50b
Narrow run-source input to strings
tanertopal Aug 26, 2026
7f635e5
Format run-source test signature
tanertopal Aug 26, 2026
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
3 changes: 3 additions & 0 deletions framework/e2e/test_control_api.sh
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,9 @@ background_pids=()
cleanup() {
if [ "${#background_pids[@]}" -gt 0 ]; then
kill "${background_pids[@]}" 2>/dev/null || true
sleep 1
# SuperNodes can keep retrying after the SuperLink exits.
kill -KILL "${background_pids[@]}" 2>/dev/null || true
Comment thread
tanertopal marked this conversation as resolved.
Comment thread
tanertopal marked this conversation as resolved.
wait "${background_pids[@]}" 2>/dev/null || true
fi
}
Expand Down
35 changes: 35 additions & 0 deletions framework/py/flwr/superlink/dependencies/run_source.py
Comment thread
tanertopal marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# Copyright 2026 Flower Labs GmbH. All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# 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.
# ==============================================================================
"""FastAPI dependency for Control API run-source attribution."""

from typing import Annotated

from fastapi import Depends, Header

from flwr.superlink.run_source import (
RUN_SOURCE_METADATA_KEY,
RunStartSource,
resolve_run_start_source,
)


def get_run_source(
run_source: Annotated[str | None, Header(alias=RUN_SOURCE_METADATA_KEY)] = None,
) -> RunStartSource:
"""Return the normalized run source from the request header."""
return resolve_run_start_source(run_source)


RunSourceDependency = Annotated[RunStartSource, Depends(get_run_source)]
27 changes: 27 additions & 0 deletions framework/py/flwr/superlink/dependencies/run_source_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# Copyright 2026 Flower Labs GmbH. All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# 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.
# ==============================================================================
"""Tests for the Control API run-source dependency."""

from .run_source import get_run_source


def test_get_run_source_defaults_to_unknown() -> None:
"""Return unknown when the request has no source header."""
assert get_run_source() == "unknown"


def test_get_run_source_normalizes_header_value() -> None:
"""Normalize a caller-provided source header."""
assert get_run_source("web_ui") == "web_ui"
7 changes: 4 additions & 3 deletions framework/py/flwr/superlink/extensions.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
# See the License for the specific language governing permissions and
# limitations under the License.
# ==============================================================================
"""SuperLink FastAPI extension hooks."""
"""SuperLink extension hooks."""

from collections.abc import Callable, Mapping
from contextlib import AbstractAsyncContextManager
Expand All @@ -27,11 +27,11 @@

from flwr.common.logger import log
from flwr.supercore.run import Run
from flwr.superlink.run_source import RunStartSource

SuperLinkLifespanContext = Callable[
[FastAPI], AbstractAsyncContextManager[Mapping[str, Any] | None]
]
RunStartSource = Literal["cli", "web_ui", "automation", "unknown"]
ResultDeliveryChannel = Literal["logs", "chat"]
_SGXT_MODULE = "flwr.ee.superlink.extensions"

Expand Down Expand Up @@ -102,7 +102,8 @@ def notify_run_started(run: Run, source: RunStartSource) -> None:
non-blocking and best effort; the Flower framework does not create a
background thread or event loop for it. The run snapshot is copied before
handing it to the extension so the callback cannot mutate the object used
to build the successful StartRun response.
to build the successful StartRun response. The source is also best-effort
caller attribution and must not be used for authorization decisions.
"""
try:
sgxt = _try_import_sgxt()
Expand Down
10 changes: 9 additions & 1 deletion framework/py/flwr/superlink/routers/control/router.py
Comment thread
tanertopal marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@
from flwr.supercore.protobuf.translation import get_protobuf_request
from flwr.superlink.dependencies.account import get_account
from flwr.superlink.dependencies.linkstate import get_linkstate
from flwr.superlink.dependencies.run_source import RunSourceDependency
from flwr.superlink.servicer.control import control_handlers

router = APIRouter(prefix="/v1/control", tags=["Control"], route_class=ProtobufRoute)
Expand All @@ -93,10 +94,17 @@ def start_run(
request: Annotated[StartRunRequest, Depends(get_protobuf_request)],
linkstate: LinkStateDependency,
account: AccountDependency,
run_source: RunSourceDependency,
) -> StartRunResponse:
"""Start a run."""
# Temporary: pass an empty Fleet API type
return control_handlers.start_run(request, account, linkstate, "")
return control_handlers.start_run(
request,
account,
linkstate,
"",
source=run_source,
)


@router.post("/list-runs")
Expand Down
42 changes: 41 additions & 1 deletion framework/py/flwr/superlink/routers/control/router_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@


from datetime import datetime
from unittest.mock import Mock
from unittest.mock import Mock, patch

from fastapi import FastAPI, HTTPException, Request, Response, status
from fastapi.routing import APIRoute
Expand All @@ -26,6 +26,8 @@
from flwr.proto.control_pb2 import ( # pylint: disable=E0611
ListRunsRequest,
ListRunsResponse,
StartRunRequest,
StartRunResponse,
)
from flwr.server.superlink.linkstate import LinkState
from flwr.supercore.auth.typing import AccountInfo
Expand All @@ -41,6 +43,7 @@
from flwr.superlink.dependencies.linkstate import get_linkstate
from flwr.superlink.routers.control.middlewares import ControlAuthenticationMiddleware
from flwr.superlink.routers.control.router import router
from flwr.superlink.routers.control.router import start_run as start_run_route

_ACCOUNT = AccountInfo(flwr_aid=NOOP_FLWR_AID, account_name="account")

Expand Down Expand Up @@ -75,6 +78,43 @@ def test_all_control_routes_have_protobuf_request_types() -> None:
assert route_keys == control_request_types


def test_start_run_forwards_resolved_source() -> None:
"""Forward the normalized source to the control handler."""
request = StartRunRequest()
linkstate = Mock()
expected = StartRunResponse(run_id=1)

with patch(
"flwr.superlink.routers.control.router.control_handlers.start_run",
return_value=expected,
) as start_run:
response = start_run_route(request, linkstate, _ACCOUNT, "unknown")

assert response is expected
start_run.assert_called_once_with(
request,
_ACCOUNT,
linkstate,
"",
source="unknown",
)


def test_start_run_forwards_caller_provided_source() -> None:
"""Forward a caller-provided analytics source without treating it as auth."""
request = StartRunRequest()
linkstate = Mock()
expected = StartRunResponse(run_id=1)

with patch(
"flwr.superlink.routers.control.router.control_handlers.start_run",
return_value=expected,
) as start_run:
start_run_route(request, linkstate, _ACCOUNT, run_source="cli")

assert start_run.call_args.kwargs["source"] == "cli"


def test_protobuf_request_without_handler_response_returns_internal_error() -> None:
"""A configured protobuf route must store its handler response in request state."""
app = FastAPI()
Expand Down
36 changes: 36 additions & 0 deletions framework/py/flwr/superlink/run_source.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# Copyright 2026 Flower Labs GmbH. All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# 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.
# ==============================================================================
"""Run-start source attribution utilities."""

from typing import Literal, cast, get_args

RunStartSource = Literal["cli", "web_ui", "automation", "unknown"]
RUN_SOURCE_METADATA_KEY = "x-flwr-run-source"
_RUN_START_SOURCES = frozenset(get_args(RunStartSource))


def resolve_run_start_source(value: str | None) -> RunStartSource:
"""Normalize a caller-provided source label for analytics.

Source attribution is intentionally best effort. Callers can only affect
the analytics label for their own request, so recognized values are
trusted and invalid values fall back to ``unknown``. This value is not a
security or authorization signal.
"""
if value is None:
return "unknown"
if value not in _RUN_START_SOURCES:
return "unknown"
return cast(RunStartSource, value)
33 changes: 33 additions & 0 deletions framework/py/flwr/superlink/run_source_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# Copyright 2026 Flower Labs GmbH. All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# 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.
# ==============================================================================
"""Tests for run-start source attribution utilities."""

from pytest import mark

from .run_source import RunStartSource, resolve_run_start_source


@mark.parametrize(
("value", "expected"),
[
(None, "unknown"),
("web_ui", "web_ui"),
("automation", "automation"),
("not-a-source", "unknown"),
],
)
def test_resolve_run_start_source(value: str | None, expected: RunStartSource) -> None:
"""Normalize best-effort caller attribution without treating it as auth."""
assert resolve_run_start_source(value) == expected
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,7 @@
from flwr.superlink.artifact_provider import ArtifactProvider
from flwr.superlink.auth_plugin import ControlAuthnPlugin
from flwr.superlink.federation.noop_federation_manager import NoOpFederationManager
from flwr.superlink.run_source import RunStartSource


class InvalidConnectorRequestError(FlowerError):
Expand Down Expand Up @@ -467,7 +468,8 @@ def start_run( # pylint: disable=too-many-branches,too-many-locals,too-many-sta
account: AccountInfo,
state: LinkState,
fleet_api_type: str | None,
source: extensions.RunStartSource = "unknown",
*,
source: RunStartSource = "unknown",
) -> StartRunResponse:
"""Create run ID."""
log(INFO, "ControlServicer.StartRun")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -140,13 +140,19 @@ def test_start_run_notifies_extension_after_persisting_run(self) -> None:
request = StartRunRequest(federation=NOOP_FEDERATION_ID)
request.app_spec = "@flwr/demo==0.0.1"
request.fab.hash_str = fab_hash
response = start_run(request, self.account, self.state, None)
response = start_run(
request,
self.account,
self.state,
None,
source="web_ui",
)

run = self.state.get_run_info(run_ids=[response.run_id])[0]
notify_run_started.assert_called_once()
notified_run, source = notify_run_started.call_args.args
self.assertEqual(notified_run.run_id, run.run_id)
self.assertEqual(source, "unknown")
self.assertEqual(source, "web_ui")

def test_start_run_rejects_unknown_fab_hash(self) -> None:
"""Test StartRun rejects an unknown FAB hash without an app spec."""
Expand Down
Comment thread
tanertopal marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -97,8 +97,10 @@
from flwr.supercore.auth.typing import AccountInfo
from flwr.supercore.error import ApiErrorCode, FlowerError
from flwr.supercore.object_store import ObjectStoreFactory
Comment thread
tanertopal marked this conversation as resolved.
from flwr.supercore.utils import get_metadata_str
from flwr.superlink.artifact_provider import ArtifactProvider
from flwr.superlink.auth_plugin import ControlAuthnPlugin
from flwr.superlink.run_source import RUN_SOURCE_METADATA_KEY, resolve_run_start_source

from . import control_handlers
from .control_account_auth_interceptor import get_current_account_info
Expand Down Expand Up @@ -127,8 +129,17 @@ def StartRun(
self, request: StartRunRequest, context: grpc.ServicerContext
) -> StartRunResponse:
"""Create run ID."""
# Best-effort analytics attribution only;
# trust caller-provided labels and default missing sources to unknown.
metadata = context.invocation_metadata()
run_source = get_metadata_str(metadata, RUN_SOURCE_METADATA_KEY)

return control_handlers.start_run(
request, _get_account(), self.linkstate_factory.state(), self.fleet_api_type
request,
_get_account(),
self.linkstate_factory.state(),
self.fleet_api_type,
source=resolve_run_start_source(run_source),
)

def StreamLogs( # pylint: disable=C0103
Expand Down
Loading
Loading