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 +135 -0
- fred_pod/common/__init__.py +85 -0
- fred_pod/common/config_files.py +135 -0
- fred_pod/common/config_loader.py +91 -0
- fred_pod/common/naming.py +110 -0
- fred_pod/common/structures.py +247 -0
- fred_pod/py.typed +0 -0
- fred_pod/security/__init__.py +51 -0
- fred_pod/security/backend_to_backend_auth.py +152 -0
- fred_pod/security/structure.py +153 -0
- fred_pod-4.1.0.dist-info/METADATA +104 -0
- fred_pod-4.1.0.dist-info/RECORD +14 -0
- fred_pod-4.1.0.dist-info/WHEEL +5 -0
- fred_pod-4.1.0.dist-info/top_level.txt +1 -0
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 @@
|
|
|
1
|
+
fred_pod
|