python-rundeck 0.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.
rundeck/config.py ADDED
@@ -0,0 +1,166 @@
1
+ from __future__ import annotations
2
+
3
+ """
4
+ Gestion de la configuration Rundeck (arguments, variables d'env, fichiers).
5
+ """
6
+
7
+ import configparser
8
+ import os
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ from rundeck.const import DEFAULT_API_VERSION, DEFAULT_TIMEOUT, USER_AGENT
13
+
14
+ DEFAULT_CONFIG_FILES: list[str] = [
15
+ "/etc/rundeck.cfg",
16
+ str(Path.home() / ".rundeck.cfg"),
17
+ ]
18
+
19
+
20
+ class RundeckConfigError(Exception):
21
+ """Erreur de configuration Rundeck."""
22
+
23
+
24
+ def _resolve_existing_files(config_files: list[str]) -> list[str]:
25
+ existing: list[str] = []
26
+ for file in config_files:
27
+ path = Path(file).expanduser()
28
+ if path.exists():
29
+ existing.append(str(path.resolve()))
30
+ return existing
31
+
32
+
33
+ def _coerce_timeout(value: Any) -> float:
34
+ try:
35
+ return float(value)
36
+ except (TypeError, ValueError):
37
+ return DEFAULT_TIMEOUT
38
+
39
+
40
+ def _coerce_ssl_verify(value: Any) -> bool | str:
41
+ if isinstance(value, bool):
42
+ return value
43
+ if value is None:
44
+ return True
45
+ text = str(value).strip().lower()
46
+ if text in {"true", "1", "yes", "y"}:
47
+ return True
48
+ if text in {"false", "0", "no", "n"}:
49
+ return False
50
+ # Assume path to CA bundle
51
+ return value
52
+
53
+
54
+ class RundeckConfig:
55
+ """Charge la configuration depuis args/env/fichiers avec priorité décroissante."""
56
+
57
+ def __init__(
58
+ self,
59
+ url: str | None = None,
60
+ token: str | None = None,
61
+ username: str | None = None,
62
+ password: str | None = None,
63
+ api_version: int | str | None = None,
64
+ timeout: float | None = None,
65
+ ssl_verify: bool | str | None = None,
66
+ config_files: list[str] | None = None,
67
+ config_section: str = "rundeck",
68
+ ) -> None:
69
+ env_url = os.getenv("RUNDECK_URL")
70
+ env_token = os.getenv("RUNDECK_TOKEN")
71
+ env_username = os.getenv("RUNDECK_USERNAME")
72
+ env_password = os.getenv("RUNDECK_PASSWORD")
73
+ env_api_version = os.getenv("RUNDECK_API_VERSION")
74
+ env_timeout = os.getenv("RUNDECK_TIMEOUT")
75
+ env_ssl_verify = os.getenv("RUNDECK_SSL_VERIFY")
76
+ env_user_agent = os.getenv("RUNDECK_USER_AGENT")
77
+
78
+ files = self._resolve_files(config_files)
79
+ file_conf = self._parse_files(files, config_section) if files else {}
80
+
81
+ self.url: str | None = url or env_url or file_conf.get("url")
82
+ self.token: str | None = token or env_token or file_conf.get("token")
83
+ self.username: str | None = (
84
+ username or env_username or file_conf.get("username")
85
+ )
86
+ self.password: str | None = (
87
+ password or env_password or file_conf.get("password")
88
+ )
89
+ self.api_version: str = str(
90
+ api_version
91
+ or env_api_version
92
+ or file_conf.get("api_version")
93
+ or DEFAULT_API_VERSION
94
+ )
95
+ self.timeout: float = (
96
+ float(timeout)
97
+ if timeout is not None
98
+ else _coerce_timeout(env_timeout or file_conf.get("timeout"))
99
+ )
100
+ self.ssl_verify: bool | str = (
101
+ ssl_verify
102
+ if ssl_verify is not None
103
+ else _coerce_ssl_verify(env_ssl_verify or file_conf.get("ssl_verify"))
104
+ )
105
+ self.user_agent: str = (
106
+ env_user_agent or file_conf.get("user_agent") or USER_AGENT
107
+ )
108
+ self.config_files = files
109
+
110
+ @staticmethod
111
+ def _resolve_files(config_files: list[str] | None) -> list[str]:
112
+ if config_files:
113
+ resolved = _resolve_existing_files(config_files)
114
+ if not resolved:
115
+ raise RundeckConfigError("Aucun fichier de configuration trouvé.")
116
+ return resolved
117
+
118
+ env_cfg = os.getenv("RUNDECK_CFG")
119
+ if env_cfg:
120
+ resolved = _resolve_existing_files([env_cfg])
121
+ if not resolved:
122
+ raise RundeckConfigError(
123
+ f"Fichier de configuration introuvable: {env_cfg}"
124
+ )
125
+ return resolved
126
+
127
+ return _resolve_existing_files(DEFAULT_CONFIG_FILES)
128
+
129
+ @staticmethod
130
+ def _parse_files(
131
+ files: list[str],
132
+ section: str,
133
+ ) -> dict[str, Any]:
134
+ parser = configparser.ConfigParser()
135
+ parser.read(files, encoding="utf-8")
136
+ if not parser.has_section(section):
137
+ return {}
138
+
139
+ def _get(option: str) -> Any:
140
+ try:
141
+ return parser.get(section, option)
142
+ except (configparser.NoSectionError, configparser.NoOptionError):
143
+ return None
144
+
145
+ return {
146
+ "url": _get("url"),
147
+ "token": _get("token"),
148
+ "username": _get("username"),
149
+ "password": _get("password"),
150
+ "api_version": _get("api_version"),
151
+ "timeout": _get("timeout"),
152
+ "ssl_verify": _get("ssl_verify"),
153
+ "user_agent": _get("user_agent"),
154
+ }
155
+
156
+ @classmethod
157
+ def from_config(
158
+ cls, config_section: str | None = None, config_files: list[str] | None = None
159
+ ) -> "RundeckConfig":
160
+ """
161
+ Charge uniquement depuis fichiers/env (sans arguments explicites).
162
+ """
163
+ return cls(
164
+ config_files=config_files,
165
+ config_section=config_section or "rundeck",
166
+ )
rundeck/const.py ADDED
@@ -0,0 +1,45 @@
1
+ """
2
+ Constantes partagées pour le client Rundeck.
3
+ """
4
+
5
+ # Package metadata
6
+ __version__ = "0.1.0"
7
+
8
+ # Auth
9
+ RUNDECK_AUTH_HEADER = "X-Rundeck-Auth-Token"
10
+
11
+ # HTTP methods
12
+ HTTP_GET = "GET"
13
+ HTTP_POST = "POST"
14
+ HTTP_PUT = "PUT"
15
+ HTTP_DELETE = "DELETE"
16
+
17
+ # Content / formats
18
+ FORMAT_JSON = "json"
19
+
20
+ # API versions (cf. docs Rundeck: current 56, minimum 14, dépréciation 17)
21
+ API_VERSION_CURRENT = "56"
22
+ API_VERSION_MIN = "14"
23
+ API_VERSION_DEPRECATION = "17"
24
+
25
+ # Defaults
26
+ DEFAULT_API_VERSION = API_VERSION_CURRENT
27
+ DEFAULT_TIMEOUT = 30
28
+ USER_AGENT = "python-rundeck"
29
+
30
+
31
+ __all__ = [
32
+ "__version__",
33
+ "API_VERSION_CURRENT",
34
+ "API_VERSION_DEPRECATION",
35
+ "API_VERSION_MIN",
36
+ "DEFAULT_API_VERSION",
37
+ "DEFAULT_TIMEOUT",
38
+ "FORMAT_JSON",
39
+ "HTTP_DELETE",
40
+ "HTTP_GET",
41
+ "HTTP_POST",
42
+ "HTTP_PUT",
43
+ "RUNDECK_AUTH_HEADER",
44
+ "USER_AGENT",
45
+ ]
rundeck/exceptions.py ADDED
@@ -0,0 +1,164 @@
1
+ from __future__ import annotations
2
+
3
+ """
4
+ Exceptions et helpers pour l'API Rundeck.
5
+ """
6
+
7
+ from typing import Any
8
+
9
+ import requests
10
+
11
+
12
+ class RundeckError(Exception):
13
+ """Exception de base pour toutes les erreurs Rundeck."""
14
+
15
+ def __init__(
16
+ self,
17
+ message: str = "",
18
+ error_code: str | None = None,
19
+ response: requests.Response | None = None,
20
+ ) -> None:
21
+ super().__init__(message)
22
+ self.message = message
23
+ self.error_code = error_code
24
+ self.response = response
25
+ self.response_code = response.status_code if response is not None else None
26
+
27
+ # Stocke le corps de la réponse (JSON ou texte brut)
28
+ if response is None or not response.text:
29
+ self.response_body: Any | None = None
30
+ else:
31
+ try:
32
+ self.response_body = response.json()
33
+ except ValueError:
34
+ self.response_body = response.text
35
+
36
+ def __str__(self) -> str:
37
+ code = f"{self.response_code}: " if self.response_code is not None else ""
38
+ error = f" ({self.error_code})" if self.error_code else ""
39
+ return f"{code}{self.message}{error}"
40
+
41
+
42
+ class RundeckHTTPError(RundeckError):
43
+ """Erreur HTTP générique renvoyée par Rundeck."""
44
+
45
+
46
+ class RundeckOperationError(RundeckHTTPError):
47
+ """Erreur lors d'une opération métier."""
48
+
49
+
50
+ class RundeckAuthenticationError(RundeckOperationError):
51
+ """Authentification ou autorisation refusée."""
52
+
53
+
54
+ class RundeckNotFoundError(RundeckOperationError):
55
+ """Ressource introuvable."""
56
+
57
+
58
+ class RundeckValidationError(RundeckOperationError):
59
+ """Entrée invalide (erreur 400)."""
60
+
61
+
62
+ class RundeckApiVersionUnsupportedError(RundeckValidationError):
63
+ """Version d'API non supportée pour la ressource demandée."""
64
+
65
+
66
+ class RundeckConflictError(RundeckOperationError):
67
+ """Conflit (erreur 409)."""
68
+
69
+
70
+ class RundeckQuotaExceededError(RundeckOperationError):
71
+ """Quota dépassé (erreur 429)."""
72
+
73
+
74
+ class RundeckServerError(RundeckHTTPError):
75
+ """Erreur serveur (5xx)."""
76
+
77
+
78
+ class RundeckConnectionError(RundeckError):
79
+ """Erreur réseau ou connexion refusée."""
80
+
81
+
82
+ class RundeckTimeoutError(RundeckError):
83
+ """Timeout réseau."""
84
+
85
+
86
+ def _extract_error(response: requests.Response) -> tuple[str, str | None, Any | None]:
87
+ """
88
+ Extrait le message et le code d'erreur depuis la réponse HTTP.
89
+ """
90
+ message = f"HTTP {response.status_code}"
91
+ error_code: str | None = None
92
+ body: Any | None = None
93
+
94
+ if response.text:
95
+ try:
96
+ body = response.json()
97
+ except ValueError:
98
+ body = response.text
99
+
100
+ if isinstance(body, dict):
101
+ message = (
102
+ body.get("message")
103
+ or body.get("error")
104
+ or body.get("errorMessage")
105
+ or message
106
+ )
107
+ error_code = body.get("errorCode") or body.get("error_code")
108
+ elif isinstance(body, str) and body.strip():
109
+ message = body.strip()
110
+
111
+ return message, error_code, body
112
+
113
+
114
+ def raise_for_status(response: requests.Response) -> None:
115
+ """
116
+ Lève une exception spécialisée en fonction du code HTTP.
117
+ """
118
+ message, error_code, body = _extract_error(response)
119
+ status = response.status_code
120
+ code_lower = error_code.lower() if isinstance(error_code, str) else None
121
+
122
+ # Certains retours peuvent être HTTP 200 mais contenir un errorCode explicite
123
+ if (
124
+ response.ok
125
+ and not code_lower
126
+ and not (isinstance(body, dict) and body.get("error") is True)
127
+ ):
128
+ return
129
+
130
+ if code_lower == "unauthorized":
131
+ raise RundeckAuthenticationError(message, error_code, response)
132
+ if code_lower == "api-version-unsupported":
133
+ raise RundeckApiVersionUnsupportedError(message, error_code, response)
134
+ if status == 400:
135
+ raise RundeckValidationError(message, error_code, response)
136
+ if status in (401, 403):
137
+ raise RundeckAuthenticationError(message, error_code, response)
138
+ if status == 404:
139
+ raise RundeckNotFoundError(message, error_code, response)
140
+ if status == 409:
141
+ raise RundeckConflictError(message, error_code, response)
142
+ if status == 429:
143
+ raise RundeckQuotaExceededError(message, error_code, response)
144
+ if status >= 500:
145
+ raise RundeckServerError(message, error_code, response)
146
+
147
+ raise RundeckHTTPError(message, error_code, response)
148
+
149
+
150
+ __all__ = [
151
+ "RundeckApiVersionUnsupportedError",
152
+ "RundeckAuthenticationError",
153
+ "RundeckConflictError",
154
+ "RundeckConnectionError",
155
+ "RundeckError",
156
+ "RundeckHTTPError",
157
+ "RundeckNotFoundError",
158
+ "RundeckOperationError",
159
+ "RundeckQuotaExceededError",
160
+ "RundeckServerError",
161
+ "RundeckTimeoutError",
162
+ "RundeckValidationError",
163
+ "raise_for_status",
164
+ ]
rundeck/py.typed ADDED
@@ -0,0 +1 @@
1
+ # Marker file for PEP 561 type hints.
rundeck/v1/__init__.py ADDED
@@ -0,0 +1 @@
1
+ # Package v1
@@ -0,0 +1 @@
1
+ # Package v1.objects
@@ -0,0 +1,93 @@
1
+ """
2
+ Exécution AdHoc (commandes, scripts, URLs) pour un projet.
3
+ """
4
+
5
+ from typing import Any
6
+
7
+ from rundeck.base import RundeckObjectManager
8
+ from rundeck.v1.objects.executions import Execution
9
+
10
+
11
+ class AdhocManager(RundeckObjectManager[Execution]):
12
+ """Manager pour les exécutions AdHoc d'un projet."""
13
+
14
+ _obj_cls = Execution
15
+
16
+ def _project_name(self, project: str | None) -> str:
17
+ project_name = project or (self.parent.id if self.parent else None)
18
+ if not project_name:
19
+ raise ValueError("Le nom du projet est requis pour les commandes AdHoc")
20
+ return project_name
21
+
22
+ def _wrap_execution(self, result: Any, refresh: bool = True) -> Execution:
23
+ data = (
24
+ result.get("execution")
25
+ if isinstance(result, dict) and "execution" in result
26
+ else result
27
+ )
28
+ if isinstance(data, dict):
29
+ exec_id = data.get("id")
30
+ # Les réponses AdHoc ne sont pas complètes,
31
+ # Le rafraîchissement permet d'obtenir toutes les données
32
+ if refresh and exec_id:
33
+ try:
34
+ return self.rd.executions.get(exec_id)
35
+ except Exception:
36
+ return Execution(self.rd.executions, data)
37
+ return Execution(self.rd.executions, data)
38
+
39
+ def run_command(
40
+ self,
41
+ exec: str,
42
+ project: str | None = None,
43
+ refresh: bool = True,
44
+ **options: Any,
45
+ ) -> Execution:
46
+ """Exécute une commande shell AdHoc."""
47
+ project_name = self._project_name(project)
48
+ payload: dict[str, Any] = {"exec": exec, "project": project_name}
49
+ payload.update(options)
50
+ path = f"/project/{project_name}/run/command"
51
+ result = self.rd.http_post(path, json=payload)
52
+ return self._wrap_execution(result, refresh=refresh)
53
+
54
+ def run_script(
55
+ self,
56
+ script: str | None = None,
57
+ project: str | None = None,
58
+ refresh: bool = True,
59
+ script_file: tuple[str, Any, str | None] | None = None,
60
+ **options: Any,
61
+ ) -> Execution:
62
+ """Exécute un script AdHoc (contenu inline ou multipart)."""
63
+ project_name = self._project_name(project)
64
+ path = f"/project/{project_name}/run/script"
65
+ if script_file:
66
+ filename, content, content_type = script_file
67
+ payload: dict[str, Any] = {"project": project_name}
68
+ payload.update(options)
69
+ files = {"scriptFile": (filename, content, content_type or "text/plain")}
70
+ result = self.rd.http_post(path, data=payload, files=files)
71
+ else:
72
+ payload: dict[str, Any] = {"script": script or "", "project": project_name}
73
+ payload.update(options)
74
+ result = self.rd.http_post(path, json=payload)
75
+ return self._wrap_execution(result, refresh=refresh)
76
+
77
+ def run_url(
78
+ self,
79
+ script_url: str,
80
+ project: str | None = None,
81
+ refresh: bool = True,
82
+ **options: Any,
83
+ ) -> Execution:
84
+ """Exécute un script AdHoc depuis une URL."""
85
+ project_name = self._project_name(project)
86
+ payload: dict[str, Any] = {"scriptURL": script_url, "project": project_name}
87
+ payload.update(options)
88
+ path = f"/project/{project_name}/run/url"
89
+ result = self.rd.http_post(path, json=payload)
90
+ return self._wrap_execution(result, refresh=refresh)
91
+
92
+
93
+ __all__ = ["AdhocManager"]
@@ -0,0 +1,51 @@
1
+ from __future__ import annotations
2
+
3
+ """
4
+ Gestion de la configuration Rundeck (instance globale).
5
+ """
6
+
7
+ from rundeck.base import RundeckObjectManager
8
+
9
+
10
+ class ConfigManagementManager(RundeckObjectManager):
11
+ """
12
+ Gestion des configurations globales (plugin/custom) via /config.
13
+ """
14
+
15
+ _path = "/config"
16
+
17
+ def list(self) -> list[dict[str, str]]:
18
+ """Liste toutes les configs et propriétés."""
19
+ path = self._build_path("list")
20
+ return self.rd.http_get(path)
21
+
22
+ def save(self, entries: list[dict[str, str]]) -> dict[str, str | list[str]]:
23
+ """
24
+ Crée ou met à jour des configs (payload: liste de dicts {key, value, strata?}).
25
+ """
26
+ path = self._build_path("save")
27
+ return self.rd.http_post(path, json=entries)
28
+
29
+ def delete(self, key: str, strata: str | None = None) -> None:
30
+ """
31
+ Supprime une config via /config/delete avec payload JSON.
32
+ """
33
+ payload: dict[str, str] = {"key": key}
34
+ if strata:
35
+ payload["strata"] = strata
36
+ path = self._build_path("delete")
37
+ self.rd.http_delete(path, json=payload)
38
+
39
+ def refresh(self) -> dict[str, str]:
40
+ """
41
+ Recharge les configurations depuis les fichiers de propriétés.
42
+ """
43
+ path = self._build_path("refresh")
44
+ return self.rd.http_post(path)
45
+
46
+ def restart(self) -> dict[str, str]:
47
+ """
48
+ Redémarre le serveur Rundeck.
49
+ """
50
+ path = self._build_path("restart")
51
+ return self.rd.http_post(path)