github-actions-ingester 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.
@@ -0,0 +1,9 @@
1
+ """GitHub Actions ingester.
2
+
3
+ Pulls repositories, workflows, workflow runs and jobs from the GitHub REST
4
+ API into PostgreSQL on a fixed interval, keeps the schema up to date on
5
+ its own, and exposes Prometheus metrics about the ingestion itself and
6
+ about the liveness of scheduled workflows.
7
+ """
8
+
9
+ __version__ = "0.1.0"
@@ -0,0 +1,330 @@
1
+ """Entry point — CLI with a handful of subcommands.
2
+
3
+ github-actions-ingester run forever (default)
4
+ github-actions-ingester run --once one cycle, then exit
5
+ github-actions-ingester migrate bootstrap/upgrade the schema only
6
+ github-actions-ingester check validate config, GitHub auth, DB
7
+ github-actions-ingester app-manifest print the GitHub App manifest
8
+ github-actions-ingester app-convert X exchange a manifest code for creds
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import argparse
14
+ import json
15
+ import logging
16
+ import signal
17
+ import sys
18
+ import threading
19
+ from pathlib import Path
20
+ from types import FrameType
21
+
22
+ import structlog
23
+ from pydantic import ValidationError
24
+
25
+ from . import __version__
26
+ from .app_manifest import convert_code, manifest_form_url, manifest_json, write_private_key
27
+ from .collector import Collector
28
+ from .config import Settings, load_settings
29
+ from .github import AppAuth, GitHubAPIError, GitHubClient, TokenAuth
30
+ from .metrics import Metrics
31
+ from .ratelimit import RateLimiter
32
+ from .server import MetricsServer
33
+ from .store import Store
34
+
35
+
36
+ def _setup_logging(level: str, fmt: str) -> None:
37
+ log_level = getattr(logging, level.upper(), logging.INFO)
38
+ logging.basicConfig(level=log_level, format="%(message)s", stream=sys.stderr)
39
+ # httpx logs one INFO line per request; the client has its own metrics
40
+ # and structured events for that, so keep the library quiet unless
41
+ # debugging.
42
+ for name in ("httpx", "httpcore"):
43
+ logging.getLogger(name).setLevel(max(log_level, logging.WARNING))
44
+ processors: list[structlog.types.Processor] = [
45
+ structlog.contextvars.merge_contextvars,
46
+ structlog.processors.add_log_level,
47
+ structlog.processors.TimeStamper(fmt="iso", utc=True),
48
+ ]
49
+ if fmt == "json":
50
+ processors.append(structlog.processors.JSONRenderer())
51
+ else:
52
+ processors.append(structlog.dev.ConsoleRenderer(colors=sys.stderr.isatty()))
53
+ structlog.configure(
54
+ processors=processors,
55
+ wrapper_class=structlog.make_filtering_bound_logger(log_level),
56
+ logger_factory=structlog.PrintLoggerFactory(file=sys.stderr),
57
+ # Not cached: a reconfigured stream (tests, supervisors that swap
58
+ # stderr) must be picked up; the volume here is far too low to matter.
59
+ cache_logger_on_first_use=False,
60
+ )
61
+
62
+
63
+ def _build_client(settings: Settings, metrics: Metrics | None = None) -> GitHubClient:
64
+ auth: TokenAuth | AppAuth
65
+ if settings.uses_app():
66
+ orgs = settings.org_list()
67
+ auth = AppAuth(
68
+ app_id=settings.github_app_id,
69
+ private_key_pem=settings.app_private_key_pem(),
70
+ installation_id=settings.github_app_installation_id,
71
+ preferred_owner=orgs[0] if orgs else "",
72
+ )
73
+ else:
74
+ auth = TokenAuth(settings.github_token)
75
+
76
+ def on_request(status: int) -> None:
77
+ if metrics is not None:
78
+ metrics.github_requests_total.labels(status=str(status)).inc()
79
+
80
+ return GitHubClient(
81
+ auth=auth,
82
+ base_url=settings.github_api_base,
83
+ timeout=settings.api_timeout_seconds,
84
+ limiter=RateLimiter(settings.api_rate_limit_rps),
85
+ min_remaining=settings.api_min_remaining,
86
+ max_retries=settings.api_max_retries,
87
+ on_request=on_request,
88
+ )
89
+
90
+
91
+ def _load(log_to_console: bool = False) -> Settings | None:
92
+ try:
93
+ settings = load_settings()
94
+ except ValidationError as exc:
95
+ sys.stderr.write(f"configuration error:\n{exc}\n")
96
+ return None
97
+ _setup_logging(settings.log_level, "console" if log_to_console else settings.log_format)
98
+ return settings
99
+
100
+
101
+ # ---------------------------------------------------------------------------
102
+ # Subcommands
103
+ # ---------------------------------------------------------------------------
104
+
105
+
106
+ def cmd_run(once: bool) -> int:
107
+ settings = _load()
108
+ if settings is None:
109
+ return 2
110
+ log = structlog.get_logger("github_actions_ingester")
111
+ log.info(
112
+ "ingester.start",
113
+ version=__version__,
114
+ auth="app" if settings.uses_app() else "token",
115
+ orgs=settings.org_list(),
116
+ repos=settings.repo_list(),
117
+ poll_interval=settings.poll_interval_seconds,
118
+ backfill_days=settings.backfill_days,
119
+ listen=f"{settings.listen_host}:{settings.listen_port}",
120
+ )
121
+
122
+ metrics = Metrics()
123
+ metrics.build_info.info({"version": __version__, "python": sys.version.split()[0]})
124
+ metrics.up.set(0)
125
+ metrics.ready.set(0)
126
+
127
+ store = Store(
128
+ settings.database_url,
129
+ settings.database_schema,
130
+ settings.database_connect_timeout_seconds,
131
+ )
132
+ ready = threading.Event()
133
+ server = MetricsServer(
134
+ settings.listen_host, settings.listen_port, metrics.registry, ready.is_set
135
+ )
136
+ server.start()
137
+
138
+ stop = threading.Event()
139
+
140
+ def _handle_signal(signum: int, _frame: FrameType | None) -> None:
141
+ log.info("ingester.signal", signal=signal.Signals(signum).name)
142
+ stop.set()
143
+
144
+ signal.signal(signal.SIGTERM, _handle_signal)
145
+ signal.signal(signal.SIGINT, _handle_signal)
146
+
147
+ exit_code = 0
148
+ client: GitHubClient | None = None
149
+ try:
150
+ # Bootstrap the schema before anything else; retry while the
151
+ # database is not there yet (rollouts, fresh clusters).
152
+ while not stop.is_set():
153
+ try:
154
+ store.migrate()
155
+ break
156
+ except Exception as exc:
157
+ log.error("store.bootstrap_failed", error=str(exc), retry_in=15)
158
+ metrics.errors_total.labels(stage="bootstrap").inc()
159
+ if once:
160
+ return 1
161
+ stop.wait(15)
162
+ if stop.is_set():
163
+ return 0
164
+ client = _build_client(settings, metrics)
165
+ collector = Collector(client, store, metrics, settings)
166
+ if once:
167
+ result = collector.run_cycle()
168
+ ready.set()
169
+ exit_code = 0 if result != "error" else 1
170
+ else:
171
+ first = threading.Thread(
172
+ target=lambda: collector.run_forever(stop), name="ingest", daemon=True
173
+ )
174
+ first.start()
175
+ # Readiness flips after the first cycle, whatever its outcome:
176
+ # the schema is up and metrics are meaningful from here on.
177
+ while not stop.is_set() and collector.cycles == 0:
178
+ stop.wait(1)
179
+ ready.set()
180
+ while not stop.is_set():
181
+ stop.wait(1)
182
+ first.join(timeout=30)
183
+ finally:
184
+ server.stop()
185
+ if client is not None:
186
+ client.close()
187
+ store.close()
188
+ log.info("ingester.stopped")
189
+ return exit_code
190
+
191
+
192
+ def cmd_migrate() -> int:
193
+ settings = _load(log_to_console=True)
194
+ if settings is None:
195
+ return 2
196
+ store = Store(
197
+ settings.database_url,
198
+ settings.database_schema,
199
+ settings.database_connect_timeout_seconds,
200
+ )
201
+ try:
202
+ report = store.migrate()
203
+ finally:
204
+ store.close()
205
+ print(
206
+ f"schema {settings.database_schema}: version {report.current_version}, "
207
+ f"applied now: {report.applied or 'nothing'}"
208
+ )
209
+ return 0
210
+
211
+
212
+ def cmd_check() -> int:
213
+ settings = _load(log_to_console=True)
214
+ if settings is None:
215
+ return 2
216
+ ok = True
217
+ store = Store(
218
+ settings.database_url,
219
+ settings.database_schema,
220
+ settings.database_connect_timeout_seconds,
221
+ )
222
+ try:
223
+ version = store.schema_version()
224
+ print(f"database: ok (schema {settings.database_schema} version {version})")
225
+ except Exception as exc:
226
+ print(f"database: FAILED ({exc})")
227
+ ok = False
228
+ finally:
229
+ store.close()
230
+
231
+ client = _build_client(settings)
232
+ try:
233
+ if settings.uses_app():
234
+ repos = list(client.list_installation_repositories())
235
+ print(f"github: ok (App, installation sees {len(repos)} repositories)")
236
+ else:
237
+ for org in settings.org_list():
238
+ n = sum(1 for _ in client.list_org_repositories(org))
239
+ print(f"github: ok (token, org {org}: {n} repositories)")
240
+ for full_name in settings.repo_list():
241
+ client.get_repository(full_name)
242
+ print(f"github: ok (token, repo {full_name})")
243
+ rl = client.rate_limit
244
+ print(f"rate limit: {rl.remaining}/{rl.limit}")
245
+ except GitHubAPIError as exc:
246
+ print(f"github: FAILED ({exc})")
247
+ ok = False
248
+ finally:
249
+ client.close()
250
+ return 0 if ok else 1
251
+
252
+
253
+ def cmd_app_manifest(org: str, redirect_url: str, name: str) -> int:
254
+ print(manifest_json(name=name, redirect_url=redirect_url))
255
+ sys.stderr.write(f"\nPOST this manifest (form field `manifest`) to {manifest_form_url(org)}\n")
256
+ return 0
257
+
258
+
259
+ def cmd_app_convert(code: str, key_file: str, api_base: str) -> int:
260
+ try:
261
+ body = convert_code(code, api_base)
262
+ except RuntimeError as exc:
263
+ sys.stderr.write(f"{exc}\n")
264
+ return 1
265
+ pem = str(body.get("pem", ""))
266
+ target = Path(key_file)
267
+ write_private_key(pem, target)
268
+ summary = {
269
+ "app_id": body.get("id"),
270
+ "client_id": body.get("client_id"),
271
+ "slug": body.get("slug"),
272
+ "html_url": body.get("html_url"),
273
+ "private_key_file": str(target),
274
+ }
275
+ print(json.dumps(summary, indent=2))
276
+ sys.stderr.write(
277
+ "\nNext: install the App on your organization "
278
+ f"({body.get('html_url')}/installations/new), then set\n"
279
+ f" GHA_GITHUB_APP_ID={body.get('id')}\n"
280
+ f" GHA_GITHUB_APP_PRIVATE_KEY_FILE={target}\n"
281
+ )
282
+ return 0
283
+
284
+
285
+ # ---------------------------------------------------------------------------
286
+
287
+
288
+ def build_parser() -> argparse.ArgumentParser:
289
+ parser = argparse.ArgumentParser(
290
+ prog="github-actions-ingester",
291
+ description="Ingest GitHub Actions runs and jobs into PostgreSQL.",
292
+ )
293
+ parser.add_argument("--version", action="version", version=__version__)
294
+ sub = parser.add_subparsers(dest="command")
295
+
296
+ run = sub.add_parser("run", help="run the ingester (default)")
297
+ run.add_argument("--once", action="store_true", help="run a single cycle and exit")
298
+
299
+ sub.add_parser("migrate", help="bootstrap or upgrade the database schema and exit")
300
+ sub.add_parser("check", help="validate configuration, GitHub credentials and database")
301
+
302
+ man = sub.add_parser("app-manifest", help="print the GitHub App manifest JSON")
303
+ man.add_argument("--org", default="", help="organization that will own the App")
304
+ man.add_argument("--redirect-url", default="", help="where GitHub sends the code")
305
+ man.add_argument("--name", default="github-actions-ingester")
306
+
307
+ conv = sub.add_parser("app-convert", help="exchange a manifest code for App credentials")
308
+ conv.add_argument("code")
309
+ conv.add_argument("--key-file", default="github-app.pem")
310
+ conv.add_argument("--api-base", default="https://api.github.com")
311
+ return parser
312
+
313
+
314
+ def main(argv: list[str] | None = None) -> int:
315
+ args = build_parser().parse_args(argv)
316
+ if args.command in (None, "run"):
317
+ return cmd_run(once=bool(getattr(args, "once", False)))
318
+ if args.command == "migrate":
319
+ return cmd_migrate()
320
+ if args.command == "check":
321
+ return cmd_check()
322
+ if args.command == "app-manifest":
323
+ return cmd_app_manifest(args.org, args.redirect_url, args.name)
324
+ if args.command == "app-convert":
325
+ return cmd_app_convert(args.code, args.key_file, args.api_base)
326
+ return 2
327
+
328
+
329
+ if __name__ == "__main__":
330
+ sys.exit(main())
@@ -0,0 +1,97 @@
1
+ """GitHub App Manifest flow helpers.
2
+
3
+ Lets every operator create *their own* GitHub App, inside their own
4
+ organization, with exactly the permissions the ingester needs and no
5
+ private key ever leaving their hands:
6
+
7
+ 1. ``github-actions-ingester app-manifest`` prints the manifest JSON
8
+ (also embedded in ``examples/github-app/create-app.html``, a static
9
+ page that POSTs it to GitHub);
10
+ 2. GitHub creates the App and redirects back with a one-hour ``code``;
11
+ 3. ``github-actions-ingester app-convert <code>`` exchanges it for the
12
+ App ID and the private key (``POST /app-manifests/{code}/conversions``).
13
+
14
+ Reference: https://docs.github.com/en/apps/sharing-github-apps/registering-a-github-app-from-a-manifest
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ from pathlib import Path
21
+ from typing import Any
22
+
23
+ import httpx
24
+
25
+ from .github import API_VERSION
26
+
27
+ PROJECT_URL = "https://github.com/danielgines/github-actions-ingester"
28
+
29
+ # Least privilege for everything the collector reads. `contents: read`
30
+ # only serves the workflow-file read behind GHA_SYNC_SCHEDULES.
31
+ DEFAULT_PERMISSIONS: dict[str, str] = {
32
+ "actions": "read",
33
+ "metadata": "read",
34
+ "contents": "read",
35
+ }
36
+
37
+
38
+ def build_manifest(
39
+ name: str = "github-actions-ingester",
40
+ redirect_url: str = "",
41
+ public: bool = False,
42
+ description: str = "Ingests GitHub Actions runs and jobs into PostgreSQL for Grafana.",
43
+ ) -> dict[str, Any]:
44
+ manifest: dict[str, Any] = {
45
+ "name": name,
46
+ "url": PROJECT_URL,
47
+ "description": description,
48
+ "public": public,
49
+ "default_permissions": DEFAULT_PERMISSIONS,
50
+ "default_events": [],
51
+ "hook_attributes": {"active": False},
52
+ }
53
+ if redirect_url:
54
+ manifest["redirect_url"] = redirect_url
55
+ return manifest
56
+
57
+
58
+ def manifest_form_url(org: str = "") -> str:
59
+ """Where the manifest must be POSTed (org-owned or user-owned App)."""
60
+ if org:
61
+ return f"https://github.com/organizations/{org}/settings/apps/new"
62
+ return "https://github.com/settings/apps/new"
63
+
64
+
65
+ def convert_code(
66
+ code: str, api_base: str = "https://api.github.com", timeout: float = 30.0
67
+ ) -> dict[str, Any]:
68
+ """Exchange the temporary ``code`` for the App credentials.
69
+
70
+ The code is the credential: no token is sent. Returns the API body
71
+ (``id``, ``slug``, ``client_id``, ``pem``, ``html_url`` ...).
72
+ """
73
+ resp = httpx.post(
74
+ f"{api_base.rstrip('/')}/app-manifests/{code}/conversions",
75
+ headers={
76
+ "Accept": "application/vnd.github+json",
77
+ "X-GitHub-Api-Version": API_VERSION,
78
+ "User-Agent": "github-actions-ingester",
79
+ },
80
+ timeout=timeout,
81
+ )
82
+ if resp.status_code != 201:
83
+ raise RuntimeError(
84
+ f"conversion failed: HTTP {resp.status_code} {resp.text[:300]} "
85
+ "(the code expires one hour after the App is created)"
86
+ )
87
+ body: dict[str, Any] = resp.json()
88
+ return body
89
+
90
+
91
+ def write_private_key(pem: str, path: Path) -> None:
92
+ path.write_text(pem, encoding="utf-8")
93
+ path.chmod(0o600)
94
+
95
+
96
+ def manifest_json(**kwargs: Any) -> str:
97
+ return json.dumps(build_manifest(**kwargs), indent=2)