fred-pod 4.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
fred_pod/__init__.py ADDED
@@ -0,0 +1,135 @@
1
+ # Copyright Thales 2026
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """
16
+ What a Fred component needs to *be* a pod: configuration, identity, naming.
17
+
18
+ A Knowledge Base pod, a capability pod, an MCP server pod and an agent pod all
19
+ read a `configuration.yaml`, get a machine-to-machine token and name things
20
+ under a prefix they own. That floor is this distribution, and it depends on
21
+ four third-party packages — pydantic, PyYAML, python-dotenv, httpx.
22
+
23
+ "What do I need to run a Fred component?" → `fred-pod`.
24
+ "What do I need to build an agent?" → `fred-core` / `fred-sdk`.
25
+
26
+ Unlike `fred-core`, importing this package is cheap and stays cheap: a
27
+ separate distribution cannot import what it does not depend on, so the
28
+ boundary is enforced by the build rather than by discipline.
29
+ """
30
+
31
+ from fred_pod.common.config_files import ConfigFiles
32
+ from fred_pod.common.config_loader import (
33
+ TConfig,
34
+ get_config,
35
+ load_configuration_with_config_files,
36
+ parse_yaml_mapping_file,
37
+ )
38
+ from fred_pod.common.naming import (
39
+ CONTRIBUTED_NAME_PATTERN,
40
+ KNOWLEDGE_BASE_CATALOG_NAMESPACE_PREFIX,
41
+ MAX_NAME_CHARS,
42
+ PREFIX_PATTERN,
43
+ InvalidContributedName,
44
+ knowledge_base_catalog_id,
45
+ knowledge_base_name_from_catalog_id,
46
+ prefix_covers,
47
+ require_contributed_name,
48
+ )
49
+ from fred_pod.common.structures import (
50
+ BaseModelWithId,
51
+ DuckdbStoreConfig,
52
+ InMemoryStoreConfig,
53
+ KpiLogSinkConfig,
54
+ KpiObservabilityConfig,
55
+ KpiOpenSearchSinkConfig,
56
+ KpiPrometheusSinkConfig,
57
+ LogStoreConfig,
58
+ ModelConfiguration,
59
+ OpenSearchIndexConfig,
60
+ OpenSearchStoreConfig,
61
+ OwnerFilter,
62
+ PostgresStoreConfig,
63
+ PostgresTableConfig,
64
+ StoreConfig,
65
+ TemporalSchedulerConfig,
66
+ )
67
+ from fred_pod.security.backend_to_backend_auth import (
68
+ M2MAuthConfig,
69
+ M2MBearerAuth,
70
+ M2MTokenProvider,
71
+ make_m2m_asgi_client,
72
+ )
73
+ from fred_pod.security.structure import (
74
+ LOCAL_DEV_CLIENT_ID,
75
+ SERVICE_AGENT_ROLE,
76
+ KeycloakUser,
77
+ M2MSecurity,
78
+ OpenFgaRebacConfig,
79
+ RebacBaseConfig,
80
+ RebacConfiguration,
81
+ SecurityConfiguration,
82
+ UserSecurity,
83
+ is_service_agent,
84
+ )
85
+
86
+ __all__ = [
87
+ # Configuration
88
+ "ConfigFiles",
89
+ "TConfig",
90
+ "get_config",
91
+ "load_configuration_with_config_files",
92
+ "parse_yaml_mapping_file",
93
+ # Naming
94
+ "CONTRIBUTED_NAME_PATTERN",
95
+ "KNOWLEDGE_BASE_CATALOG_NAMESPACE_PREFIX",
96
+ "MAX_NAME_CHARS",
97
+ "PREFIX_PATTERN",
98
+ "InvalidContributedName",
99
+ "knowledge_base_catalog_id",
100
+ "knowledge_base_name_from_catalog_id",
101
+ "prefix_covers",
102
+ "require_contributed_name",
103
+ # Configuration models
104
+ "BaseModelWithId",
105
+ "DuckdbStoreConfig",
106
+ "InMemoryStoreConfig",
107
+ "KpiLogSinkConfig",
108
+ "KpiObservabilityConfig",
109
+ "KpiOpenSearchSinkConfig",
110
+ "KpiPrometheusSinkConfig",
111
+ "LogStoreConfig",
112
+ "ModelConfiguration",
113
+ "OpenSearchIndexConfig",
114
+ "OpenSearchStoreConfig",
115
+ "OwnerFilter",
116
+ "PostgresStoreConfig",
117
+ "PostgresTableConfig",
118
+ "StoreConfig",
119
+ "TemporalSchedulerConfig",
120
+ # Identity
121
+ "LOCAL_DEV_CLIENT_ID",
122
+ "SERVICE_AGENT_ROLE",
123
+ "KeycloakUser",
124
+ "M2MAuthConfig",
125
+ "M2MBearerAuth",
126
+ "M2MSecurity",
127
+ "M2MTokenProvider",
128
+ "OpenFgaRebacConfig",
129
+ "RebacBaseConfig",
130
+ "RebacConfiguration",
131
+ "SecurityConfiguration",
132
+ "UserSecurity",
133
+ "is_service_agent",
134
+ "make_m2m_asgi_client",
135
+ ]
@@ -0,0 +1,85 @@
1
+ # Copyright Thales 2026
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """Configuration, naming, and the models every component's configuration uses."""
16
+
17
+ from fred_pod.common.config_files import ConfigFiles
18
+ from fred_pod.common.config_loader import (
19
+ TConfig,
20
+ get_config,
21
+ load_configuration_with_config_files,
22
+ parse_yaml_mapping_file,
23
+ )
24
+ from fred_pod.common.naming import (
25
+ CONTRIBUTED_NAME_PATTERN,
26
+ KNOWLEDGE_BASE_CATALOG_NAMESPACE_PREFIX,
27
+ MAX_NAME_CHARS,
28
+ PREFIX_PATTERN,
29
+ InvalidContributedName,
30
+ knowledge_base_catalog_id,
31
+ knowledge_base_name_from_catalog_id,
32
+ prefix_covers,
33
+ require_contributed_name,
34
+ )
35
+ from fred_pod.common.structures import (
36
+ BaseModelWithId,
37
+ DuckdbStoreConfig,
38
+ InMemoryStoreConfig,
39
+ KpiLogSinkConfig,
40
+ KpiObservabilityConfig,
41
+ KpiOpenSearchSinkConfig,
42
+ KpiPrometheusSinkConfig,
43
+ LogStoreConfig,
44
+ ModelConfiguration,
45
+ OpenSearchIndexConfig,
46
+ OpenSearchStoreConfig,
47
+ OwnerFilter,
48
+ PostgresStoreConfig,
49
+ PostgresTableConfig,
50
+ StoreConfig,
51
+ TemporalSchedulerConfig,
52
+ )
53
+
54
+ __all__ = [
55
+ "CONTRIBUTED_NAME_PATTERN",
56
+ "KNOWLEDGE_BASE_CATALOG_NAMESPACE_PREFIX",
57
+ "MAX_NAME_CHARS",
58
+ "PREFIX_PATTERN",
59
+ "BaseModelWithId",
60
+ "ConfigFiles",
61
+ "DuckdbStoreConfig",
62
+ "InMemoryStoreConfig",
63
+ "InvalidContributedName",
64
+ "KpiLogSinkConfig",
65
+ "KpiObservabilityConfig",
66
+ "KpiOpenSearchSinkConfig",
67
+ "KpiPrometheusSinkConfig",
68
+ "LogStoreConfig",
69
+ "ModelConfiguration",
70
+ "OpenSearchIndexConfig",
71
+ "OpenSearchStoreConfig",
72
+ "OwnerFilter",
73
+ "PostgresStoreConfig",
74
+ "PostgresTableConfig",
75
+ "StoreConfig",
76
+ "TConfig",
77
+ "TemporalSchedulerConfig",
78
+ "get_config",
79
+ "knowledge_base_catalog_id",
80
+ "knowledge_base_name_from_catalog_id",
81
+ "load_configuration_with_config_files",
82
+ "parse_yaml_mapping_file",
83
+ "prefix_covers",
84
+ "require_contributed_name",
85
+ ]
@@ -0,0 +1,135 @@
1
+ # Copyright Thales 2025
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ from __future__ import annotations
16
+
17
+ import logging
18
+ import os
19
+
20
+ from dotenv import load_dotenv
21
+
22
+
23
+ class ConfigFiles:
24
+ """Resolve and track startup config paths for Fred backends.
25
+
26
+ Why this exists:
27
+ - Every backend starts the same way: load environment variables, then load a
28
+ YAML configuration file.
29
+ - Developers and operators should see the exact files that were used.
30
+
31
+ Example:
32
+ - `ENV_FILE=./config/.env.prod`
33
+ - `CONFIG_FILE=./config/configuration_prod.yaml`
34
+ """
35
+
36
+ def __init__(
37
+ self,
38
+ *,
39
+ logger: logging.Logger,
40
+ default_env_file: str = "./config/.env",
41
+ default_config_file: str = "./config/configuration.yaml",
42
+ env_var_name: str = "ENV_FILE",
43
+ config_var_name: str = "CONFIG_FILE",
44
+ log_prefix: str = "[CONFIG]",
45
+ ) -> None:
46
+ """Create a resolver with Fred defaults.
47
+
48
+ Example:
49
+ - Keep defaults for regular startup.
50
+ - Override `default_config_file` in tests to point to a fixture.
51
+ """
52
+ self._logger = logger
53
+ self._default_env_file = default_env_file
54
+ self._default_config_file = default_config_file
55
+ self._env_var_name = env_var_name
56
+ self._config_var_name = config_var_name
57
+ self._log_prefix = log_prefix
58
+ self._loaded_env_file_path: str | None = None
59
+ self._loaded_config_file_path: str | None = None
60
+
61
+ def get_loaded_env_file_path(self) -> str | None:
62
+ """Return the effective env file path used at runtime.
63
+
64
+ Example:
65
+ - Returns `./config/.env` when no override is provided.
66
+ - Returns `/etc/fred/agentic.env` in a production deployment override.
67
+ """
68
+ return self._loaded_env_file_path
69
+
70
+ def get_loaded_config_file_path(self) -> str | None:
71
+ """Return the effective YAML config file path used at runtime.
72
+
73
+ Example:
74
+ - `./config/configuration.yaml` in local mode.
75
+ - `./config/configuration_worker.yaml` for worker startup.
76
+ """
77
+ return self._loaded_config_file_path
78
+
79
+ def load_environment(self, dotenv_path: str | None = None) -> str:
80
+ """Load environment variables from the selected env file.
81
+
82
+ Selection order:
83
+ 1. Explicit `dotenv_path` argument.
84
+ 2. `ENV_FILE` environment variable.
85
+ 3. Default `./config/.env`.
86
+
87
+ Example:
88
+ - Calling `load_environment()` with `ENV_FILE=./config/.env.prod`
89
+ loads production secrets and returns that path.
90
+ """
91
+ env_path = dotenv_path or os.getenv(self._env_var_name, self._default_env_file)
92
+ if load_dotenv(env_path):
93
+ self._logger.info(
94
+ "%s Loaded environment variables from: %s",
95
+ self._log_prefix,
96
+ env_path,
97
+ )
98
+ else:
99
+ self._logger.warning(
100
+ "%s No .env file found at: %s",
101
+ self._log_prefix,
102
+ env_path,
103
+ )
104
+ self._loaded_env_file_path = env_path
105
+ return env_path
106
+
107
+ def resolve_config_file_path(self, config_file: str | None = None) -> str:
108
+ """Resolve and validate the YAML configuration path.
109
+
110
+ Selection order:
111
+ 1. Explicit `config_file` argument.
112
+ 2. `CONFIG_FILE` environment variable.
113
+ 3. Default `./config/configuration.yaml`.
114
+
115
+ Raises:
116
+ - `FileNotFoundError` if the resolved file does not exist.
117
+ """
118
+ resolved = config_file or os.getenv(
119
+ self._config_var_name, self._default_config_file
120
+ )
121
+ if not os.path.exists(resolved):
122
+ raise FileNotFoundError(f"Configuration file not found: {resolved}")
123
+ return resolved
124
+
125
+ def mark_config_loaded(self, config_file: str) -> None:
126
+ """Record and log the configuration file effectively loaded.
127
+
128
+ Example:
129
+ - After parsing `configuration_prod.yaml`, call this method so startup
130
+ logs and diagnostics expose the exact profile in use.
131
+ """
132
+ self._loaded_config_file_path = config_file
133
+ self._logger.info(
134
+ "%s Loaded configuration from: %s", self._log_prefix, config_file
135
+ )
@@ -0,0 +1,91 @@
1
+ # Copyright Thales 2025
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ from __future__ import annotations
16
+
17
+ import sys
18
+ from typing import Callable, TypeVar
19
+
20
+ import yaml
21
+ from pydantic import ValidationError
22
+
23
+ from .config_files import ConfigFiles
24
+
25
+ TConfig = TypeVar("TConfig")
26
+
27
+
28
+ def _render_config_error_banner(config_file: str, error: Exception) -> None:
29
+ """Print a loud, unmissable configuration-error banner to stderr.
30
+
31
+ A misconfigured service must not start silently and fail later with an
32
+ opaque error inside a request handler. We surface the root cause in red at
33
+ startup. Colours are emitted only on a TTY so log files stay clean.
34
+ """
35
+ use_colour = sys.stderr.isatty()
36
+ red = "\033[1;31m" if use_colour else ""
37
+ reset = "\033[0m" if use_colour else ""
38
+ bar = "=" * 78
39
+
40
+ if isinstance(error, ValidationError):
41
+ details = "\n".join(
42
+ f" - {' -> '.join(str(p) for p in err['loc']) or '(root)'}: {err['msg']}"
43
+ for err in error.errors()
44
+ )
45
+ else:
46
+ details = f" - {error}"
47
+
48
+ print(
49
+ f"\n{red}{bar}\n"
50
+ f" CONFIGURATION ERROR — refusing to start\n"
51
+ f" file: {config_file}\n"
52
+ f"{bar}{reset}\n"
53
+ f"{details}\n"
54
+ f"{red}{bar}{reset}\n",
55
+ file=sys.stderr,
56
+ flush=True,
57
+ )
58
+
59
+
60
+ def parse_yaml_mapping_file(config_file: str) -> dict:
61
+ """Load a YAML file and ensure it is a non-empty mapping."""
62
+ with open(config_file, encoding="utf-8") as file:
63
+ payload = yaml.safe_load(file)
64
+ if payload is None:
65
+ raise ValueError(f"Configuration file is empty: {config_file}")
66
+ if not isinstance(payload, dict):
67
+ raise ValueError(f"Configuration file must be a mapping object: {config_file}")
68
+ return payload
69
+
70
+
71
+ def load_configuration_with_config_files(
72
+ config_files: ConfigFiles,
73
+ parser: Callable[[str], TConfig],
74
+ dotenv_path: str | None = None,
75
+ ) -> TConfig:
76
+ """Load env + config path using ConfigFiles and parse via callback."""
77
+ config_files.load_environment(dotenv_path)
78
+ config_file = config_files.resolve_config_file_path()
79
+ try:
80
+ configuration = parser(config_file)
81
+ except (ValidationError, ValueError) as exc:
82
+ # Render the root cause in red and stop, rather than letting an opaque
83
+ # traceback (or a deferred runtime 401) bury what is wrong.
84
+ _render_config_error_banner(config_file, exc)
85
+ raise SystemExit(1) from exc
86
+ config_files.mark_config_loaded(config_file)
87
+ return configuration
88
+
89
+
90
+ def get_config():
91
+ raise NotImplementedError("This dependency have to be override by the backend")
@@ -0,0 +1,110 @@
1
+ # Copyright Thales 2026
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """
16
+ How everything a contributor adds to Fred is named.
17
+
18
+ One rule for agents, Knowledge Bases and applications: a dotted name under a
19
+ prefix its contributor owns — `fred.github.assistant`, `fred.samples.local-folder`,
20
+ `thales.prism.triage`. Uniqueness comes from owning the prefix, the way Java
21
+ packages and Maven group ids work, so no registry has to be kept and no operator
22
+ arbitrates a name.
23
+
24
+ Ownership is a prefix test, never a split: a name is not cut into parts, it is
25
+ checked against the prefixes a caller owns. That is why no separator is needed,
26
+ and why prefixes may be of any depth.
27
+
28
+ Full rationale: docs/swift/platform/CONFIGURATION_AND_POLICY_CONVENTIONS.md
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import re
34
+
35
+ # A run of letters/digits, then any number of single-separator groups. Written
36
+ # this way so the pattern alone rejects a leading, trailing or doubled separator
37
+ # — `a__b`, `a--b`, `-a`, `a_` — without a second check a caller could forget.
38
+ _SEGMENT = r"[a-z0-9]+(?:[_-][a-z0-9]+)*"
39
+
40
+ #: A prefix a contributor owns. One segment or more: `fred`, `fred.samples`.
41
+ PREFIX_PATTERN = rf"^{_SEGMENT}(?:\.{_SEGMENT})*$"
42
+
43
+ #: A contributed name. Two segments or more: a prefix, then what it names.
44
+ CONTRIBUTED_NAME_PATTERN = rf"^{_SEGMENT}(?:\.{_SEGMENT})+$"
45
+
46
+ MAX_NAME_CHARS = 255
47
+
48
+ _NAME_RE = re.compile(CONTRIBUTED_NAME_PATTERN)
49
+
50
+
51
+ class InvalidContributedName(ValueError):
52
+ """Raised when a name or prefix does not follow the contributor rule."""
53
+
54
+
55
+ def require_contributed_name(name: str) -> str:
56
+ """Return the name, or say precisely why it cannot be one."""
57
+ if len(name) > MAX_NAME_CHARS:
58
+ raise InvalidContributedName(
59
+ f"name is longer than {MAX_NAME_CHARS} characters: {name!r}"
60
+ )
61
+ if "__" in name:
62
+ raise InvalidContributedName(
63
+ f"name must not contain a double underscore: {name!r}"
64
+ )
65
+ if _NAME_RE.match(name) is None:
66
+ raise InvalidContributedName(
67
+ "name must be lowercase dotted segments, at least two, each starting "
68
+ f"and ending on a letter or digit: {name!r}"
69
+ )
70
+ return name
71
+
72
+
73
+ def prefix_covers(prefix: str, name: str) -> bool:
74
+ """Whether a client owning `prefix` may write `name`.
75
+
76
+ A segment boundary is required, so owning `fred.sample` does not reach
77
+ `fred.samples.local-folder`. Kept as one function because the dispatching
78
+ side and the publishing side must decide this identically.
79
+ """
80
+ return name == prefix or name.startswith(f"{prefix}.")
81
+
82
+
83
+ #: The key prefix Knowledge Base definitions reserve in the shared catalog.
84
+ KNOWLEDGE_BASE_CATALOG_NAMESPACE_PREFIX = "kb__"
85
+
86
+
87
+ def knowledge_base_catalog_id(name: str) -> str:
88
+ """Return the key this definition takes in the shared administration catalog.
89
+
90
+ The catalog is one flat dictionary shared with capabilities, agents and
91
+ applications, so each kind reserves a prefix and no two kinds can collide in
92
+ it. That prefix is an internal key, never part of the contributed name.
93
+ """
94
+
95
+ return f"{KNOWLEDGE_BASE_CATALOG_NAMESPACE_PREFIX}{name}"
96
+
97
+
98
+ def knowledge_base_name_from_catalog_id(catalog_id: str) -> str:
99
+ """Return the contributed name a catalog key carries.
100
+
101
+ A removal, not a split: the name keeps whatever depth its contributor chose,
102
+ and nothing here has to guess where a prefix ends.
103
+ """
104
+
105
+ if not catalog_id.startswith(KNOWLEDGE_BASE_CATALOG_NAMESPACE_PREFIX):
106
+ raise ValueError(f"Not a Knowledge Base catalog id: {catalog_id!r}")
107
+ name = catalog_id[len(KNOWLEDGE_BASE_CATALOG_NAMESPACE_PREFIX) :]
108
+ if not name:
109
+ raise ValueError(f"Knowledge Base catalog id carries no name: {catalog_id!r}")
110
+ return name
@@ -0,0 +1,247 @@
1
+ # Copyright Thales 2025
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ import os
16
+ from enum import Enum
17
+ from typing import Annotated, Any, Dict, Literal, Optional, Union
18
+
19
+ from pydantic import BaseModel, Field, model_validator
20
+
21
+
22
+ class OwnerFilter(str, Enum):
23
+ """Filter resources by ownership type.
24
+
25
+ - PERSONAL: resources where the user is directly owner/editor/viewer (not via team)
26
+ - TEAM: resources owned by a specific team (requires team_id parameter)
27
+ """
28
+
29
+ PERSONAL = "personal"
30
+ TEAM = "team"
31
+
32
+
33
+ class BaseModelWithId(BaseModel):
34
+ id: str
35
+
36
+
37
+ class TemporalSchedulerConfig(BaseModel):
38
+ host: str = "localhost:7233"
39
+ namespace: str = "default"
40
+ task_queue: str = "default"
41
+ workflow_id_prefix: str = "task"
42
+ connect_timeout_seconds: Optional[int] = 5
43
+ rpc_timeout_seconds: Optional[int] = Field(
44
+ default=10,
45
+ description="Deadline applied to individual Temporal RPC calls (start_workflow, describe) "
46
+ "so a stuck Temporal frontend cannot hang the caller indefinitely.",
47
+ )
48
+ ingestion_workflow_parallelism: int = Field(
49
+ default=3,
50
+ ge=1,
51
+ description="Max number of files launched in parallel per parent ingestion workflow.",
52
+ )
53
+ ingestion_max_concurrent_workflow_tasks: int = Field(
54
+ default=3,
55
+ ge=1,
56
+ description="Max concurrent Temporal workflow tasks processed by a worker process.",
57
+ )
58
+ ingestion_max_concurrent_activities: int = Field(
59
+ default=3,
60
+ ge=1,
61
+ description="Max concurrent Temporal activities processed by a worker process.",
62
+ )
63
+
64
+
65
+ class ModelConfiguration(BaseModel):
66
+ provider: Optional[str] = Field(
67
+ None, description="Provider of the AI model, e.g., openai, ollama, azure."
68
+ )
69
+ name: Optional[str] = Field(None, description="Model name, e.g., gpt-4o, llama2.")
70
+ settings: Optional[Dict[str, Any]] = Field(
71
+ default_factory=dict,
72
+ description=(
73
+ "Additional provider-specific settings, "
74
+ "e.g. Azure endpoint/API version or Vertex AI project/location."
75
+ ),
76
+ )
77
+
78
+
79
+ class OpenSearchStoreConfig(BaseModel):
80
+ host: str = Field(..., description="OpenSearch host URL")
81
+ username: str = Field(..., description="Username from env")
82
+ password: Optional[str] = Field(
83
+ default_factory=lambda: os.getenv("OPENSEARCH_PASSWORD"),
84
+ description="Password from env",
85
+ )
86
+ secure: bool = Field(default=False, description="Use TLS (https)")
87
+ verify_certs: bool = Field(default=False, description="Verify TLS certs")
88
+
89
+ @model_validator(mode="after")
90
+ def _require_password(self) -> "OpenSearchStoreConfig":
91
+ """Fail fast at config load if OpenSearch is configured without credentials.
92
+
93
+ Reaching this validator means a ``storage.opensearch`` block is present in
94
+ the active configuration, so the service genuinely depends on OpenSearch.
95
+ A missing password only surfaces later as an opaque HTTP 401 deep inside a
96
+ request handler — we convert it into an actionable startup failure instead.
97
+ """
98
+ if not self.password:
99
+ raise ValueError(
100
+ f"OpenSearch is configured (host={self.host!r}, username="
101
+ f"{self.username!r}) but no password was provided. "
102
+ "Set the OPENSEARCH_PASSWORD environment variable (in your .env) "
103
+ "to the OpenSearch password for this user, or remove the "
104
+ "storage.opensearch block if OpenSearch is not used."
105
+ )
106
+ return self
107
+
108
+
109
+ class OpenSearchIndexConfig(BaseModel):
110
+ type: Literal["opensearch"]
111
+ index: str = Field(..., description="OpenSearch index name")
112
+
113
+
114
+ class LogStoreConfig(BaseModel):
115
+ type: Literal["log"]
116
+ level: str = Field(..., description="Logging level")
117
+
118
+
119
+ class DuckdbStoreConfig(BaseModel):
120
+ type: Literal["duckdb"]
121
+ duckdb_path: str = Field(..., description="Path to the DuckDB database file.")
122
+
123
+
124
+ class PostgresStoreConfig(BaseModel):
125
+ type: Literal["postgres"] = "postgres"
126
+ host: Optional[str] = Field(default=None, description="PostgreSQL host")
127
+ port: int = 5432
128
+ sqlite_path: Optional[str] = Field(
129
+ default=None,
130
+ description="Path to the SQLite database file (for local dev/testing).",
131
+ )
132
+ database: Optional[str] = None
133
+ username: Optional[str] = None
134
+ password: Optional[str] = Field(
135
+ default_factory=lambda: os.getenv("FRED_POSTGRES_PASSWORD")
136
+ )
137
+ echo: bool = Field(default=False, description="SQLAlchemy echo flag.")
138
+ pool_size: Optional[int] = Field(
139
+ default=None, description="Optional pool size for the engine."
140
+ )
141
+ max_overflow: Optional[int] = Field(
142
+ default=None,
143
+ description="Optional max_overflow for SQLAlchemy pool (defaults to SQLAlchemy's 10 if unset).",
144
+ )
145
+ pool_timeout: Optional[int] = Field(
146
+ default=None,
147
+ description="Seconds to wait for a connection from the pool before timing out.",
148
+ )
149
+ pool_recycle: Optional[int] = Field(
150
+ default=None,
151
+ description="Recycle connections after this many seconds (prevents stale TCP / server timeouts).",
152
+ )
153
+ pool_pre_ping: Optional[bool] = Field(
154
+ default=None,
155
+ description="Enable SQLAlchemy pool_pre_ping to evict stale connections.",
156
+ )
157
+ connect_args: Optional[dict[str, Any]] = Field(
158
+ default=None, description="Optional connect_args passed to SQLAlchemy."
159
+ )
160
+
161
+ def dsn(self) -> str:
162
+ return f"postgresql://{self.username}:{self.password}@{self.host}:{self.port}/{self.database}"
163
+
164
+ def async_dsn(self) -> str:
165
+ return f"postgresql+asyncpg://{self.username}:{self.password}@{self.host}:{self.port}/{self.database}"
166
+
167
+
168
+ class PostgresTableConfig(BaseModel):
169
+ # Allow reusing the same table-oriented config for local SQLite runs.
170
+ type: Literal["postgres"]
171
+ table: Optional[str] = Field(
172
+ default=None,
173
+ description="Table name used by the store. Deprecated: stores now use fixed table names.",
174
+ )
175
+ prefix: Optional[str] = Field(
176
+ default=None,
177
+ description="Optional prefix applied to the table name. Deprecated: stores now use fixed table names.",
178
+ )
179
+
180
+
181
+ class InMemoryStoreConfig(BaseModel):
182
+ """
183
+ Minimal config for in-memory stores (dev/test only).
184
+ """
185
+
186
+ type: Literal["memory"] = "memory"
187
+
188
+
189
+ StoreConfig = Annotated[
190
+ Union[
191
+ DuckdbStoreConfig,
192
+ OpenSearchIndexConfig,
193
+ LogStoreConfig,
194
+ PostgresTableConfig,
195
+ InMemoryStoreConfig,
196
+ ],
197
+ Field(discriminator="type"),
198
+ ]
199
+
200
+
201
+ # ---------------------------------------------------------------------------
202
+ # KPI observability config — shared across all backends
203
+ # ---------------------------------------------------------------------------
204
+
205
+
206
+ class KpiLogSinkConfig(BaseModel):
207
+ enabled: bool = False
208
+ level: str = "info"
209
+ summary_interval_sec: float = 0.0
210
+ summary_top_n: int = 0
211
+
212
+
213
+ class KpiPrometheusSinkConfig(BaseModel):
214
+ enabled: bool = True
215
+ port: int = 9000
216
+ address: str = "127.0.0.1"
217
+
218
+
219
+ class KpiOpenSearchSinkConfig(BaseModel):
220
+ enabled: bool = True
221
+ index: str = "kpi-index"
222
+
223
+
224
+ class KpiObservabilityConfig(BaseModel):
225
+ """
226
+ KPI sink configuration shared across all Fred backends.
227
+
228
+ Defaults enable Prometheus + OpenSearch (prod-ready out of the box).
229
+ For local dev, disable both and enable log instead:
230
+
231
+ observability:
232
+ kpi:
233
+ log:
234
+ enabled: true
235
+ prometheus:
236
+ enabled: false
237
+ opensearch:
238
+ enabled: false
239
+ """
240
+
241
+ log: KpiLogSinkConfig = Field(default_factory=KpiLogSinkConfig)
242
+ prometheus: KpiPrometheusSinkConfig = Field(default_factory=KpiPrometheusSinkConfig)
243
+ opensearch: KpiOpenSearchSinkConfig = Field(default_factory=KpiOpenSearchSinkConfig)
244
+ process_metrics_interval_sec: int = Field(
245
+ default=10,
246
+ description="Emit process/SQL-pool KPIs every N seconds. 0 to disable.",
247
+ )
fred_pod/py.typed ADDED
File without changes
@@ -0,0 +1,51 @@
1
+ # Copyright Thales 2026
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """Identity: who a pod is to Fred, and who a caller is to a pod."""
16
+
17
+ from fred_pod.security.backend_to_backend_auth import (
18
+ M2MAuthConfig,
19
+ M2MBearerAuth,
20
+ M2MTokenProvider,
21
+ make_m2m_asgi_client,
22
+ )
23
+ from fred_pod.security.structure import (
24
+ LOCAL_DEV_CLIENT_ID,
25
+ SERVICE_AGENT_ROLE,
26
+ KeycloakUser,
27
+ M2MSecurity,
28
+ OpenFgaRebacConfig,
29
+ RebacBaseConfig,
30
+ RebacConfiguration,
31
+ SecurityConfiguration,
32
+ UserSecurity,
33
+ is_service_agent,
34
+ )
35
+
36
+ __all__ = [
37
+ "LOCAL_DEV_CLIENT_ID",
38
+ "SERVICE_AGENT_ROLE",
39
+ "KeycloakUser",
40
+ "M2MAuthConfig",
41
+ "M2MBearerAuth",
42
+ "M2MSecurity",
43
+ "M2MTokenProvider",
44
+ "OpenFgaRebacConfig",
45
+ "RebacBaseConfig",
46
+ "RebacConfiguration",
47
+ "SecurityConfiguration",
48
+ "UserSecurity",
49
+ "is_service_agent",
50
+ "make_m2m_asgi_client",
51
+ ]
@@ -0,0 +1,152 @@
1
+ # Copyright Thales 2025
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ from __future__ import annotations
16
+
17
+ import asyncio
18
+ import os
19
+ import time
20
+ import typing as t
21
+
22
+ import httpx
23
+ from httpx import Request, Response
24
+ from pydantic import BaseModel
25
+
26
+ # The shape httpx.ASGITransport accepts. Any ASGI application qualifies —
27
+ # FastAPI is one — and naming it this way keeps FastAPI out of the pod floor.
28
+ _ASGIMessage = t.MutableMapping[str, t.Any]
29
+ ASGIApp = t.Callable[
30
+ [
31
+ _ASGIMessage,
32
+ t.Callable[[], t.Awaitable[_ASGIMessage]],
33
+ t.Callable[[_ASGIMessage], t.Awaitable[None]],
34
+ ],
35
+ t.Awaitable[None],
36
+ ]
37
+
38
+ # A tool or pod calling a Fred API has no user bearer, so it needs a service
39
+ # token (client_credentials) or the call 401s. It lives in fred-pod because every
40
+ # pod needs it and none should install the agents platform to get it.
41
+
42
+
43
+ class M2MAuthConfig(BaseModel):
44
+ """
45
+ Minimal config for client-credentials flow.
46
+ keycloak_realm_url: the *realm* URL, same one you already use for JWKS
47
+ (e.g., https://kc.example/realms/myrealm)
48
+ client_id: confidential client ID (e.g., "knowledge")
49
+ secret_env: env var name that stores the client secret (e.g., "KEYCLOAK_KNOWLEDGE_FLOW_CLIENT_SECRET")
50
+ scope: optional Keycloak scopes (rarely needed)
51
+ """
52
+
53
+ keycloak_realm_url: str
54
+ client_id: str
55
+ secret_env: str
56
+ scope: str | None = None
57
+
58
+ @property
59
+ def token_url(self) -> str:
60
+ # Mirrors how you compute JWKS: realm/protocol/openid-connect/token
61
+ return f"{self.keycloak_realm_url}/protocol/openid-connect/token"
62
+
63
+
64
+ class M2MTokenProvider:
65
+ """
66
+ Caches and refreshes a Keycloak client-credentials token.
67
+ Thread-safe (async) and cheap to reuse across calls.
68
+ """
69
+
70
+ def __init__(self, cfg: M2MAuthConfig):
71
+ self.cfg = cfg
72
+ self._secret = os.getenv(cfg.secret_env, "")
73
+ self._lock = asyncio.Lock()
74
+ self._token: str | None = None
75
+ self._exp: int = 0 # epoch seconds
76
+
77
+ async def get_token(self) -> str:
78
+ now = int(time.time())
79
+ if self._token and now < self._exp - 30:
80
+ return self._token
81
+
82
+ async with self._lock:
83
+ # double-check inside lock
84
+ now = int(time.time())
85
+ if self._token and now < self._exp - 30:
86
+ return self._token
87
+
88
+ if not self._secret:
89
+ # Fail fast: missing secret will otherwise cause confusing 401s
90
+ raise RuntimeError(
91
+ f"Missing Keycloak client secret in env: {self.cfg.secret_env}"
92
+ )
93
+
94
+ form = {
95
+ "grant_type": "client_credentials",
96
+ "client_id": self.cfg.client_id,
97
+ "client_secret": self._secret,
98
+ }
99
+ if self.cfg.scope:
100
+ form["scope"] = self.cfg.scope
101
+
102
+ async with httpx.AsyncClient(timeout=10.0) as c:
103
+ r = await c.post(self.cfg.token_url, data=form)
104
+ r.raise_for_status()
105
+ payload = r.json()
106
+
107
+ token = payload.get("access_token")
108
+ expires_in = int(payload.get("expires_in", 60))
109
+
110
+ if not isinstance(token, str) or not token:
111
+ raise RuntimeError("Auth server did not return a valid access_token")
112
+
113
+ self._token = token
114
+ self._exp = now + expires_in
115
+
116
+ # Guarantee to the outside world that we return str
117
+ assert self._token is not None
118
+ return self._token
119
+
120
+
121
+ class M2MBearerAuth(httpx.Auth):
122
+ """
123
+ httpx.Auth that injects 'Authorization: Bearer <service_token>'.
124
+
125
+ Important: httpx expects an *async generator* here. We yield the request
126
+ after mutating headers, which avoids the common type errors.
127
+ """
128
+
129
+ requires_request_body = True
130
+ requires_response_body = False
131
+
132
+ def __init__(self, provider: M2MTokenProvider):
133
+ self._provider = provider
134
+
135
+ async def async_auth_flow(
136
+ self, request: Request
137
+ ) -> t.AsyncGenerator[Request, Response]:
138
+ token = await self._provider.get_token()
139
+ request.headers["Authorization"] = f"Bearer {token}"
140
+ yield request # httpx performs the request; we don't need the response hook here.
141
+
142
+
143
+ def make_m2m_asgi_client(app: ASGIApp, auth: httpx.Auth) -> httpx.AsyncClient:
144
+ """
145
+ In-process client for self-calls via ASGITransport (no network).
146
+ """
147
+ return httpx.AsyncClient(
148
+ transport=httpx.ASGITransport(app=app, raise_app_exceptions=False),
149
+ base_url="http://apiserver",
150
+ timeout=15.0,
151
+ auth=auth,
152
+ )
@@ -0,0 +1,153 @@
1
+ # Copyright Thales 2025
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ from typing import Annotated, List, Literal, Union
16
+
17
+ from pydantic import AnyHttpUrl, AnyUrl, BaseModel, Field
18
+
19
+
20
+ class KeycloakUser(BaseModel):
21
+ """Represents an authenticated Keycloak user."""
22
+
23
+ uid: str
24
+ username: str
25
+ roles: list[str]
26
+ email: str | None = None
27
+ client_id: str | None = Field(
28
+ default=None,
29
+ description=(
30
+ "The token's authorized party (`azp`). Set for a client-credentials "
31
+ "identity, so a caller can be checked against one exact client "
32
+ "rather than a broad service role."
33
+ ),
34
+ )
35
+
36
+ def __repr_args__(self):
37
+ # Directly identifying data must never reach a log line, and an
38
+ # f-string/log call interpolating this model (or anything containing
39
+ # it) goes through repr — not model_dump() — so redacting here, not
40
+ # only at each log call site, is what actually closes the leak
41
+ # (docs/swift/platform/OBSERVABILITY-AND-AUDIT.md §7: "Directly
42
+ # identifying | user email, full name | Nowhere"). Explicit `.email`
43
+ # access for a genuine need (e.g. sending mail) is unaffected — this
44
+ # only changes str()/repr().
45
+ for name, value in super().__repr_args__():
46
+ yield (name, "<redacted>" if name == "email" and value else value)
47
+
48
+
49
+ # Keycloak app role carried by backend service identities (agentic, knowledge-flow,
50
+ # control-plane, and the evaluation worker). Identity marker, not a ReBAC relation:
51
+ # nothing is stored in OpenFGA for it.
52
+ SERVICE_AGENT_ROLE = "service_agent"
53
+
54
+ # The `azp` a mock user carries when authentication is disabled, so routes
55
+ # gated on an exact client stay reachable in local development.
56
+ LOCAL_DEV_CLIENT_ID = "local-dev"
57
+
58
+
59
+ def is_service_agent(user: KeycloakUser) -> bool:
60
+ """Return True when the caller is a service identity (holds ``service_agent``).
61
+
62
+ Identity predicate on the JWT (reads ``user.roles``) — not a ReBAC check.
63
+ Used by execution-authorization enforcement points (fred-runtime and the
64
+ control-plane) to recognize the evaluation worker for team ``can_read``,
65
+ scoped to the request ``team_id`` (RFC EVAL-AUTH, Solution A). No OpenFGA
66
+ tuple links the service to a team.
67
+ """
68
+ return SERVICE_AGENT_ROLE in (user.roles or [])
69
+
70
+
71
+ class M2MSecurity(BaseModel):
72
+ """Configuration for machine-to-machine authentication."""
73
+
74
+ enabled: bool = True
75
+ realm_url: AnyUrl
76
+ client_id: str
77
+ audience: str | None = None
78
+ secret_env_var: str = "M2M_CLIENT_SECRET"
79
+
80
+
81
+ class UserSecurity(BaseModel):
82
+ """Configuration for user authentication."""
83
+
84
+ enabled: bool = True
85
+ realm_url: AnyUrl
86
+ client_id: str
87
+
88
+
89
+ class RebacBaseConfig(BaseModel):
90
+ enabled: bool = Field(
91
+ default=True,
92
+ description="To disable ReBAC checks (do not disable in production). If OIDC (UserSecurity and M2MSecurity) ReBAC check will be disabled even if this is true.",
93
+ )
94
+
95
+
96
+ class OpenFgaRebacConfig(RebacBaseConfig):
97
+ """Configuration for an OpenFGA-backed relationship engine."""
98
+
99
+ type: Literal["openfga"] = "openfga"
100
+ api_url: AnyHttpUrl = Field(
101
+ ...,
102
+ description="Base URL for the OpenFGA HTTP API (e.g. https://fga.example.com)",
103
+ )
104
+ store_name: str = Field(
105
+ default="fred", description="Name of the OpenFGA store to use"
106
+ )
107
+ authorization_model_id: str | None = Field(
108
+ default=None,
109
+ description="Optional authorization model ID to use for read operations. Will be overridden if sync_schema_on_init is True.",
110
+ )
111
+ create_store_if_needed: bool = Field(
112
+ default=True,
113
+ description="Create the OpenFGA store if it does not already exist",
114
+ )
115
+ sync_schema_on_init: bool = Field(
116
+ default=True,
117
+ description="Synchronize the authorization model when creating the engine",
118
+ )
119
+ token_env_var: str = Field(
120
+ default="OPENFGA_API_TOKEN",
121
+ description="Environment variable that stores the OpenFGA API token",
122
+ )
123
+ timeout_millisec: int | None = Field(
124
+ default=5000,
125
+ description=(
126
+ "Timeout in milliseconds for OpenFGA API requests. Defaults to 5000 so a "
127
+ "stalled OpenFGA call fails fast with an error instead of hanging the request "
128
+ "indefinitely (set to None only to explicitly disable the timeout)."
129
+ ),
130
+ )
131
+ headers: dict[str, str] | None = Field(
132
+ default=None,
133
+ description="Static HTTP headers to send with each OpenFGA API request",
134
+ )
135
+
136
+
137
+ RebacConfiguration = Annotated[Union[OpenFgaRebacConfig], Field(discriminator="type")]
138
+
139
+
140
+ class SecurityConfiguration(BaseModel):
141
+ m2m: M2MSecurity
142
+ user: UserSecurity
143
+ authorized_origins: List[AnyHttpUrl] = []
144
+ rebac: RebacConfiguration | None = None
145
+ profile: Literal["c3"] | None = Field(
146
+ default=None,
147
+ description=(
148
+ "Hardened security profile (RUNTIME-07). 'c3' forces strict JWT "
149
+ "issuer/audience validation, forbids no-security/mock-admin, and "
150
+ "requires OpenFGA ReBAC enabled (pod-side authorization, fail-closed) "
151
+ "— failing startup otherwise. The control-plane issues no signed grant."
152
+ ),
153
+ )
@@ -0,0 +1,104 @@
1
+ Metadata-Version: 2.4
2
+ Name: fred-pod
3
+ Version: 4.1.0
4
+ Summary: What a Fred component needs to be a pod: configuration, identity, naming.
5
+ Author-email: Thales <noreply@thalesgroup.com>
6
+ License: Apache-2.0
7
+ Project-URL: Homepage, https://site.fredlab.dev
8
+ Project-URL: Repository, https://github.com/ThalesGroup/fred
9
+ Classifier: License :: OSI Approved :: Apache Software License
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3 :: Only
12
+ Classifier: Operating System :: OS Independent
13
+ Requires-Python: >=3.12
14
+ Description-Content-Type: text/markdown
15
+ Requires-Dist: pydantic<3.0.0,>=2.5.2
16
+ Requires-Dist: python-dotenv<2.0.0,>=1.0.1
17
+ Requires-Dist: pyyaml>=6.0.1
18
+ Requires-Dist: httpx>=0.28.1
19
+ Provides-Extra: dev
20
+ Requires-Dist: bandit>=1.8.6; extra == "dev"
21
+ Requires-Dist: basedpyright==1.31.0; extra == "dev"
22
+ Requires-Dist: detect-secrets>=1.5.0; extra == "dev"
23
+ Requires-Dist: pytest>=9.0.3; extra == "dev"
24
+ Requires-Dist: pytest-asyncio>=1.2.0; extra == "dev"
25
+ Requires-Dist: pytest-cov>=6.2.1; extra == "dev"
26
+ Requires-Dist: pytest-socket>=0.7.0; extra == "dev"
27
+ Requires-Dist: ruff<0.16,>=0.12.5; extra == "dev"
28
+
29
+ # fred-pod
30
+
31
+ What a Fred component needs to *be* a pod: **configuration, identity, naming**.
32
+
33
+ A Knowledge Base pod, a capability pod, an MCP server pod and an agent pod all read a
34
+ `configuration.yaml`, get a machine-to-machine token, and name things under a prefix they
35
+ own. That floor is this distribution.
36
+
37
+ | Question | Answer |
38
+ | --- | --- |
39
+ | What do I need to *run* a Fred component? | `fred-pod` |
40
+ | What do I need to *build an agent*? | `fred-core` / `fred-sdk` |
41
+
42
+ ## Why it is its own distribution
43
+
44
+ `fred-core` declares 31 runtime dependencies — pandas, pyarrow, google-cloud-storage, minio,
45
+ opensearch-py, sqlalchemy, asyncpg, azure-identity, fastapi, and a client for every LLM
46
+ provider. That is the right list for the agents/LLM platform `fred-core` is. It is the wrong
47
+ list for a pod whose job is one PROPFIND and a few GETs.
48
+
49
+ Extras were the obvious alternative and were rejected for one reason: **a boundary the build
50
+ does not enforce erodes.** `fred-core` reached 31 dependencies precisely because nothing
51
+ stopped it. Nothing would stop an `import pandas` landing in `structures.py` next month
52
+ either, and nobody would notice. A separate distribution cannot import what it does not
53
+ depend on — the rule keeps itself.
54
+
55
+ It showed its worth immediately: `config_loader.py` has always done `import yaml`, and
56
+ `fred-core` never declared PyYAML. It worked because something else happened to pull it in.
57
+ Here it is declared, because here it had to be.
58
+
59
+ ## The dependency list is the contract
60
+
61
+ ```
62
+ pydantic python-dotenv pyyaml httpx
63
+ ```
64
+
65
+ Four packages. Adding a fifth is a decision someone makes on purpose, in a diff, and that is
66
+ the whole point. If a change to this library needs a heavier import, the change belongs in
67
+ `fred-core`, not here.
68
+
69
+ ## Layout
70
+
71
+ ```
72
+ fred_pod/
73
+ ├── common/
74
+ │ ├── naming.py contributed names, prefixes, catalog ids
75
+ │ ├── structures.py configuration models (scheduler, stores, KPI sinks)
76
+ │ ├── config_files.py resolving configuration.yaml and its .env
77
+ │ └── config_loader.py loading and validating it into a Pydantic model
78
+ └── security/
79
+ ├── structure.py security configuration models, KeycloakUser
80
+ └── backend_to_backend_auth.py M2M token provider and httpx auth
81
+ ```
82
+
83
+ The paths mirror `fred_core`'s on purpose: a call site migrates by swapping the prefix
84
+ `fred_core.` → `fred_pod.` and nothing else.
85
+
86
+ ## Compatibility
87
+
88
+ `fred-core` depends on `fred-pod` and re-exports every name it moved, at both the package
89
+ top level and the original submodule paths. Existing code keeps working unchanged; imports
90
+ migrate opportunistically.
91
+
92
+ ```python
93
+ from fred_pod import ConfigFiles, KeycloakUser, M2MTokenProvider
94
+ from fred_pod.common.naming import require_contributed_name
95
+ ```
96
+
97
+ ## Development
98
+
99
+ ```
100
+ make dev # install with the local monorepo checkout
101
+ make test # offline unit tests
102
+ make code-quality # ruff, bandit, detect-secrets, basedpyright
103
+ make publish # build and upload to PyPI (needs PYPI_TOKEN)
104
+ ```
@@ -0,0 +1,14 @@
1
+ fred_pod/__init__.py,sha256=PyNu6l2xjpROtOn3eWjRSFGDMq2z7mc8X22UAGZ3Vu0,3881
2
+ fred_pod/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
3
+ fred_pod/common/__init__.py,sha256=eOkTJhuesdiRFLbj1oKuYi9GU16tMoYh35yzSstTxL8,2431
4
+ fred_pod/common/config_files.py,sha256=5ZV2FAEhZe7ZrfbJGngNjXHorNCtmm33PznAlbnbNvs,4743
5
+ fred_pod/common/config_loader.py,sha256=F4ZezOU84GZIiJvrs7b-CtH-tIsYTClpWJF1Tyi8oJY,3133
6
+ fred_pod/common/naming.py,sha256=UDmfG-QGIHGDa7CYJGN5dREP5HDfKlQ-_2JURXq1vPQ,4260
7
+ fred_pod/common/structures.py,sha256=OukWTTsFTKuCnAG27OdvvjBeJ-MTZwl-OLsOh0-K7Hk,8517
8
+ fred_pod/security/__init__.py,sha256=qVwrfjp_9z-pM8M2SvPXE3baCjAJiN1-oN9z577Zyx0,1397
9
+ fred_pod/security/backend_to_backend_auth.py,sha256=YDW15IntxdQ6OAKD6IGH8MUz6yvS28PakR5I13ls5Bs,5150
10
+ fred_pod/security/structure.py,sha256=hWqsGeq295t4Ht9QY_KrLeDBuYGf7ZKa7RIU0egEn_c,5770
11
+ fred_pod-4.1.0.dist-info/METADATA,sha256=bxcow3PfiwMX9o87ANgWQJB_9rfrxMAuoMsbAE6mawE,4225
12
+ fred_pod-4.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
13
+ fred_pod-4.1.0.dist-info/top_level.txt,sha256=tmTvVg6fqlDopKUFNmXfWd5VG5I2kG6sJc5oyJtCgOY,9
14
+ fred_pod-4.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ fred_pod