service-forge 0.1.11__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.

Potentially problematic release.


This version of service-forge might be problematic. Click here for more details.

Files changed (75) hide show
  1. service_forge/api/deprecated_websocket_api.py +86 -0
  2. service_forge/api/deprecated_websocket_manager.py +425 -0
  3. service_forge/api/http_api.py +148 -0
  4. service_forge/api/http_api_doc.py +455 -0
  5. service_forge/api/kafka_api.py +126 -0
  6. service_forge/api/routers/service/__init__.py +4 -0
  7. service_forge/api/routers/service/service_router.py +137 -0
  8. service_forge/api/routers/websocket/websocket_manager.py +83 -0
  9. service_forge/api/routers/websocket/websocket_router.py +78 -0
  10. service_forge/api/task_manager.py +141 -0
  11. service_forge/db/__init__.py +1 -0
  12. service_forge/db/database.py +240 -0
  13. service_forge/llm/__init__.py +62 -0
  14. service_forge/llm/llm.py +56 -0
  15. service_forge/model/__init__.py +0 -0
  16. service_forge/model/websocket.py +13 -0
  17. service_forge/proto/foo_input.py +5 -0
  18. service_forge/service.py +288 -0
  19. service_forge/service_config.py +158 -0
  20. service_forge/sft/cli.py +91 -0
  21. service_forge/sft/cmd/config_command.py +67 -0
  22. service_forge/sft/cmd/deploy_service.py +123 -0
  23. service_forge/sft/cmd/list_tars.py +41 -0
  24. service_forge/sft/cmd/service_command.py +149 -0
  25. service_forge/sft/cmd/upload_service.py +36 -0
  26. service_forge/sft/config/injector.py +119 -0
  27. service_forge/sft/config/injector_default_files.py +131 -0
  28. service_forge/sft/config/sf_metadata.py +30 -0
  29. service_forge/sft/config/sft_config.py +153 -0
  30. service_forge/sft/file/__init__.py +0 -0
  31. service_forge/sft/file/ignore_pattern.py +80 -0
  32. service_forge/sft/file/sft_file_manager.py +107 -0
  33. service_forge/sft/kubernetes/kubernetes_manager.py +257 -0
  34. service_forge/sft/util/assert_util.py +25 -0
  35. service_forge/sft/util/logger.py +16 -0
  36. service_forge/sft/util/name_util.py +8 -0
  37. service_forge/sft/util/yaml_utils.py +57 -0
  38. service_forge/utils/__init__.py +0 -0
  39. service_forge/utils/default_type_converter.py +12 -0
  40. service_forge/utils/register.py +39 -0
  41. service_forge/utils/type_converter.py +99 -0
  42. service_forge/utils/workflow_clone.py +124 -0
  43. service_forge/workflow/__init__.py +1 -0
  44. service_forge/workflow/context.py +14 -0
  45. service_forge/workflow/edge.py +24 -0
  46. service_forge/workflow/node.py +184 -0
  47. service_forge/workflow/nodes/__init__.py +8 -0
  48. service_forge/workflow/nodes/control/if_node.py +29 -0
  49. service_forge/workflow/nodes/control/switch_node.py +28 -0
  50. service_forge/workflow/nodes/input/console_input_node.py +26 -0
  51. service_forge/workflow/nodes/llm/query_llm_node.py +41 -0
  52. service_forge/workflow/nodes/nested/workflow_node.py +28 -0
  53. service_forge/workflow/nodes/output/kafka_output_node.py +27 -0
  54. service_forge/workflow/nodes/output/print_node.py +29 -0
  55. service_forge/workflow/nodes/test/if_console_input_node.py +33 -0
  56. service_forge/workflow/nodes/test/time_consuming_node.py +62 -0
  57. service_forge/workflow/port.py +89 -0
  58. service_forge/workflow/trigger.py +24 -0
  59. service_forge/workflow/triggers/__init__.py +6 -0
  60. service_forge/workflow/triggers/a2a_api_trigger.py +255 -0
  61. service_forge/workflow/triggers/fast_api_trigger.py +169 -0
  62. service_forge/workflow/triggers/kafka_api_trigger.py +44 -0
  63. service_forge/workflow/triggers/once_trigger.py +20 -0
  64. service_forge/workflow/triggers/period_trigger.py +26 -0
  65. service_forge/workflow/triggers/websocket_api_trigger.py +184 -0
  66. service_forge/workflow/workflow.py +210 -0
  67. service_forge/workflow/workflow_callback.py +141 -0
  68. service_forge/workflow/workflow_event.py +15 -0
  69. service_forge/workflow/workflow_factory.py +246 -0
  70. service_forge/workflow/workflow_group.py +27 -0
  71. service_forge/workflow/workflow_type.py +52 -0
  72. service_forge-0.1.11.dist-info/METADATA +98 -0
  73. service_forge-0.1.11.dist-info/RECORD +75 -0
  74. service_forge-0.1.11.dist-info/WHEEL +4 -0
  75. service_forge-0.1.11.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,30 @@
1
+ from omegaconf import OmegaConf
2
+
3
+ class SfMetadata:
4
+ def __init__(
5
+ self,
6
+ name: str,
7
+ version: str,
8
+ description: str,
9
+ service_config: str,
10
+ config_only: bool,
11
+ env: list[dict],
12
+ ) -> None:
13
+ self.name = name
14
+ self.version = version
15
+ self.description = description
16
+ self.service_config = service_config
17
+ self.config_only = config_only
18
+ self.env = env
19
+
20
+ def load_metadata(path: str) -> SfMetadata:
21
+ with open(path, 'r') as file:
22
+ data = OmegaConf.load(file)
23
+ return SfMetadata(
24
+ name=data.get('name'),
25
+ version=data.get('version'),
26
+ description=data.get('description'),
27
+ service_config=data.get('service_config'),
28
+ config_only=data.get('config_only'),
29
+ env=data.get('env', []),
30
+ )
@@ -0,0 +1,153 @@
1
+ from __future__ import annotations
2
+
3
+ import inspect
4
+ from pathlib import Path
5
+ from typing import Optional
6
+ from omegaconf import OmegaConf
7
+
8
+ class SftConfig:
9
+ CONFIG_ROOT = Path.home() / ".sft"
10
+
11
+ # Configuration descriptions mapping
12
+ CONFIG_DESCRIPTIONS = {
13
+ "sft_file_root": "SFT file storage root directory",
14
+ "service_center_address": "Service center address",
15
+ "k8s_namespace": "Kubernetes namespace",
16
+ "registry_address": "Registry address",
17
+ "inject_http_port": "HTTP port for services",
18
+ "inject_kafka_host": "Kafka host for services",
19
+ "inject_kafka_port": "Kafka port for services",
20
+ "inject_postgres_host": "Postgres host for services",
21
+ "inject_postgres_port": "Postgres port for services",
22
+ "inject_postgres_user": "Postgres user for services",
23
+ "inject_postgres_password": "Postgres password for services",
24
+ "deepseek_api_key": "DeepSeek API key",
25
+ "deepseek_base_url": "DeepSeek base URL",
26
+ }
27
+
28
+ def __init__(
29
+ self,
30
+ sft_file_root: str = "/tmp/sft",
31
+ service_center_address: str = "http://vps.shiweinan.com:37919/service_center",
32
+ k8s_namespace: str = "secondbrain",
33
+ registry_address: str = "crpi-cev6qq28wwgwwj0y.cn-beijing.personal.cr.aliyuncs.com/nexthci",
34
+
35
+ inject_http_port: int = 8000,
36
+
37
+ inject_kafka_host: str = "localhost",
38
+ inject_kafka_port: int = 9092,
39
+
40
+ inject_postgres_host: str = "second-brain-postgres-postgresql",
41
+ inject_postgres_port: int = 5432,
42
+ inject_postgres_user: str = "postgres",
43
+ inject_postgres_password: str = "gnBGWg7aL4",
44
+
45
+ inject_mongo_host: str = "mongo-mongodb",
46
+ inject_mongo_port: int = 27017,
47
+ inject_mongo_user: str = "secondbrain",
48
+ inject_mongo_password: str = "secondbrain",
49
+ inject_mongo_db: str = "secondbrain",
50
+
51
+ inject_redis_host: str = "redis-master",
52
+ inject_redis_port: int = 6379,
53
+ inject_redis_password: str = "rDdM2Y2gX9",
54
+
55
+ deepseek_api_key: str = "82c9df22-f6ed-411e-90d7-c5255376b7ca",
56
+ deepseek_base_url: str = "https://ark.cn-beijing.volces.com/api/v3",
57
+ ):
58
+ self.sft_file_root = sft_file_root
59
+ self.service_center_address = service_center_address
60
+ self.k8s_namespace = k8s_namespace
61
+ self.registry_address = registry_address
62
+
63
+ self.inject_http_port = inject_http_port
64
+
65
+ self.inject_kafka_host = inject_kafka_host
66
+ self.inject_kafka_port = inject_kafka_port
67
+
68
+ self.inject_postgres_host = inject_postgres_host
69
+ self.inject_postgres_port = inject_postgres_port
70
+ self.inject_postgres_user = inject_postgres_user
71
+ self.inject_postgres_password = inject_postgres_password
72
+
73
+ self.inject_mongo_host = inject_mongo_host
74
+ self.inject_mongo_port = inject_mongo_port
75
+ self.inject_mongo_user = inject_mongo_user
76
+ self.inject_mongo_password = inject_mongo_password
77
+ self.inject_mongo_db = inject_mongo_db
78
+
79
+ self.inject_redis_host = inject_redis_host
80
+ self.inject_redis_port = inject_redis_port
81
+ self.inject_redis_password = inject_redis_password
82
+
83
+ self.deepseek_api_key = deepseek_api_key
84
+ self.deepseek_base_url = deepseek_base_url
85
+
86
+ @property
87
+ def server_url(self) -> str:
88
+ return self.service_center_address
89
+
90
+ @property
91
+ def upload_timeout(self) -> int:
92
+ return 300 # 5 minutes default timeout
93
+
94
+ @classmethod
95
+ def get_config_keys(cls) -> list[str]:
96
+ sig = inspect.signature(cls.__init__)
97
+ return [param for param in sig.parameters.keys() if param != 'self']
98
+
99
+ @property
100
+ def config_file_path(self) -> Path:
101
+ return self.CONFIG_ROOT / "config.yaml"
102
+
103
+ def ensure_config_dir(self) -> None:
104
+ self.CONFIG_ROOT.mkdir(parents=True, exist_ok=True)
105
+
106
+ def to_dict(self) -> dict:
107
+ config_keys = self.get_config_keys()
108
+ return {key: getattr(self, key) for key in config_keys}
109
+
110
+ def from_dict(self, data: dict) -> None:
111
+ config_keys = self.get_config_keys()
112
+ for key in config_keys:
113
+ if key in data:
114
+ setattr(self, key, data[key])
115
+
116
+ def save(self) -> None:
117
+ self.ensure_config_dir()
118
+ config_dict = self.to_dict()
119
+ OmegaConf.save(config_dict, self.config_file_path)
120
+
121
+ def get(self, key: str, default: Optional[str] = None) -> Optional[str]:
122
+ return getattr(self, key, default)
123
+
124
+ def set(self, key: str, value: str) -> None:
125
+ if key in ["config_root"]:
126
+ raise ValueError(f"{key} is read-only")
127
+ if hasattr(self, key):
128
+ setattr(self, key, value)
129
+ else:
130
+ raise ValueError(f"Unknown config key: {key}")
131
+
132
+ def update(self, updates: dict) -> None:
133
+ for key, value in updates.items():
134
+ self.set(key, value)
135
+
136
+
137
+ def load_config() -> SftConfig:
138
+ # config = SftConfig()
139
+ config = SftConfig()
140
+ config_file = config.config_file_path
141
+
142
+ if config_file.exists():
143
+ try:
144
+ data = OmegaConf.load(config_file)
145
+ config = SftConfig(**OmegaConf.to_container(data, resolve=True))
146
+ except Exception as e:
147
+ ...
148
+
149
+ config.save()
150
+
151
+ return config
152
+
153
+ sft_config = load_config()
File without changes
@@ -0,0 +1,80 @@
1
+ import fnmatch
2
+ import re
3
+ from pathlib import Path
4
+ from typing import List
5
+
6
+
7
+ class IgnorePattern:
8
+ def __init__(self, patterns: List[str], base_path: Path):
9
+ self.base_path = base_path.resolve()
10
+ self.patterns: List[tuple[str, bool]] = []
11
+
12
+ for pattern in patterns:
13
+ pattern = pattern.strip()
14
+ if not pattern or pattern.startswith('#'):
15
+ continue
16
+
17
+ is_negation = pattern.startswith('!')
18
+ if is_negation:
19
+ pattern = pattern[1:].strip()
20
+
21
+ pattern = pattern.replace('\\', '/')
22
+
23
+ if pattern.startswith('/'):
24
+ pattern = pattern[1:]
25
+
26
+ self.patterns.append((pattern, is_negation))
27
+
28
+ def should_ignore(self, file_path: Path) -> bool:
29
+ file_path = file_path.resolve()
30
+
31
+ try:
32
+ relative_path = file_path.relative_to(self.base_path)
33
+ except ValueError:
34
+ return False
35
+
36
+ path_str = str(relative_path).replace('\\', '/')
37
+
38
+ ignored = False
39
+ for pattern, is_negation in self.patterns:
40
+ if self._match_pattern(pattern, path_str, file_path):
41
+ if is_negation:
42
+ ignored = False
43
+ else:
44
+ ignored = True
45
+
46
+ return ignored
47
+
48
+ def _match_pattern(self, pattern: str, path_str: str, file_path: Path) -> bool:
49
+ if pattern.endswith('/'):
50
+ pattern = pattern[:-1]
51
+ if not file_path.is_dir():
52
+ return False
53
+
54
+ if '**' in pattern:
55
+ regex_pattern = pattern.replace('**', '.*')
56
+ regex_pattern = fnmatch.translate(regex_pattern)
57
+ return bool(re.match(regex_pattern, path_str))
58
+
59
+ path_parts = path_str.split('/')
60
+
61
+ if '/' in pattern:
62
+ return fnmatch.fnmatch(path_str, pattern)
63
+
64
+ return any(fnmatch.fnmatch(part, pattern) for part in path_parts)
65
+
66
+
67
+ def load_ignore_patterns(project_path: Path) -> IgnorePattern:
68
+ project_path = project_path.resolve()
69
+ ignore_file = project_path / '.sftignore'
70
+
71
+ if not ignore_file.exists():
72
+ return IgnorePattern([], project_path)
73
+
74
+ try:
75
+ with open(ignore_file, 'r', encoding='utf-8') as f:
76
+ patterns = f.readlines()
77
+ return IgnorePattern(patterns, project_path)
78
+ except Exception:
79
+ return IgnorePattern([], project_path)
80
+
@@ -0,0 +1,107 @@
1
+ import os
2
+ import tarfile
3
+ from pathlib import Path
4
+ import requests
5
+
6
+ from service_forge.sft.file.ignore_pattern import load_ignore_patterns
7
+ from service_forge.sft.config.sft_config import sft_config
8
+ from service_forge.sft.util.logger import log_success, log_info, log_error
9
+
10
+ class SftTarFile:
11
+ # example: sf_tag_service_0.0.1.tar
12
+ def __init__(self, path: Path):
13
+ self.path = path
14
+ self.name = path.name
15
+ self.size = path.stat().st_size
16
+ self.modified_time = path.stat().st_mtime
17
+ self.project_name = '_'.join(path.name.split('_')[1:-1])
18
+ self.version = path.name.split('_')[-1][:-4]
19
+
20
+ def _format_size(self) -> str:
21
+ for unit in ['B', 'KB', 'MB', 'GB']:
22
+ if self.size < 1024.0:
23
+ return f"{self.size:.2f} {unit}"
24
+ self.size /= 1024.0
25
+ return f"{self.size:.2f} TB"
26
+
27
+ def _format_modified_time(self) -> str:
28
+ from datetime import datetime
29
+ return datetime.fromtimestamp(self.modified_time).strftime("%Y-%m-%d %H:%M:%S")
30
+
31
+ @staticmethod
32
+ def is_valid_path(path: Path) -> bool:
33
+ return path.is_file() and path.suffix == '.tar' and path.name.startswith('sf_')
34
+
35
+
36
+ class SftFileManager:
37
+ def __init__(self):
38
+ self.tar_path = Path(sft_config.sft_file_root) / "service-tar"
39
+ self.tars: list[SftTarFile] = []
40
+ os.makedirs(self.tar_path, exist_ok=True)
41
+ self.load_tars()
42
+
43
+ def create_tar(self, project_path: Path, name: str, version: str) -> Path:
44
+ project_path = Path(project_path).resolve()
45
+ tar_path = Path(self.tar_path) / f"sf_{name}_{version}.tar"
46
+
47
+ ignore_pattern = load_ignore_patterns(project_path)
48
+
49
+ with tarfile.open(tar_path, 'w') as tar:
50
+ for root, dirs, files in os.walk(project_path):
51
+ root_path = Path(root)
52
+
53
+ dirs[:] = [
54
+ d for d in dirs
55
+ if not ignore_pattern.should_ignore(root_path / d)
56
+ ]
57
+
58
+ for file in files:
59
+ file_path = root_path / file
60
+ if ignore_pattern.should_ignore(file_path):
61
+ continue
62
+
63
+ arcname = file_path.relative_to(project_path)
64
+ tar.add(file_path, arcname=Path(f"{name}_{version}") / arcname)
65
+ self.load_tars()
66
+ return tar_path
67
+
68
+ def load_tars(self) -> list[SftTarFile]:
69
+ self.tars = [SftTarFile(p) for p in self.tar_path.iterdir() if SftTarFile.is_valid_path(p)]
70
+ return self.tars
71
+
72
+ def upload_tar(self, tar_path: Path) -> None:
73
+ if not tar_path.exists():
74
+ raise FileNotFoundError(f"File not found: {tar_path}")
75
+
76
+ upload_url = f"{sft_config.server_url}/api/v1/services/upload-tar"
77
+
78
+ try:
79
+ with open(tar_path, 'rb') as file:
80
+ files = {'file': (tar_path.name, file)}
81
+
82
+ response = requests.post(
83
+ upload_url,
84
+ files=files,
85
+ timeout=sft_config.upload_timeout
86
+ )
87
+
88
+ if response.status_code == 200:
89
+ result = response.json()
90
+ if result.get('code') == 200:
91
+ log_success(f"Upload successful: {result.get('message')}")
92
+ log_info(f"File saved path: {result.get('data', {}).get('file_path')}")
93
+ else:
94
+ raise Exception(f"Upload failed: {result.get('message')}")
95
+ else:
96
+ try:
97
+ error_detail = response.json()
98
+ error_message = error_detail.get('message', f"HTTP错误: {response.status_code}")
99
+ if 'debug' in error_detail and error_detail['debug']:
100
+ log_error(f"Error details: {error_detail['debug']}")
101
+ raise Exception(error_message)
102
+ except ValueError:
103
+ raise Exception(f"Server returned error status code: {response.status_code}")
104
+ except requests.exceptions.RequestException as e:
105
+ raise Exception(f"Upload request failed: {str(e)}")
106
+
107
+ sft_file_manager = SftFileManager()
@@ -0,0 +1,257 @@
1
+ from __future__ import annotations
2
+ from kubernetes.utils.create_from_yaml import FailToCreateError
3
+ import threading
4
+ from pathlib import Path
5
+ import yaml
6
+ from kubernetes import client, config, utils
7
+ from kubernetes.client.rest import ApiException
8
+ from kubernetes.dynamic import DynamicClient
9
+ from kubernetes.dynamic.exceptions import NotFoundError
10
+ from service_forge.sft.util.logger import log_error, log_info, log_success, log_warning
11
+
12
+ class KubernetesServiceDetails:
13
+ def __init__(self, name: str, type: str | None = None, port: int | None = None, target_port: int | None = None):
14
+ self.name = name
15
+ self.type = type
16
+ self.port = port
17
+ self.target_port = target_port
18
+
19
+ class KubernetesManager:
20
+ _instance_lock = threading.Lock()
21
+
22
+ def __init__(self):
23
+ try:
24
+ config.load_incluster_config()
25
+ # 使用InCluster配置创建DynamicClient
26
+ self.dynamic_client = DynamicClient(client.ApiClient())
27
+ except config.ConfigException:
28
+ config.load_kube_config()
29
+ # 如果InCluster配置失败,使用kubeconfig文件创建DynamicClient
30
+ self.dynamic_client = DynamicClient(config.new_client_from_config())
31
+
32
+ self.k8s_client = client.CoreV1Api()
33
+ self.k8s_apps_client = client.AppsV1Api()
34
+ self.k8s_batch_client = client.BatchV1Api()
35
+ self.k8s_rbac_client = client.RbacAuthorizationV1Api()
36
+ self.k8s_networking_client = client.NetworkingV1Api()
37
+ self.k8s_apiextensions_client = client.ApiextensionsV1Api()
38
+
39
+ self.api_mapping = {
40
+ "v1": self.k8s_client,
41
+ "apps/v1": self.k8s_apps_client,
42
+ "batch/v1": self.k8s_batch_client,
43
+ "rbac.authorization.k8s.io/v1": self.k8s_rbac_client,
44
+ "networking.k8s.io/v1": self.k8s_networking_client,
45
+ "apiextensions.k8s.io/v1": self.k8s_apiextensions_client,
46
+ }
47
+
48
+ def __new__(cls) -> KubernetesManager:
49
+ if not hasattr(cls, '_instance'):
50
+ with KubernetesManager._instance_lock:
51
+ if not hasattr(cls, '_instance'):
52
+ KubernetesManager._instance = super().__new__(cls)
53
+ return KubernetesManager._instance
54
+
55
+ def get_services_in_namespace(self, namespace: str) -> list[str]:
56
+ try:
57
+ services = self.k8s_client.list_namespaced_service(namespace=namespace)
58
+ return [svc.metadata.name for svc in services.items if svc.metadata.name.startswith("sf-")]
59
+ except ApiException as e:
60
+ log_error(f"Failed to get services: {e.reason}")
61
+ return []
62
+ except Exception as e:
63
+ log_error(f"Failed to get services: {e}")
64
+ return []
65
+
66
+ def get_service_details(self, namespace: str, service_name: str) -> KubernetesServiceDetails:
67
+ try:
68
+ service = self.k8s_client.read_namespaced_service(name=service_name, namespace=namespace)
69
+ return KubernetesServiceDetails(
70
+ name=service.metadata.name,
71
+ type=service.spec.type,
72
+ port=service.spec.ports[0].port,
73
+ target_port=service.spec.ports[0].target_port
74
+ )
75
+ except ApiException as e:
76
+ log_error(f"Failed to get service details: {e.reason}")
77
+ return KubernetesServiceDetails(name=service_name)
78
+ except Exception as e:
79
+ log_error(f"Failed to get service details: {e}")
80
+ return KubernetesServiceDetails(name=service_name)
81
+
82
+ def get_pods_for_service(self, namespace: str, service_name: str) -> list[str]:
83
+ try:
84
+ service = self.k8s_client.read_namespaced_service(name=service_name, namespace=namespace)
85
+ selector = service.spec.selector
86
+ if not selector:
87
+ log_error(f"Service '{service_name}' has no selector")
88
+ return []
89
+ label_selector = ",".join([f"{k}={v}" for k, v in selector.items()])
90
+ pods = self.k8s_client.list_namespaced_pod(namespace=namespace, label_selector=label_selector)
91
+ return [pod.metadata.name for pod in pods.items]
92
+ except ApiException as e:
93
+ log_error(f"Failed to get pods for service: {e.reason}")
94
+ return []
95
+ except Exception as e:
96
+ log_error(f"Failed to get pods for service: {e}")
97
+ return []
98
+
99
+ def get_pod_containers(self, namespace: str, pod_name: str) -> list[str]:
100
+ try:
101
+ pod = self.k8s_client.read_namespaced_pod(name=pod_name, namespace=namespace)
102
+ containers = []
103
+ if pod.spec.containers:
104
+ containers.extend([c.name for c in pod.spec.containers])
105
+ if pod.spec.init_containers:
106
+ containers.extend([c.name for c in pod.spec.init_containers])
107
+ return containers
108
+ except ApiException as e:
109
+ log_error(f"Failed to get pod containers: {e.reason}")
110
+ return []
111
+ except Exception as e:
112
+ log_error(f"Failed to get pod containers: {e}")
113
+ return []
114
+
115
+ def get_pod_logs(self, namespace: str, pod_name: str, container_name: str, tail: int, follow: bool, previous: bool) -> str:
116
+ try:
117
+ logs = self.k8s_client.read_namespaced_pod_log(
118
+ name=pod_name,
119
+ namespace=namespace,
120
+ container=container_name,
121
+ tail_lines=tail if not follow else None,
122
+ previous=previous,
123
+ follow=follow,
124
+ _preload_content=not follow
125
+ )
126
+ return logs
127
+ except ApiException as e:
128
+ log_error(f"Failed to get pod logs: {e.reason}")
129
+ return ""
130
+ except Exception as e:
131
+ log_error(f"Failed to get pod logs: {e}")
132
+
133
+ def apply_dynamic_yaml(self, obj: dict, namespace: str) -> None:
134
+ api_version = obj["apiVersion"]
135
+ kind = obj["kind"]
136
+ metadata = obj["metadata"]
137
+ name = metadata["name"]
138
+
139
+ resource = self.dynamic_client.resources.get(api_version=api_version, kind=kind)
140
+
141
+ try:
142
+ resource.get(name=name, namespace=namespace)
143
+ print(f"{kind}/{name} exists → patching...")
144
+ resource.patch(name=name, namespace=namespace, body=obj, content_type="application/merge-patch+json")
145
+
146
+ except NotFoundError:
147
+ print(f"{kind}/{name} not found → creating...")
148
+ resource.create(body=obj, namespace=namespace)
149
+
150
+
151
+ def apply_deployment_yaml(self, deployment_yaml: Path, namespace: str) -> None:
152
+ with open(deployment_yaml, 'r') as f:
153
+ objs = yaml.safe_load_all(f)
154
+ for obj in objs:
155
+ api_version = obj["apiVersion"]
156
+ kind = obj["kind"]
157
+ metadata = obj["metadata"]
158
+
159
+ name = metadata["name"]
160
+
161
+ api_client = self.api_mapping.get(api_version)
162
+ if not api_client:
163
+ self.apply_dynamic_yaml(obj, namespace)
164
+ continue
165
+
166
+ read_fn = getattr(api_client, f"read_namespaced_{kind.lower()}", None)
167
+ create_fn = getattr(api_client, f"create_namespaced_{kind.lower()}", None)
168
+ patch_fn = getattr(api_client, f"patch_namespaced_{kind.lower()}", None)
169
+
170
+ if not read_fn:
171
+ raise Exception(f"Unsupported resource type: {kind}")
172
+
173
+ try:
174
+ read_fn(name=name, namespace=namespace)
175
+ print(f"{kind}/{name} exists → patching...")
176
+ patch_fn(name=name, namespace=namespace, body=obj)
177
+
178
+ except ApiException as e:
179
+ if e.status == 404:
180
+ print(f"{kind}/{name} not found → creating...")
181
+ create_fn(namespace=namespace, body=obj)
182
+ else:
183
+ raise
184
+
185
+ def delete_service(self, namespace: str, service_name: str, force: bool = False) -> None:
186
+ delete_options = client.V1DeleteOptions()
187
+ if force:
188
+ delete_options.grace_period_seconds = 0
189
+ delete_options.propagation_policy = "Background"
190
+
191
+ # Delete deployment
192
+ try:
193
+ log_info(f"Attempting to delete deployment '{service_name}'...")
194
+ self.k8s_apps_client.delete_namespaced_deployment(
195
+ name=service_name,
196
+ namespace=namespace,
197
+ body=delete_options
198
+ )
199
+ log_success(f"Deployment '{service_name}' deleted successfully")
200
+ except ApiException as e:
201
+ if e.status == 404:
202
+ log_warning(f"Deployment '{service_name}' not found, skipping...")
203
+ else:
204
+ log_warning(f"Failed to delete deployment '{service_name}': {e.reason}")
205
+ log_warning("Continuing with service deletion...")
206
+ except Exception as e:
207
+ log_warning(f"Failed to delete deployment '{service_name}': {e}")
208
+ log_warning("Continuing with service deletion...")
209
+
210
+ # Delete service
211
+ try:
212
+ log_info(f"Attempting to delete service '{service_name}'...")
213
+ self.k8s_client.delete_namespaced_service(
214
+ name=service_name,
215
+ namespace=namespace,
216
+ body=delete_options
217
+ )
218
+ log_success(f"Service '{service_name}' deleted successfully")
219
+ except ApiException as e:
220
+ if e.status == 404:
221
+ log_warning(f"Service '{service_name}' not found, skipping...")
222
+ else:
223
+ log_error(f"Failed to delete service '{service_name}': {e.reason}")
224
+ if e.body:
225
+ log_error(f"Error details: {e.body}")
226
+ except Exception as e:
227
+ log_error(f"Failed to delete service '{service_name}': {e}")
228
+
229
+ # Delete IngressRoute (Traefik CRD)
230
+ try:
231
+ log_info(f"Attempting to delete IngressRoute '{service_name}'...")
232
+ ingressroute_resource = self.dynamic_client.resources.get(
233
+ api_version="traefik.io/v1alpha1",
234
+ kind="IngressRoute"
235
+ )
236
+ ingressroute_resource.delete(name=service_name, namespace=namespace)
237
+ log_success(f"IngressRoute '{service_name}' deleted successfully")
238
+ except NotFoundError:
239
+ log_warning(f"IngressRoute '{service_name}' not found, skipping...")
240
+ except Exception as e:
241
+ log_warning(f"Failed to delete IngressRoute '{service_name}': {e}")
242
+
243
+ # Delete Middleware (Traefik CRD)
244
+ middleware_name = f"strip-prefix-{service_name}"
245
+ try:
246
+ log_info(f"Attempting to delete Middleware '{middleware_name}'...")
247
+ middleware_resource = self.dynamic_client.resources.get(
248
+ api_version="traefik.io/v1alpha1",
249
+ kind="Middleware"
250
+ )
251
+ middleware_resource.delete(name=middleware_name, namespace=namespace)
252
+ log_success(f"Middleware '{middleware_name}' deleted successfully")
253
+ except NotFoundError:
254
+ log_warning(f"Middleware '{middleware_name}' not found, skipping...")
255
+ except Exception as e:
256
+ log_warning(f"Failed to delete Middleware '{middleware_name}': {e}")
257
+
@@ -0,0 +1,25 @@
1
+ import typer
2
+ from pathlib import Path
3
+ from typing import Callable, TypeVar, Any
4
+ from service_forge.sft.util.logger import log_error, log_info
5
+ from service_forge.sft.config.sf_metadata import load_metadata, SfMetadata
6
+
7
+ T = TypeVar('T')
8
+
9
+ def assert_dir_exists(path: Path) -> None:
10
+ if not path.exists():
11
+ log_error(f"Directory does not exist: {path}")
12
+ raise typer.Exit(1)
13
+ if not path.is_dir():
14
+ log_error(f"Path is not a directory: {path}")
15
+ raise typer.Exit(1)
16
+ log_info(f"Directory exists: {path}")
17
+
18
+ def assert_file_exists(path: Path) -> None:
19
+ if not path.exists():
20
+ log_error(f"File does not exist: {path}")
21
+ raise typer.Exit(1)
22
+ if not path.is_file():
23
+ log_error(f"Path is not a file: {path}")
24
+ raise typer.Exit(1)
25
+ log_info(f"File exists: {path}")
@@ -0,0 +1,16 @@
1
+ from rich.console import Console
2
+ from typing import Any
3
+
4
+ console = Console()
5
+
6
+ def log_error(message: str, **kwargs: Any) -> None:
7
+ console.print(f"[red]{message}[/red]", **kwargs)
8
+
9
+ def log_info(message: str, **kwargs: Any) -> None:
10
+ console.print(f"{message}", **kwargs)
11
+
12
+ def log_success(message: str, **kwargs: Any) -> None:
13
+ console.print(f"[green]{message}[/green]", **kwargs)
14
+
15
+ def log_warning(message: str, **kwargs: Any) -> None:
16
+ console.print(f"[yellow]{message}[/yellow]", **kwargs)
@@ -0,0 +1,8 @@
1
+ def get_metadata_file_name(name: str, version: str) -> str:
2
+ return "sf-meta.yaml"
3
+
4
+ def get_service_name(name: str, version: str) -> str:
5
+ return f"sf-{name}-{version.replace('.', '-')}v"
6
+
7
+ def get_service_url_name(name: str, version: str) -> str:
8
+ return f"{name}-{version.replace('.', '-')}"