kubernetes-watch 0.1.12__tar.gz → 0.1.13__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.
Files changed (37) hide show
  1. {kubernetes_watch-0.1.12 → kubernetes_watch-0.1.13}/PKG-INFO +1 -1
  2. {kubernetes_watch-0.1.12 → kubernetes_watch-0.1.13}/kube_watch/modules/clusters/kube.py +39 -1
  3. kubernetes_watch-0.1.13/kube_watch/modules/providers/git.py +309 -0
  4. {kubernetes_watch-0.1.12 → kubernetes_watch-0.1.13}/pyproject.toml +1 -1
  5. kubernetes_watch-0.1.12/kube_watch/modules/providers/git.py +0 -109
  6. {kubernetes_watch-0.1.12 → kubernetes_watch-0.1.13}/LICENSE +0 -0
  7. {kubernetes_watch-0.1.12 → kubernetes_watch-0.1.13}/README.md +0 -0
  8. {kubernetes_watch-0.1.12 → kubernetes_watch-0.1.13}/kube_watch/__init__.py +0 -0
  9. {kubernetes_watch-0.1.12 → kubernetes_watch-0.1.13}/kube_watch/enums/__init__.py +0 -0
  10. {kubernetes_watch-0.1.12 → kubernetes_watch-0.1.13}/kube_watch/enums/kube.py +0 -0
  11. {kubernetes_watch-0.1.12 → kubernetes_watch-0.1.13}/kube_watch/enums/logic.py +0 -0
  12. {kubernetes_watch-0.1.12 → kubernetes_watch-0.1.13}/kube_watch/enums/providers.py +0 -0
  13. {kubernetes_watch-0.1.12 → kubernetes_watch-0.1.13}/kube_watch/enums/workflow.py +0 -0
  14. {kubernetes_watch-0.1.12 → kubernetes_watch-0.1.13}/kube_watch/models/__init__.py +0 -0
  15. {kubernetes_watch-0.1.12 → kubernetes_watch-0.1.13}/kube_watch/models/common.py +0 -0
  16. {kubernetes_watch-0.1.12 → kubernetes_watch-0.1.13}/kube_watch/models/workflow.py +0 -0
  17. {kubernetes_watch-0.1.12 → kubernetes_watch-0.1.13}/kube_watch/modules/__init__.py +0 -0
  18. {kubernetes_watch-0.1.12 → kubernetes_watch-0.1.13}/kube_watch/modules/clusters/__init__.py +0 -0
  19. {kubernetes_watch-0.1.12 → kubernetes_watch-0.1.13}/kube_watch/modules/database/__init__.py +0 -0
  20. {kubernetes_watch-0.1.12 → kubernetes_watch-0.1.13}/kube_watch/modules/database/model.py +0 -0
  21. {kubernetes_watch-0.1.12 → kubernetes_watch-0.1.13}/kube_watch/modules/database/postgre.py +0 -0
  22. {kubernetes_watch-0.1.12 → kubernetes_watch-0.1.13}/kube_watch/modules/logic/actions.py +0 -0
  23. {kubernetes_watch-0.1.12 → kubernetes_watch-0.1.13}/kube_watch/modules/logic/checks.py +0 -0
  24. {kubernetes_watch-0.1.12 → kubernetes_watch-0.1.13}/kube_watch/modules/logic/load.py +0 -0
  25. {kubernetes_watch-0.1.12 → kubernetes_watch-0.1.13}/kube_watch/modules/logic/merge.py +0 -0
  26. {kubernetes_watch-0.1.12 → kubernetes_watch-0.1.13}/kube_watch/modules/logic/scheduler.py +0 -0
  27. {kubernetes_watch-0.1.12 → kubernetes_watch-0.1.13}/kube_watch/modules/logic/trasnform.py +0 -0
  28. {kubernetes_watch-0.1.12 → kubernetes_watch-0.1.13}/kube_watch/modules/mock/__init__.py +0 -0
  29. {kubernetes_watch-0.1.12 → kubernetes_watch-0.1.13}/kube_watch/modules/mock/mock_generator.py +0 -0
  30. {kubernetes_watch-0.1.12 → kubernetes_watch-0.1.13}/kube_watch/modules/providers/__init__.py +0 -0
  31. {kubernetes_watch-0.1.12 → kubernetes_watch-0.1.13}/kube_watch/modules/providers/aws.py +0 -0
  32. {kubernetes_watch-0.1.12 → kubernetes_watch-0.1.13}/kube_watch/modules/providers/github.py +0 -0
  33. {kubernetes_watch-0.1.12 → kubernetes_watch-0.1.13}/kube_watch/modules/providers/vault.py +0 -0
  34. {kubernetes_watch-0.1.12 → kubernetes_watch-0.1.13}/kube_watch/standalone/metarecogen/ckan_to_gn.py +0 -0
  35. {kubernetes_watch-0.1.12 → kubernetes_watch-0.1.13}/kube_watch/watch/__init__.py +0 -0
  36. {kubernetes_watch-0.1.12 → kubernetes_watch-0.1.13}/kube_watch/watch/helpers.py +0 -0
  37. {kubernetes_watch-0.1.12 → kubernetes_watch-0.1.13}/kube_watch/watch/workflow.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: kubernetes-watch
3
- Version: 0.1.12
3
+ Version: 0.1.13
4
4
  Summary:
5
5
  Author: bmotevalli
6
6
  Author-email: b.motevalli@gmail.com
@@ -146,7 +146,45 @@ def restart_deployment(deployment, namespace):
146
146
  api_response = v1.patch_namespaced_deployment(name=deployment, namespace=namespace, body=body)
147
147
  logger.info(f"Deployment restarted. Name: {api_response.metadata.name}")
148
148
  except ApiException as e:
149
- logger.error(f"Exception when restarting deployment: {e}")
149
+ logger.error(
150
+ "Failed to restart deployment. status=%s reason=%s body=%s",
151
+ e.status, e.reason, e.body
152
+ )
153
+ # raise
154
+
155
+ except Exception as e:
156
+ # Everything else: connectivity, config, TLS, serialization, etc.
157
+ logger.error("Unexpected error restarting deployment: %s", e)
158
+ # raise
159
+
160
+
161
+ def delete_pod(namespace: str, label_selector: str, max_pods: int | None = 1):
162
+ v1 = client.CoreV1Api()
163
+
164
+
165
+ pods = v1.list_namespaced_pod(namespace=namespace, label_selector=label_selector)
166
+ if not pods.items:
167
+ raise RuntimeError(f"No pods found for selector: {label_selector}")
168
+
169
+
170
+ for i, pod in enumerate(pods.items):
171
+ if max_pods is not None and i >= max_pods:
172
+ break
173
+
174
+ try:
175
+ v1.delete_namespaced_pod(
176
+ name=pod,
177
+ namespace=namespace,
178
+ grace_period_seconds=30,
179
+ )
180
+ print(f"Deleted pod: {pod}")
181
+ except ApiException as e:
182
+ logger.error(f"Failed to delete pod: status={e.status} reason={e.reason} body={e.body}")
183
+ # raise
184
+ except Exception as e:
185
+ # Everything else: connectivity, config, TLS, serialization, etc.
186
+ logger.error("Unexpected error restarting deployment: %s", e)
187
+ # raise
150
188
 
151
189
 
152
190
  def has_mismatch_image_digest(repo_digest, label_selector, namespace):
@@ -0,0 +1,309 @@
1
+ import os
2
+ import shutil
3
+ import time
4
+ import subprocess
5
+ import tempfile
6
+ from pathlib import Path
7
+
8
+ import jwt
9
+ import requests
10
+ from git import Repo
11
+
12
+ from prefect import get_run_logger
13
+
14
+ logger = get_run_logger()
15
+
16
+
17
+ def is_ssh_clone(git_method: str) -> bool:
18
+ """ Determine if the git clone method is SSH based on the provided method string. """
19
+ result = git_method.lower() == 'ssh'
20
+ logger.info(f"Checking if git method '{git_method}' is SSH: {result}")
21
+ return result
22
+
23
+ def is_pat_clone(git_method: str) -> bool:
24
+ """ Determine if the git clone method is PAT based on the provided method string. """
25
+ result = git_method.lower() == 'pat'
26
+ logger.info(f"Checking if git method '{git_method}' is PAT: {result}")
27
+ return result
28
+
29
+ def is_gh_apps_clone(git_method: str) -> bool:
30
+ """ Determine if the git clone method is GitHub App based on the provided method string. """
31
+ result = git_method.lower() == 'apps'
32
+ logger.info(f"Checking if git method '{git_method}' is GitHub Apps: {result}")
33
+ return result
34
+
35
+
36
+ def clone_repo_pat(git_pat, git_url, clone_base_path):
37
+ """ Clone a Git repository using a Personal Access Token (PAT) for authentication."""
38
+ logger.info(f"Starting PAT-based git clone for URL: {git_url}")
39
+ logger.info(f"Clone base path: {clone_base_path}")
40
+
41
+ # Retrieve environment variables
42
+ access_token = git_pat # os.environ.get('GIT_PAT')
43
+ repo_url = git_url # os.environ.get('GIT_URL')
44
+
45
+ if not access_token or not repo_url:
46
+ logger.error("Missing required parameters: git_pat or git_url")
47
+ raise ValueError("Environment variables GIT_PAT or GIT_URL are not set")
48
+
49
+ # Correctly format the URL with the PAT
50
+ logger.info("Formatting URL with PAT authentication")
51
+ if 'https://' in repo_url:
52
+ # Splitting the URL and inserting the PAT
53
+ parts = repo_url.split('https://', 1)
54
+ repo_url = f'https://{access_token}@{parts[1]}'
55
+ logger.info("Successfully formatted URL with PAT")
56
+ else:
57
+ logger.error(f"Invalid URL format: {repo_url}. Must begin with https://")
58
+ raise ValueError("URL must begin with https:// for PAT authentication")
59
+
60
+ # Directory where the repo will be cloned
61
+ repo_path = os.path.join(clone_base_path, 'manifest-repo')
62
+ logger.info(f"Target repository path: {repo_path}")
63
+
64
+ # Clone the repository
65
+ if not os.path.exists(repo_path):
66
+ logger.info(f"Cloning repository into {repo_path}")
67
+ repo = Repo.clone_from(repo_url, repo_path)
68
+ logger.info("Repository cloned successfully.")
69
+ else:
70
+ logger.info(f"Repository already exists at {repo_path}")
71
+
72
+
73
+ def clone_repo_ssh(
74
+ git_url: str,
75
+ clone_base_path: str,
76
+ repo_dir_name: str = "manifest-repo",
77
+ depth: int = 1,
78
+ ssh_key_env: str = "GIT_SSH_PRIVATE_KEY",
79
+ known_hosts_env: str = "GIT_SSH_KNOWN_HOSTS",
80
+ ) -> Path:
81
+ """
82
+ Clone/update a repo via SSH using key + known_hosts from env vars.
83
+ - GIT_SSH_PRIVATE_KEY: full private key (BEGIN/END ...)
84
+ - GIT_SSH_KNOWN_HOSTS: lines from `ssh-keyscan github.com`
85
+ """
86
+ logger.info(f"Starting SSH-based git clone for URL: {git_url}")
87
+ logger.info(f"Clone base path: {clone_base_path}, repo directory: {repo_dir_name}")
88
+ logger.info(f"Clone depth: {depth}")
89
+
90
+ if not git_url.startswith("git@"):
91
+ logger.error(f"Invalid SSH URL format: {git_url}")
92
+ raise ValueError("git_url must be an SSH URL like 'git@github.com:org/repo.git'")
93
+
94
+ logger.info(f"Reading SSH credentials from environment variables: {ssh_key_env}, {known_hosts_env}")
95
+ priv_key = os.environ.get(ssh_key_env)
96
+ kh_data = os.environ.get(known_hosts_env)
97
+ if not priv_key:
98
+ logger.error(f"Missing SSH private key environment variable: {ssh_key_env}")
99
+ raise ValueError(f"Missing env var {ssh_key_env}")
100
+ if not kh_data:
101
+ logger.error(f"Missing SSH known hosts environment variable: {known_hosts_env}")
102
+ raise ValueError(f"Missing env var {known_hosts_env}")
103
+
104
+ base = Path(clone_base_path).expanduser().resolve()
105
+ base.mkdir(parents=True, exist_ok=True)
106
+ repo_path = base / repo_dir_name
107
+ logger.info(f"Target repository path: {repo_path}")
108
+
109
+ tmpdir = Path(tempfile.mkdtemp(prefix="git_ssh_"))
110
+ key_path = tmpdir / "id_rsa"
111
+ kh_path = tmpdir / "known_hosts"
112
+ logger.info(f"Created temporary directory for SSH keys: {tmpdir}")
113
+
114
+ try:
115
+ key_path.write_text(priv_key, encoding="utf-8")
116
+ kh_path.write_text(kh_data, encoding="utf-8")
117
+ try:
118
+ os.chmod(key_path, 0o600)
119
+ except PermissionError:
120
+ pass # ignore on platforms where chmod doesn't apply
121
+
122
+ # Note: no StrictModes here
123
+ ssh_cmd = (
124
+ f"ssh -i {key_path} -o IdentitiesOnly=yes "
125
+ f"-o UserKnownHostsFile={kh_path} "
126
+ f"-o StrictHostKeyChecking=yes"
127
+ )
128
+
129
+ env = os.environ.copy()
130
+ env["GIT_SSH_COMMAND"] = ssh_cmd
131
+
132
+ if not repo_path.exists():
133
+ logger.info(f"Repository doesn't exist, performing fresh clone")
134
+ cmd = ["git", "clone"]
135
+ if depth and depth > 0:
136
+ cmd += ["--depth", str(depth)]
137
+ cmd += [git_url, str(repo_path)]
138
+ logger.info(f"Executing git clone command: {' '.join(cmd[:-1])} <repo_path>")
139
+ subprocess.check_call(cmd, env=env)
140
+ logger.info("Git clone completed successfully")
141
+ else:
142
+ logger.info(f"Repository already exists at {repo_path}, updating existing repo")
143
+ if not (repo_path / ".git").exists():
144
+ logger.error(f"Path exists but is not a git repository: {repo_path}")
145
+ raise RuntimeError(f"Path exists but is not a git repo: {repo_path}")
146
+ logger.info("Updating remote URL")
147
+ subprocess.check_call(["git", "remote", "set-url", "origin", git_url], cwd=repo_path, env=env)
148
+ logger.info("Fetching latest changes")
149
+ subprocess.check_call(["git", "fetch", "--all", "--prune"], cwd=repo_path, env=env)
150
+ logger.info("Pulling latest changes")
151
+ subprocess.check_call(["git", "pull", "--ff-only", "origin"], cwd=repo_path, env=env)
152
+ logger.info("Repository update completed successfully")
153
+
154
+ logger.info(f"SSH clone operation completed, returning path: {repo_path}")
155
+ return repo_path
156
+
157
+ finally:
158
+ try:
159
+ shutil.rmtree(tmpdir)
160
+ except Exception:
161
+ pass
162
+
163
+
164
+
165
+ def _ghapp_installation_token(app_id: str, installation_id: str, private_key_pem: str) -> str:
166
+ """Create an App-signed JWT and exchange it for a short-lived installation token (~1h)."""
167
+ logger.info(f"Creating GitHub App installation token for app_id: {app_id}, installation_id: {installation_id}")
168
+
169
+ now = int(time.time())
170
+ payload = {"iat": now - 60, "exp": now + 9 * 60, "iss": app_id} # JWT max 10m
171
+ app_jwt = jwt.encode(payload, private_key_pem, algorithm="RS256")
172
+ logger.info("JWT token created successfully")
173
+
174
+ headers = {"Authorization": f"Bearer {app_jwt}", "Accept": "application/vnd.github+json"}
175
+ logger.info("Requesting installation access token from GitHub API")
176
+ resp = requests.post(
177
+ f"https://api.github.com/app/installations/{installation_id}/access_tokens",
178
+ headers=headers,
179
+ timeout=20,
180
+ )
181
+ resp.raise_for_status()
182
+ logger.info("Installation access token retrieved successfully")
183
+ return resp.json()["token"] # valid ~1 hour
184
+
185
+
186
+ def _to_https_url(git_url: str) -> str:
187
+ """
188
+ Accepts either SSH ('git@github.com:org/repo.git') or HTTPS.
189
+ Returns HTTPS form required for GitHub App tokens:
190
+ 'https://github.com/org/repo.git'
191
+ """
192
+ logger.info(f"Converting git URL to HTTPS format: {git_url}")
193
+
194
+ if git_url.startswith("git@github.com:"):
195
+ org_repo = git_url.split("git@github.com:", 1)[1]
196
+ https_url = f"https://github.com/{org_repo}"
197
+ logger.info(f"Converted SSH URL to HTTPS: {https_url}")
198
+ return https_url
199
+ if git_url.startswith("https://"):
200
+ logger.info("URL is already in HTTPS format")
201
+ return git_url
202
+
203
+ logger.error(f"Invalid git URL format: {git_url}")
204
+ raise ValueError("git_url must be an SSH url for github.com or an https:// URL")
205
+
206
+
207
+ def clone_repo_github_app(
208
+ git_url: str,
209
+ clone_base_path: str,
210
+ repo_dir_name: str = "manifest-repo",
211
+ depth: int = 1,
212
+ app_id_env: str = "GITHUB_APP_ID",
213
+ installation_id_env: str = "GITHUB_INSTALLATION_ID",
214
+ private_key_env: str = "GITHUB_APP_PRIVATE_KEY",
215
+ ) -> Path:
216
+ """
217
+ Clone/update a repo using a GitHub App installation token (HTTPS).
218
+
219
+ Env vars required:
220
+ - GITHUB_APP_ID : the App ID (numeric string)
221
+ - GITHUB_INSTALLATION_ID : the installation ID (numeric string)
222
+ - GITHUB_APP_PRIVATE_KEY : the App private key PEM (BEGIN/END ...)
223
+
224
+ Notes:
225
+ - Token is minted on the fly and embedded in the clone URL:
226
+ https://x-access-token:<token>@github.com/org/repo.git
227
+ - No SSH keys / known_hosts needed.
228
+ """
229
+ logger.info(f"Starting GitHub App-based git clone for URL: {git_url}")
230
+ logger.info(f"Clone base path: {clone_base_path}, repo directory: {repo_dir_name}")
231
+ logger.info(f"Clone depth: {depth}")
232
+
233
+ logger.info(f"Reading GitHub App credentials from environment variables: {app_id_env}, {installation_id_env}, {private_key_env}")
234
+ app_id = os.environ.get(app_id_env)
235
+ inst_id = os.environ.get(installation_id_env)
236
+ pem = os.environ.get(private_key_env)
237
+
238
+ if not app_id or not inst_id or not pem:
239
+ logger.error(f"Missing GitHub App environment variables. Required: {app_id_env}, {installation_id_env}, {private_key_env}")
240
+ raise ValueError(f"Missing one of env vars: {app_id_env}, {installation_id_env}, {private_key_env}")
241
+
242
+ https_url = _to_https_url(git_url)
243
+ logger.info("Obtaining GitHub App installation token")
244
+ token = _ghapp_installation_token(app_id, inst_id, pem)
245
+
246
+ # Build an authenticated URL (avoids credential helpers)
247
+ authed_url = f"https://x-access-token:{token}@{https_url.split('https://',1)[1]}"
248
+ logger.info("Successfully created authenticated URL with installation token")
249
+
250
+ base = Path(clone_base_path).expanduser().resolve()
251
+ base.mkdir(parents=True, exist_ok=True)
252
+ repo_path = base / repo_dir_name
253
+ logger.info(f"Target repository path: {repo_path}")
254
+
255
+ # Ensure git won't prompt for creds in CI
256
+ env = os.environ.copy()
257
+ env.setdefault("GIT_TERMINAL_PROMPT", "0")
258
+ env.setdefault("GCM_INTERACTIVE", "Never") # in case Git Credential Manager is present
259
+
260
+ if not repo_path.exists():
261
+ logger.info(f"Repository doesn't exist, performing fresh clone")
262
+ cmd = ["git", "clone"]
263
+ if depth and depth > 0:
264
+ cmd += ["--depth", str(depth)]
265
+ cmd += [authed_url, str(repo_path)]
266
+ logger.info(f"Executing git clone command with GitHub App authentication")
267
+ subprocess.check_call(cmd, env=env)
268
+ logger.info("Git clone completed successfully")
269
+ else:
270
+ logger.info(f"Repository already exists at {repo_path}, updating existing repo")
271
+ if not (repo_path / ".git").exists():
272
+ logger.error(f"Path exists but is not a git repository: {repo_path}")
273
+ raise RuntimeError(f"Path exists but is not a git repo: {repo_path}")
274
+ # Refresh remote URL & pull default branch
275
+ logger.info("Updating remote URL with new GitHub App token")
276
+ subprocess.check_call(["git", "remote", "set-url", "origin", authed_url], cwd=repo_path, env=env)
277
+ logger.info("Fetching latest changes")
278
+ subprocess.check_call(["git", "fetch", "--all", "--prune"], cwd=repo_path, env=env)
279
+ logger.info("Pulling latest changes")
280
+ subprocess.check_call(["git", "pull", "--ff-only", "origin"], cwd=repo_path, env=env)
281
+ logger.info("Repository update completed successfully")
282
+
283
+ logger.info(f"GitHub App clone operation completed, returning path: {repo_path}")
284
+ return repo_path
285
+
286
+
287
+
288
+ def generate_github_creds(
289
+ app_id_env: str = "GITHUB_APP_ID",
290
+ installation_id_env: str = "GITHUB_INSTALLATION_ID",
291
+ private_key_env: str = "GITHUB_APP_PRIVATE_KEY",
292
+ ):
293
+ """
294
+ Generate GitHub credentials using a GitHub App installation token.
295
+ """
296
+ app_id = os.environ.get(app_id_env)
297
+ installation_id = os.environ.get(installation_id_env)
298
+ private_key = os.environ.get(private_key_env)
299
+
300
+ if not app_id or not installation_id or not private_key:
301
+ logger.error("Missing GitHub App environment variables.")
302
+ raise ValueError(f"Missing one of env vars: {app_id_env}, {installation_id_env}, {private_key_env}")
303
+
304
+ token = _ghapp_installation_token(app_id, installation_id, private_key)
305
+
306
+ return {
307
+ "username": "x-access-token",
308
+ "password": token
309
+ }
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "kubernetes-watch"
3
- version = "0.1.12"
3
+ version = "0.1.13"
4
4
  description = ""
5
5
  authors = ["bmotevalli <b.motevalli@gmail.com>"]
6
6
  packages = [{include = "kube_watch"}]
@@ -1,109 +0,0 @@
1
- import os
2
- import shutil
3
- import subprocess
4
- import tempfile
5
- from pathlib import Path
6
-
7
- from prefect import get_run_logger
8
-
9
- logger = get_run_logger()
10
-
11
-
12
- def clone_pat_repo(git_pat, git_url, clone_base_path):
13
- """ Clone a Git repository using a Personal Access Token (PAT) for authentication."""
14
- # Retrieve environment variables
15
- access_token = git_pat # os.environ.get('GIT_PAT')
16
- repo_url = git_url # os.environ.get('GIT_URL')
17
-
18
- if not access_token or not repo_url:
19
- raise ValueError("Environment variables GIT_PAT or GIT_URL are not set")
20
-
21
- # Correctly format the URL with the PAT
22
- if 'https://' in repo_url:
23
- # Splitting the URL and inserting the PAT
24
- parts = repo_url.split('https://', 1)
25
- repo_url = f'https://{access_token}@{parts[1]}'
26
- else:
27
- raise ValueError("URL must begin with https:// for PAT authentication")
28
-
29
- # Directory where the repo will be cloned
30
- repo_path = os.path.join(clone_base_path, 'manifest-repo')
31
-
32
- # Clone the repository
33
- if not os.path.exists(repo_path):
34
- logger.info(f"Cloning repository into {repo_path}")
35
- repo = Repo.clone_from(repo_url, repo_path)
36
- logger.info("Repository cloned successfully.")
37
- else:
38
- logger.info(f"Repository already exists at {repo_path}")
39
-
40
-
41
- def clone_ssh_repo(
42
- git_url: str,
43
- clone_base_path: str,
44
- repo_dir_name: str = "manifest-repo",
45
- depth: int = 1,
46
- ssh_key_env: str = "GIT_SSH_PRIVATE_KEY",
47
- known_hosts_env: str = "GIT_SSH_KNOWN_HOSTS",
48
- ) -> Path:
49
- """
50
- Clone/update a repo via SSH using key + known_hosts from env vars.
51
- - GIT_SSH_PRIVATE_KEY: full private key (BEGIN/END ...)
52
- - GIT_SSH_KNOWN_HOSTS: lines from `ssh-keyscan github.com`
53
- """
54
- if not git_url.startswith("git@"):
55
- raise ValueError("git_url must be an SSH URL like 'git@github.com:org/repo.git'")
56
-
57
- priv_key = os.environ.get(ssh_key_env)
58
- kh_data = os.environ.get(known_hosts_env)
59
- if not priv_key:
60
- raise ValueError(f"Missing env var {ssh_key_env}")
61
- if not kh_data:
62
- raise ValueError(f"Missing env var {known_hosts_env}")
63
-
64
- base = Path(clone_base_path).expanduser().resolve()
65
- base.mkdir(parents=True, exist_ok=True)
66
- repo_path = base / repo_dir_name
67
-
68
- tmpdir = Path(tempfile.mkdtemp(prefix="git_ssh_"))
69
- key_path = tmpdir / "id_rsa"
70
- kh_path = tmpdir / "known_hosts"
71
-
72
- try:
73
- key_path.write_text(priv_key, encoding="utf-8")
74
- kh_path.write_text(kh_data, encoding="utf-8")
75
- try:
76
- os.chmod(key_path, 0o600)
77
- except PermissionError:
78
- pass # ignore on platforms where chmod doesn't apply
79
-
80
- # Note: no StrictModes here
81
- ssh_cmd = (
82
- f"ssh -i {key_path} -o IdentitiesOnly=yes "
83
- f"-o UserKnownHostsFile={kh_path} "
84
- f"-o StrictHostKeyChecking=yes"
85
- )
86
-
87
- env = os.environ.copy()
88
- env["GIT_SSH_COMMAND"] = ssh_cmd
89
-
90
- if not repo_path.exists():
91
- cmd = ["git", "clone"]
92
- if depth and depth > 0:
93
- cmd += ["--depth", str(depth)]
94
- cmd += [git_url, str(repo_path)]
95
- subprocess.check_call(cmd, env=env)
96
- else:
97
- if not (repo_path / ".git").exists():
98
- raise RuntimeError(f"Path exists but is not a git repo: {repo_path}")
99
- subprocess.check_call(["git", "remote", "set-url", "origin", git_url], cwd=repo_path, env=env)
100
- subprocess.check_call(["git", "fetch", "--all", "--prune"], cwd=repo_path, env=env)
101
- subprocess.check_call(["git", "pull", "--ff-only", "origin"], cwd=repo_path, env=env)
102
-
103
- return repo_path
104
-
105
- finally:
106
- try:
107
- shutil.rmtree(tmpdir)
108
- except Exception:
109
- pass