salla-gitpuller 0.1.2__tar.gz → 1.0.0__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: salla_gitpuller
3
- Version: 0.1.2
3
+ Version: 1.0.0
4
4
  Summary: A lightweight utility to git pull a repository using SSH deploy keys stored in environment variables
5
5
  Author-email: Muhammad Zahid <zahidmuhammad127@gmail.com>
6
6
  License: MIT
@@ -0,0 +1,19 @@
1
+ from .gitpull import GitPullExecutor
2
+ from .alert_manager import AlertManager
3
+ from .state_manager import StateManager, InMemoryStateManager, MageAIStateManager
4
+ from .slack_notifier import SlackNotifier
5
+ from .utils import transform_custom, get_repo_path, get_env_base_path
6
+
7
+
8
+ __version__ = "1.0.0"
9
+ __all__ = [
10
+ "GitPullExecutor",
11
+ "AlertManager",
12
+ "StateManager",
13
+ "InMemoryStateManager",
14
+ "MageAIStateManager",
15
+ "SlackNotifier",
16
+ "transform_custom",
17
+ "get_repo_path",
18
+ "get_env_base_path"
19
+ ]
@@ -0,0 +1,63 @@
1
+ from datetime import datetime, timedelta
2
+ from typing import Any, Dict, Optional, Tuple
3
+ from .state_manager import StateManager, InMemoryStateManager, MageAIStateManager
4
+
5
+ class AlertManager:
6
+ def __init__(self, state_manager: Optional[StateManager] = None, use_mage_ai: bool = False):
7
+ if state_manager is not None:
8
+ self.state_manager = state_manager
9
+ elif use_mage_ai:
10
+ try:
11
+ self.state_manager = MageAIStateManager()
12
+ except ImportError:
13
+ print("⚠️ Mage AI not available, falling back to in-memory state manager")
14
+ self.state_manager = InMemoryStateManager()
15
+ else:
16
+ self.state_manager = InMemoryStateManager()
17
+
18
+ def should_send_alert(
19
+ self,
20
+ pipeline_uuid: str,
21
+ current_error: str,
22
+ suppression_hours: int = 1
23
+ ) -> Tuple[bool, Dict[str, Any]]:
24
+
25
+ state = self.state_manager.load_alert_state(pipeline_uuid)
26
+ last_error = state.get("last_error_message")
27
+ last_alert_time_str = state.get("last_alert_time")
28
+
29
+ # If no previous alert, always send
30
+ if not last_error or not last_alert_time_str:
31
+ return True, {"last_error_message": None, "last_alert_time": None}
32
+
33
+ # If error is different, always send
34
+ if last_error != current_error:
35
+ return True, {"last_error_message": last_error, "last_alert_time": last_alert_time_str}
36
+
37
+ # Same error - check if enough time has passed
38
+ try:
39
+ last_alert_time = datetime.fromisoformat(last_alert_time_str)
40
+ time_since_last_alert = datetime.now() - last_alert_time
41
+
42
+ if time_since_last_alert >= timedelta(hours=suppression_hours):
43
+ return True, {"last_error_message": last_error, "last_alert_time": last_alert_time_str}
44
+ else:
45
+ return False, {"last_error_message": last_error, "last_alert_time": last_alert_time_str}
46
+ except (ValueError, TypeError):
47
+ # If we can't parse the time, send the alert to be safe
48
+ return True, {"last_error_message": last_error, "last_alert_time": last_alert_time_str}
49
+
50
+ def save_alert_state(
51
+ self,
52
+ pipeline_uuid: str,
53
+ error_message: str,
54
+ alert_time: datetime,
55
+ pipeline_status: str
56
+ ) -> None:
57
+ """Save alert state."""
58
+ self.state_manager.save_alert_state(pipeline_uuid, error_message, alert_time, pipeline_status)
59
+
60
+ def clear_alert_state(self, pipeline_uuid: str) -> None:
61
+ """Clear alert state on success."""
62
+ self.state_manager.clear_alert_state(pipeline_uuid)
63
+
@@ -0,0 +1,202 @@
1
+ import os
2
+ import subprocess
3
+ from typing import Dict, Any, Optional
4
+ from datetime import datetime
5
+ from .alert_manager import AlertManager
6
+ from .slack_notifier import SlackNotifier
7
+
8
+ class GitPullExecutor:
9
+ def __init__(
10
+ self,
11
+ slack_webhook_url: Optional[str] = None,
12
+ use_mage_ai: bool = False,
13
+ state_manager: Optional[Any] = None
14
+ ):
15
+ self.slack_notifier = SlackNotifier(webhook_url=slack_webhook_url)
16
+ self.alert_manager = AlertManager(state_manager=state_manager, use_mage_ai=use_mage_ai)
17
+
18
+ def normalize_ssh_key(self, key_material: str) -> str:
19
+ if not key_material:
20
+ return key_material
21
+
22
+ # Remove any quotes that might be wrapping the key
23
+ key_material = key_material.strip().strip('"').strip("'")
24
+
25
+ # Handle literal \n sequences
26
+ if "\\n" in key_material:
27
+ key_material = key_material.replace("\\n", "\n")
28
+
29
+ # Ensure proper line endings
30
+ if not key_material.endswith("\n"):
31
+ key_material += "\n"
32
+
33
+ return key_material
34
+
35
+ def prepare_ssh_key(
36
+ self,
37
+ key_material: str,
38
+ key_filename: str = "deploy_key",
39
+ ssh_dir: str = "/home/src/.ssh"
40
+ ) -> str:
41
+ os.makedirs(ssh_dir, exist_ok=True)
42
+ os.chmod(ssh_dir, 0o700)
43
+
44
+ key_path = os.path.join(ssh_dir, key_filename)
45
+
46
+ # Write key with restricted permissions
47
+ with open(key_path, "w") as f:
48
+ f.write(key_material)
49
+ os.chmod(key_path, 0o600)
50
+
51
+ return key_path
52
+
53
+ def execute_git_pull(
54
+ self,
55
+ repo_path: str,
56
+ git_url: str,
57
+ branch: str = "master",
58
+ ssh_key: Optional[str] = None,
59
+ workspace_name: Optional[str] = None,
60
+ key_filename: Optional[str] = None,
61
+ ssh_dir: str = "/home/src/.ssh"
62
+ ) -> Dict[str, Any]:
63
+ if not os.path.exists(repo_path):
64
+ raise ValueError(f"Repo path does not exist: {repo_path}")
65
+
66
+ # Get SSH key
67
+ if not ssh_key and workspace_name:
68
+ key_env_name = f"{workspace_name}_SSHKEY"
69
+ ssh_key = os.environ.get(key_env_name)
70
+ if not ssh_key:
71
+ raise ValueError(f"Missing env var {key_env_name} with the PRIVATE deploy key")
72
+ elif not ssh_key:
73
+ raise ValueError("SSH key is required. Provide it directly or set workspace_name.")
74
+
75
+ # Normalize key
76
+ ssh_key = self.normalize_ssh_key(ssh_key)
77
+
78
+ # Prepare key file
79
+ if not key_filename:
80
+ key_filename = f"{workspace_name}_deploy" if workspace_name else "deploy_key"
81
+
82
+ key_path = self.prepare_ssh_key(ssh_key, key_filename, ssh_dir)
83
+
84
+ # Execute git pull
85
+ cmd = [
86
+ "git",
87
+ "-c",
88
+ f"core.sshCommand=ssh -i {key_path} -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new",
89
+ "pull",
90
+ git_url,
91
+ branch,
92
+ ]
93
+
94
+ git_output = ""
95
+ git_status = "success"
96
+ key_env_var_used = f"{workspace_name}_SSHKEY" if workspace_name else "N/A"
97
+
98
+ original_dir = None
99
+ try:
100
+ original_dir = os.getcwd()
101
+ os.chdir(repo_path)
102
+
103
+ result = subprocess.run(
104
+ cmd,
105
+ capture_output=True,
106
+ text=True,
107
+ check=True,
108
+ )
109
+ git_output = result.stdout
110
+ git_status = "success"
111
+ except subprocess.CalledProcessError as e:
112
+ git_output = (e.stdout or "") + (e.stderr or "")
113
+ git_status = "error"
114
+ raise
115
+ finally:
116
+ if original_dir:
117
+ try:
118
+ os.chdir(original_dir)
119
+ except Exception:
120
+ pass
121
+ # Clean up the key file
122
+ try:
123
+ os.remove(key_path)
124
+ except Exception:
125
+ pass
126
+
127
+ return {
128
+ "workspace": workspace_name or "N/A",
129
+ "repo_path": repo_path,
130
+ "git_pull_status": git_status,
131
+ "git_pull_output": git_output.strip(),
132
+ "key_env_var_used": key_env_var_used,
133
+ }
134
+
135
+ def execute_with_alerting(
136
+ self,
137
+ repo_path: str,
138
+ git_url: str,
139
+ branch: str = "master",
140
+ ssh_key: Optional[str] = None,
141
+ workspace_name: Optional[str] = None,
142
+ pipeline_uuid: str = "auto_git_pull",
143
+ suppression_hours: int = 1,
144
+ key_filename: Optional[str] = None,
145
+ ssh_dir: str = "/home/src/.ssh"
146
+ ) -> Dict[str, Any]:
147
+ repo_name = git_url.split('/')[-1].replace('.git', '')
148
+
149
+ try:
150
+ result = self.execute_git_pull(
151
+ repo_path=repo_path,
152
+ git_url=git_url,
153
+ branch=branch,
154
+ ssh_key=ssh_key,
155
+ workspace_name=workspace_name,
156
+ key_filename=key_filename,
157
+ ssh_dir=ssh_dir
158
+ )
159
+
160
+ # Clear alert state on success
161
+ self.alert_manager.clear_alert_state(pipeline_uuid)
162
+
163
+ return result
164
+
165
+ except Exception as e:
166
+ git_output = str(e)
167
+ error_signature = git_output.strip()[:500]
168
+
169
+ # Check if we should send alert
170
+ should_alert, _ = self.alert_manager.should_send_alert(
171
+ pipeline_uuid,
172
+ error_signature,
173
+ suppression_hours=suppression_hours
174
+ )
175
+
176
+ if should_alert:
177
+ # Send alert
178
+ self.slack_notifier.send_alert(repo_name, git_output[:1500])
179
+
180
+ # Save alert state
181
+ self.alert_manager.save_alert_state(
182
+ pipeline_uuid,
183
+ error_signature,
184
+ datetime.now(),
185
+ pipeline_status="failed"
186
+ )
187
+ else:
188
+ # Alert suppressed
189
+ state = self.alert_manager.state_manager.load_alert_state(pipeline_uuid)
190
+ last_alert_time = state.get("last_alert_time")
191
+ if last_alert_time:
192
+ try:
193
+ from datetime import datetime, timedelta
194
+ last_alert_dt = datetime.fromisoformat(last_alert_time)
195
+ time_since = datetime.now() - last_alert_dt
196
+ print(f"🔇 Alert suppressed - same error occurred {time_since} ago (suppression window: {suppression_hours} hour(s))")
197
+ except:
198
+ print(f"🔇 Alert suppressed - same error within suppression window ({suppression_hours} hour(s))")
199
+
200
+ # Re-raise the exception
201
+ raise
202
+
@@ -0,0 +1,60 @@
1
+ import os
2
+ import requests
3
+ from typing import Optional, Dict, Any
4
+
5
+
6
+ class SlackNotifier:
7
+
8
+ def __init__(self, webhook_url: Optional[str] = None):
9
+ self.webhook_url = webhook_url or os.environ.get("CDM_SLACK_WEBHOOK_URL")
10
+ if not self.webhook_url:
11
+ raise ValueError(
12
+ "Slack webhook URL is required. "
13
+ "Provide it as parameter or set CDM_SLACK_WEBHOOK_URL environment variable."
14
+ )
15
+
16
+ def create_failure_payload(self, repo_name: str, error_output: str) -> Dict[str, Any]:
17
+ payload = {
18
+ "text": f"Automate Git Pull Pipeline Failed",
19
+ "blocks": [
20
+ {
21
+ "type": "header",
22
+ "text": {
23
+ "type": "plain_text",
24
+ "text": f":alert: Automate Git Pull Pipeline Failed :alert:"
25
+ }
26
+ },
27
+ {
28
+ "type": "divider"
29
+ },
30
+ {
31
+ "type": "section",
32
+ "text": {
33
+ "type": "mrkdwn",
34
+ "text": f"*Repository:* `{repo_name}`"
35
+ }
36
+ }
37
+ ]
38
+ }
39
+
40
+ if error_output:
41
+ payload["blocks"].append({
42
+ "type": "section",
43
+ "text": {
44
+ "type": "mrkdwn",
45
+ "text": f"*Error Output:*\n```{error_output[:1500]}```"
46
+ }
47
+ })
48
+
49
+ return payload
50
+
51
+ def send_alert(self, repo_name: str, error_output: str = "") -> bool:
52
+ try:
53
+ payload = self.create_failure_payload(repo_name, error_output)
54
+ response = requests.post(self.webhook_url, json=payload, timeout=10)
55
+ response.raise_for_status()
56
+ print("✅ Alert sent to Slack")
57
+ return True
58
+ except Exception as e:
59
+ print(f"⚠️ Failed to send Slack alert: {e}")
60
+ return False
@@ -0,0 +1,113 @@
1
+ from typing import Any, Dict, Optional
2
+ from datetime import datetime
3
+
4
+ class StateManager:
5
+ def load_alert_state(self, pipeline_uuid: str) -> Dict[str, Any]:
6
+ """Load alert state for a pipeline."""
7
+ raise NotImplementedError
8
+
9
+ def save_alert_state(
10
+ self,
11
+ pipeline_uuid: str,
12
+ error_message: str,
13
+ alert_time: datetime,
14
+ pipeline_status: str
15
+ ) -> None:
16
+ """Save alert state for a pipeline."""
17
+ raise NotImplementedError
18
+
19
+ def clear_alert_state(self, pipeline_uuid: str) -> None:
20
+ """Clear alert state for a pipeline."""
21
+ raise NotImplementedError
22
+
23
+ class InMemoryStateManager(StateManager):
24
+ def __init__(self):
25
+ self._state: Dict[str, Dict[str, Any]] = {}
26
+
27
+ def load_alert_state(self, pipeline_uuid: str) -> Dict[str, Any]:
28
+ """Load alert state from memory."""
29
+ return self._state.get(pipeline_uuid, {
30
+ "last_alert_time": None,
31
+ "last_error_message": None
32
+ })
33
+
34
+ def save_alert_state(
35
+ self,
36
+ pipeline_uuid: str,
37
+ error_message: str,
38
+ alert_time: datetime,
39
+ pipeline_status: str
40
+ ) -> None:
41
+ """Save alert state to memory."""
42
+ self._state[pipeline_uuid] = {
43
+ "last_alert_time": alert_time.isoformat(),
44
+ "last_error_message": error_message,
45
+ "pipeline_status": pipeline_status,
46
+ "last_alert_timestamp": alert_time.isoformat()
47
+ }
48
+
49
+ def clear_alert_state(self, pipeline_uuid: str) -> None:
50
+ """Clear alert state from memory."""
51
+ if pipeline_uuid in self._state:
52
+ self._state[pipeline_uuid] = {
53
+ "last_alert_time": None,
54
+ "last_error_message": None,
55
+ "pipeline_status": "success"
56
+ }
57
+
58
+ class MageAIStateManager(StateManager):
59
+ def __init__(self):
60
+ try:
61
+ from mage_ai.data_preparation.variable_manager import (
62
+ set_global_variable,
63
+ get_global_variable
64
+ )
65
+ self._set_global_variable = set_global_variable
66
+ self._get_global_variable = get_global_variable
67
+ except ImportError:
68
+ raise ImportError(
69
+ "Mage AI is not installed. Install it with: pip install mage-ai"
70
+ )
71
+
72
+ def load_alert_state(self, pipeline_uuid: str) -> Dict[str, Any]:
73
+ try:
74
+ last_error_message = self._get_global_variable(pipeline_uuid, 'last_error_message')
75
+ last_alert_time = self._get_global_variable(pipeline_uuid, 'last_alert_time')
76
+
77
+ if last_error_message and last_alert_time:
78
+ return {
79
+ "last_alert_time": last_alert_time,
80
+ "last_error_message": last_error_message
81
+ }
82
+ except Exception as e:
83
+ print(f"⚠️ Warning: Could not load alert state from global variables: {e}")
84
+
85
+ return {
86
+ "last_alert_time": None,
87
+ "last_error_message": None
88
+ }
89
+
90
+ def save_alert_state(
91
+ self,
92
+ pipeline_uuid: str,
93
+ error_message: str,
94
+ alert_time: datetime,
95
+ pipeline_status: str
96
+ ) -> None:
97
+ try:
98
+ self._set_global_variable(pipeline_uuid, 'last_error_message', error_message)
99
+ self._set_global_variable(pipeline_uuid, 'last_alert_time', alert_time.isoformat())
100
+ self._set_global_variable(pipeline_uuid, 'pipeline_status', pipeline_status)
101
+ self._set_global_variable(pipeline_uuid, 'last_alert_timestamp', alert_time.isoformat())
102
+ print(f"✅ Saved alert state to global variables: error_message, pipeline_status={pipeline_status}")
103
+ except Exception as e:
104
+ print(f"⚠️ Warning: Could not save alert state to global variables: {e}")
105
+
106
+ def clear_alert_state(self, pipeline_uuid: str) -> None:
107
+ try:
108
+ self._set_global_variable(pipeline_uuid, 'last_error_message', None)
109
+ self._set_global_variable(pipeline_uuid, 'last_alert_time', None)
110
+ self._set_global_variable(pipeline_uuid, 'pipeline_status', 'success')
111
+ print("✅ Cleared alert state from global variables")
112
+ except Exception as e:
113
+ print(f"⚠️ Warning: Could not clear alert state: {e}")
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "salla_gitpuller"
7
- version = "0.1.2"
7
+ version = "1.0.0"
8
8
  description = "A lightweight utility to git pull a repository using SSH deploy keys stored in environment variables"
9
9
  readme = "README.md"
10
10
  authors = [
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: salla_gitpuller
3
- Version: 0.1.2
3
+ Version: 1.0.0
4
4
  Summary: A lightweight utility to git pull a repository using SSH deploy keys stored in environment variables
5
5
  Author-email: Muhammad Zahid <zahidmuhammad127@gmail.com>
6
6
  License: MIT
@@ -2,7 +2,10 @@ LICENSE
2
2
  README.md
3
3
  pyproject.toml
4
4
  gitpuller/__init__.py
5
+ gitpuller/alert_manager.py
5
6
  gitpuller/gitpull.py
7
+ gitpuller/slack_notifier.py
8
+ gitpuller/state_manager.py
6
9
  gitpuller/utils.py
7
10
  salla_gitpuller.egg-info/PKG-INFO
8
11
  salla_gitpuller.egg-info/SOURCES.txt
@@ -1,4 +0,0 @@
1
- from .gitpull import git_pull
2
- from .utils import transform_custom, get_repo_path, get_env_base_path
3
-
4
- __all__ = ["git_pull", "transform_custom", "get_repo_path", "get_env_base_path"]
@@ -1,115 +0,0 @@
1
- import os
2
- import requests
3
- import subprocess
4
- from typing import Any, Dict
5
-
6
-
7
- def git_pull(repo_path: str, workspace_name: str, git_url: str, key_material: str, webhook_url: str) -> Dict[str, Any]:
8
- """
9
- Executes 'git pull' in the specified repository path using a deploy key
10
- provided via the env var WP_AUTOPULL_SSHKEY.
11
-
12
- Args:
13
- repo_path: Path to the local git repository
14
- workspace_name: Logical workspace identifier
15
- git_url: Git remote URL (SSH-based)
16
- Returns:
17
- Dict with status and command output.
18
- """
19
- print(f"Running git pull for workspace: {workspace_name}")
20
- print(f"Repo path: {repo_path}")
21
-
22
- repo_name = git_url.split('/')[-1].replace('.git', '')
23
-
24
- if not all([webhook_url]):
25
- raise EnvironmentError(
26
- "One or more Slack environment variables are missing:\n"
27
- "CDM_SLACK_WEBHOOK_URL"
28
- )
29
-
30
- payload = {
31
- "text": f"Automate Git Pull Pipeline Failed",
32
- "blocks": [
33
- {
34
- "type": "header",
35
- "text": {
36
- "type": "plain_text",
37
- "text": f":alert: Automate Git Pull Pipeline Failed :alert:"
38
- }
39
- },
40
- {
41
- "type": "divider"
42
- },
43
- {
44
- "type": "section",
45
- "text": {
46
- "type": "mrkdwn",
47
- "text": f"*Repository:* `{repo_name}`"
48
- }
49
- }
50
- ]
51
- }
52
-
53
-
54
- if not os.path.exists(repo_path):
55
- raise FileNotFoundError(f"Repo path does not exist: {repo_path}")
56
-
57
-
58
- if not key_material:
59
- raise EnvironmentError(f"Missing SSH key material for workspace: {workspace_name}")
60
-
61
- if key_material:
62
- # Remove any quotes that might be wrapping the key
63
- key_material = key_material.strip().strip('"').strip("'")
64
-
65
- # Handle literal \n sequences
66
- if "\\n" in key_material:
67
- key_material = key_material.replace("\\n", "\n")
68
-
69
- # Ensure proper line endings
70
- if not key_material.endswith("\n"):
71
- key_material += "\n"
72
-
73
- ssh_dir = os.path.expanduser("~/.ssh")
74
- key_path = os.path.join(ssh_dir, "SallaApp_mageai_CI_CD_deploy")
75
- os.makedirs(ssh_dir, exist_ok=True)
76
- os.chmod(ssh_dir, 0o700)
77
-
78
- with open(key_path, "w") as f:
79
- f.write(key_material)
80
- os.chmod(key_path, 0o600)
81
-
82
- cmd = [
83
- "git",
84
- "-c",
85
- f"core.sshCommand=ssh -i {key_path} -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new",
86
- "pull",
87
- git_url,
88
- "master",
89
- ]
90
-
91
- try:
92
- os.chdir(repo_path)
93
- result = subprocess.run(cmd, capture_output=True, text=True, check=True)
94
- git_output = result.stdout
95
- git_status = "success"
96
- except subprocess.CalledProcessError as e:
97
- git_output = (e.stdout or "") + (e.stderr or "")
98
- git_status = "error"
99
- payload["blocks"].append({
100
- "type": "section",
101
- "text": {"type": "mrkdwn", "text": f"*Error Output:*\n```{git_output[:1500]}```"} # truncate for Slack limit
102
- })
103
- requests.post(webhook_url, json=payload)
104
- finally:
105
- try:
106
- os.remove(key_path)
107
- except Exception:
108
- pass
109
-
110
- return {
111
- "workspace": workspace_name,
112
- "repo_path": repo_path,
113
- "git_pull_status": git_status,
114
- "git_pull_output": git_output.strip(),
115
- }
File without changes