github-actions-ingester 0.1.0__tar.gz → 0.2.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.
Files changed (32) hide show
  1. {github_actions_ingester-0.1.0 → github_actions_ingester-0.2.0}/CHANGELOG.md +19 -0
  2. {github_actions_ingester-0.1.0 → github_actions_ingester-0.2.0}/PKG-INFO +11 -1
  3. {github_actions_ingester-0.1.0 → github_actions_ingester-0.2.0}/README.md +10 -0
  4. {github_actions_ingester-0.1.0 → github_actions_ingester-0.2.0}/pyproject.toml +1 -1
  5. {github_actions_ingester-0.1.0 → github_actions_ingester-0.2.0}/src/github_actions_ingester/__init__.py +1 -1
  6. {github_actions_ingester-0.1.0 → github_actions_ingester-0.2.0}/src/github_actions_ingester/__main__.py +4 -0
  7. {github_actions_ingester-0.1.0 → github_actions_ingester-0.2.0}/src/github_actions_ingester/config.py +15 -0
  8. {github_actions_ingester-0.1.0 → github_actions_ingester-0.2.0}/src/github_actions_ingester/store.py +30 -0
  9. {github_actions_ingester-0.1.0 → github_actions_ingester-0.2.0}/tests/test_config.py +8 -0
  10. {github_actions_ingester-0.1.0 → github_actions_ingester-0.2.0}/tests/test_store.py +44 -0
  11. {github_actions_ingester-0.1.0 → github_actions_ingester-0.2.0}/.gitignore +0 -0
  12. {github_actions_ingester-0.1.0 → github_actions_ingester-0.2.0}/LICENSE +0 -0
  13. {github_actions_ingester-0.1.0 → github_actions_ingester-0.2.0}/src/github_actions_ingester/app_manifest.py +0 -0
  14. {github_actions_ingester-0.1.0 → github_actions_ingester-0.2.0}/src/github_actions_ingester/collector.py +0 -0
  15. {github_actions_ingester-0.1.0 → github_actions_ingester-0.2.0}/src/github_actions_ingester/github.py +0 -0
  16. {github_actions_ingester-0.1.0 → github_actions_ingester-0.2.0}/src/github_actions_ingester/metrics.py +0 -0
  17. {github_actions_ingester-0.1.0 → github_actions_ingester-0.2.0}/src/github_actions_ingester/migrations/0001_initial.sql +0 -0
  18. {github_actions_ingester-0.1.0 → github_actions_ingester-0.2.0}/src/github_actions_ingester/ratelimit.py +0 -0
  19. {github_actions_ingester-0.1.0 → github_actions_ingester-0.2.0}/src/github_actions_ingester/server.py +0 -0
  20. {github_actions_ingester-0.1.0 → github_actions_ingester-0.2.0}/src/github_actions_ingester/workflow_schedule.py +0 -0
  21. {github_actions_ingester-0.1.0 → github_actions_ingester-0.2.0}/tests/__init__.py +0 -0
  22. {github_actions_ingester-0.1.0 → github_actions_ingester-0.2.0}/tests/conftest.py +0 -0
  23. {github_actions_ingester-0.1.0 → github_actions_ingester-0.2.0}/tests/helpers.py +0 -0
  24. {github_actions_ingester-0.1.0 → github_actions_ingester-0.2.0}/tests/test_app_manifest.py +0 -0
  25. {github_actions_ingester-0.1.0 → github_actions_ingester-0.2.0}/tests/test_cli.py +0 -0
  26. {github_actions_ingester-0.1.0 → github_actions_ingester-0.2.0}/tests/test_collector.py +0 -0
  27. {github_actions_ingester-0.1.0 → github_actions_ingester-0.2.0}/tests/test_dashboard.py +0 -0
  28. {github_actions_ingester-0.1.0 → github_actions_ingester-0.2.0}/tests/test_github.py +0 -0
  29. {github_actions_ingester-0.1.0 → github_actions_ingester-0.2.0}/tests/test_metrics.py +0 -0
  30. {github_actions_ingester-0.1.0 → github_actions_ingester-0.2.0}/tests/test_ratelimit.py +0 -0
  31. {github_actions_ingester-0.1.0 → github_actions_ingester-0.2.0}/tests/test_server.py +0 -0
  32. {github_actions_ingester-0.1.0 → github_actions_ingester-0.2.0}/tests/test_workflow_schedule.py +0 -0
@@ -4,6 +4,25 @@ All notable changes to this project are documented here. The format follows
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project
5
5
  adheres to [Semantic Versioning](https://semver.org/).
6
6
 
7
+ ## [0.2.0] — 2026-09-05
8
+
9
+ ### Added
10
+ - Chart: `image.digest` renders the image as `repo:tag@digest` for
11
+ clusters that require digest pins.
12
+ - Chart: `extraVolumes` and `extraVolumeMounts`, so a CA bundle can be
13
+ mounted for `sslmode=verify-full`.
14
+ - `GHA_DATABASE_READ_ROLES` (chart: `database.readRoles`): existing roles
15
+ that get read-only access to the schema after every migration run,
16
+ USAGE plus SELECT on every table and view and a default privilege for
17
+ tables added later. Lets Grafana read through its own role instead of
18
+ the ingester's credentials.
19
+
20
+ ### Fixed
21
+ - `examples/github-app/create-app.html` filled the `manifest` field only
22
+ on submit; a viewer that posts before the listener runs sent an empty
23
+ manifest and GitHub answered `"url" wasn't supplied`. The fields are
24
+ now filled on load and on every edit.
25
+
7
26
  ## [0.1.0] — 2026-09-05
8
27
 
9
28
  First release.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: github-actions-ingester
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: Standalone GitHub Actions ingester — pulls workflows, runs and jobs from the GitHub API into PostgreSQL for Grafana dashboards, FinOps and alerting. Prometheus /metrics for ingester health and scheduled-workflow liveness.
5
5
  Project-URL: Homepage, https://github.com/danielgines/github-actions-ingester
6
6
  Project-URL: Repository, https://github.com/danielgines/github-actions-ingester
@@ -155,6 +155,15 @@ nothing. `github-actions-ingester migrate` does the same and exits, for
155
155
  init containers or pipelines that want the schema in place before the
156
156
  service starts.
157
157
 
158
+ The ingester owns the schema and writes to it; dashboards should read
159
+ through a role of their own. `GHA_DATABASE_READ_ROLES` names existing
160
+ roles (comma-separated) that get USAGE on the schema and SELECT on every
161
+ table and view after each migration run, plus a default privilege so
162
+ tables added by later migrations are readable too. Point Grafana at the
163
+ database with one of those roles and it never needs the ingester's
164
+ credentials. The roles themselves are created by whoever manages the
165
+ database (the ingester never runs `CREATE ROLE`).
166
+
158
167
  ### Tables and views
159
168
 
160
169
  | Object | Content |
@@ -280,6 +289,7 @@ directory is read too; see [`.env.example`](.env.example)).
280
289
  | `GHA_DATABASE_URL` | _required_ | libpq URL, e.g. `postgresql://user:pass@host:5432/db?sslmode=require` |
281
290
  | `GHA_DATABASE_SCHEMA` | `gha` | Schema holding every table; created on first start |
282
291
  | `GHA_DATABASE_CONNECT_TIMEOUT_SECONDS` | `10` | Connection timeout |
292
+ | `GHA_DATABASE_READ_ROLES` | `""` | Comma-separated existing roles granted read-only access to the schema on every start |
283
293
  | `GHA_POLL_INTERVAL_SECONDS` | `300` | Seconds between cycles (min 30) |
284
294
  | `GHA_BACKFILL_DAYS` | `30` | How far back the first cycle of a repository goes (1..3660) |
285
295
  | `GHA_LOOKBACK_MINUTES` | `180` | Window before the cursor re-listed every cycle to catch status changes |
@@ -92,6 +92,15 @@ nothing. `github-actions-ingester migrate` does the same and exits, for
92
92
  init containers or pipelines that want the schema in place before the
93
93
  service starts.
94
94
 
95
+ The ingester owns the schema and writes to it; dashboards should read
96
+ through a role of their own. `GHA_DATABASE_READ_ROLES` names existing
97
+ roles (comma-separated) that get USAGE on the schema and SELECT on every
98
+ table and view after each migration run, plus a default privilege so
99
+ tables added by later migrations are readable too. Point Grafana at the
100
+ database with one of those roles and it never needs the ingester's
101
+ credentials. The roles themselves are created by whoever manages the
102
+ database (the ingester never runs `CREATE ROLE`).
103
+
95
104
  ### Tables and views
96
105
 
97
106
  | Object | Content |
@@ -217,6 +226,7 @@ directory is read too; see [`.env.example`](.env.example)).
217
226
  | `GHA_DATABASE_URL` | _required_ | libpq URL, e.g. `postgresql://user:pass@host:5432/db?sslmode=require` |
218
227
  | `GHA_DATABASE_SCHEMA` | `gha` | Schema holding every table; created on first start |
219
228
  | `GHA_DATABASE_CONNECT_TIMEOUT_SECONDS` | `10` | Connection timeout |
229
+ | `GHA_DATABASE_READ_ROLES` | `""` | Comma-separated existing roles granted read-only access to the schema on every start |
220
230
  | `GHA_POLL_INTERVAL_SECONDS` | `300` | Seconds between cycles (min 30) |
221
231
  | `GHA_BACKFILL_DAYS` | `30` | How far back the first cycle of a repository goes (1..3660) |
222
232
  | `GHA_LOOKBACK_MINUTES` | `180` | Window before the cursor re-listed every cycle to catch status changes |
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "github-actions-ingester"
3
- version = "0.1.0"
3
+ version = "0.2.0"
4
4
  description = "Standalone GitHub Actions ingester — pulls workflows, runs and jobs from the GitHub API into PostgreSQL for Grafana dashboards, FinOps and alerting. Prometheus /metrics for ingester health and scheduled-workflow liveness."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -6,4 +6,4 @@ its own, and exposes Prometheus metrics about the ingestion itself and
6
6
  about the liveness of scheduled workflows.
7
7
  """
8
8
 
9
- __version__ = "0.1.0"
9
+ __version__ = "0.2.0"
@@ -152,6 +152,7 @@ def cmd_run(once: bool) -> int:
152
152
  while not stop.is_set():
153
153
  try:
154
154
  store.migrate()
155
+ store.grant_read_access(settings.read_role_list())
155
156
  break
156
157
  except Exception as exc:
157
158
  log.error("store.bootstrap_failed", error=str(exc), retry_in=15)
@@ -200,12 +201,15 @@ def cmd_migrate() -> int:
200
201
  )
201
202
  try:
202
203
  report = store.migrate()
204
+ granted = store.grant_read_access(settings.read_role_list())
203
205
  finally:
204
206
  store.close()
205
207
  print(
206
208
  f"schema {settings.database_schema}: version {report.current_version}, "
207
209
  f"applied now: {report.applied or 'nothing'}"
208
210
  )
211
+ if granted:
212
+ print(f"read access granted to: {', '.join(granted)}")
209
213
  return 0
210
214
 
211
215
 
@@ -27,6 +27,7 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
27
27
 
28
28
  _REPO_RE = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$")
29
29
  _OWNER_RE = re.compile(r"^[A-Za-z0-9_.-]+$")
30
+ _ROLE_RE = re.compile(r"^[a-z_][a-z0-9_]{0,62}$")
30
31
 
31
32
 
32
33
  def _split_csv(value: str) -> list[str]:
@@ -112,6 +113,14 @@ class Settings(BaseSettings):
112
113
  description="Schema that holds every ingester table. Created on first start if missing.",
113
114
  )
114
115
  database_connect_timeout_seconds: int = Field(default=10, ge=1)
116
+ database_read_roles: str = Field(
117
+ default="",
118
+ description="Comma-separated PostgreSQL roles that get read-only access "
119
+ "to the schema after every migration run: USAGE on the schema, "
120
+ "SELECT on every table and view, and the same SELECT on tables the "
121
+ "ingester creates later. Meant for the role Grafana connects with. "
122
+ "The roles must already exist; the ingester never creates roles.",
123
+ )
115
124
 
116
125
  # --- Collection ---
117
126
  poll_interval_seconds: int = Field(
@@ -250,6 +259,9 @@ class Settings(BaseSettings):
250
259
  for repo in self.repo_list():
251
260
  if not _REPO_RE.match(repo):
252
261
  raise ValueError(f"repos entry {repo!r} must look like owner/name")
262
+ for role in self.read_role_list():
263
+ if not _ROLE_RE.match(role):
264
+ raise ValueError(f"database_read_roles entry {role!r} is not a plain identifier")
253
265
  if not self.org_list() and not self.repo_list():
254
266
  raise ValueError("nothing to ingest: set GHA_ORGS and/or GHA_REPOS")
255
267
  return self
@@ -267,6 +279,9 @@ class Settings(BaseSettings):
267
279
  def exclude_patterns(self) -> list[str]:
268
280
  return _split_csv(self.exclude_repos)
269
281
 
282
+ def read_role_list(self) -> list[str]:
283
+ return _split_csv(self.database_read_roles)
284
+
270
285
  def is_excluded(self, full_name: str) -> bool:
271
286
  name = full_name.lower()
272
287
  return any(fnmatch.fnmatchcase(name, pat.lower()) for pat in self.exclude_patterns())
@@ -172,6 +172,36 @@ class Store:
172
172
  logger.info("store.migrations_up_to_date", version=current)
173
173
  return MigrationReport(applied_now, current, len(pending))
174
174
 
175
+ def grant_read_access(self, roles: Sequence[str]) -> list[str]:
176
+ """Give ``roles`` read-only access to the schema.
177
+
178
+ USAGE on the schema, SELECT on every table and view in it, and a
179
+ default privilege so tables created by later migrations are
180
+ readable too. Idempotent; run after every ``migrate``. The roles
181
+ must exist already: creating roles is the operator's job, and a
182
+ missing one raises like any other bootstrap error.
183
+ """
184
+ granted: list[str] = []
185
+ if not roles:
186
+ return granted
187
+ conn = self.connect()
188
+ schema = sql.Identifier(self._schema)
189
+ with conn.transaction(), conn.cursor() as cur:
190
+ for role in roles:
191
+ ident = sql.Identifier(role)
192
+ cur.execute(sql.SQL("GRANT USAGE ON SCHEMA {} TO {}").format(schema, ident))
193
+ cur.execute(
194
+ sql.SQL("GRANT SELECT ON ALL TABLES IN SCHEMA {} TO {}").format(schema, ident)
195
+ )
196
+ cur.execute(
197
+ sql.SQL(
198
+ "ALTER DEFAULT PRIVILEGES IN SCHEMA {} GRANT SELECT ON TABLES TO {}"
199
+ ).format(schema, ident)
200
+ )
201
+ granted.append(role)
202
+ logger.info("store.read_access_granted", roles=granted, schema=self._schema)
203
+ return granted
204
+
175
205
  def schema_version(self) -> int | None:
176
206
  """Highest applied migration, or None when the schema was never bootstrapped."""
177
207
  conn = self.connect()
@@ -104,3 +104,11 @@ def test_normalizations() -> None:
104
104
  assert s.log_format == "console"
105
105
  assert s.jobs_filter == "latest"
106
106
  assert s.github_api_base == "https://ghe.example.com/api/v3"
107
+
108
+
109
+ def test_read_roles_parsed_and_validated() -> None:
110
+ s = load_settings(github_token="x", database_read_roles=" grafana, reporting_ro ", **BASE)
111
+ assert s.read_role_list() == ["grafana", "reporting_ro"]
112
+ assert load_settings(github_token="x", **BASE).read_role_list() == []
113
+ with pytest.raises(ValidationError, match="plain identifier"):
114
+ load_settings(github_token="x", database_read_roles="grafana; drop", **BASE)
@@ -1,7 +1,9 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import re
3
4
  from datetime import timedelta
4
5
 
6
+ import psycopg
5
7
  import pytest
6
8
 
7
9
  from github_actions_ingester.store import Migration, Store, load_migrations
@@ -229,3 +231,45 @@ def _run_row(store: Store, run_id: int) -> dict[str, object]:
229
231
  return dict(cur.fetchone() or {})
230
232
  finally:
231
233
  conn.rollback()
234
+
235
+
236
+ def test_grant_read_access_lets_a_role_read_views_and_future_tables(
237
+ database_url: str, migrated_store: Store
238
+ ) -> None:
239
+ role = "r_" + migrated_store.schema
240
+ with psycopg.connect(database_url, autocommit=True) as admin:
241
+ admin.execute(f"CREATE ROLE {role} LOGIN PASSWORD 'reader'")
242
+ try:
243
+ assert migrated_store.grant_read_access([role]) == [role]
244
+ # Idempotent: a second start must not fail on grants already given.
245
+ assert migrated_store.grant_read_access([role]) == [role]
246
+ # A table created by the ingester AFTER the grant is covered too
247
+ # (default privileges), which is what a later migration looks like.
248
+ with migrated_store.connect().transaction(), migrated_store.connect().cursor() as cur:
249
+ cur.execute("CREATE TABLE later_migration (id INT)")
250
+ schema = migrated_store.schema
251
+ reader_url = re.sub(r"//[^@]*@", f"//{role}:reader@", _with_userinfo(database_url))
252
+ with psycopg.connect(reader_url, autocommit=True) as reader:
253
+ reader.execute(f"SET search_path TO {schema}")
254
+ assert reader.execute("SELECT count(*) FROM minion_workflow_runs").fetchone()[0] == 0
255
+ assert reader.execute("SELECT count(*) FROM workflow_jobs").fetchone()[0] == 0
256
+ assert reader.execute("SELECT count(*) FROM later_migration").fetchone()[0] == 0
257
+ with pytest.raises(psycopg.errors.InsufficientPrivilege):
258
+ reader.execute(
259
+ "INSERT INTO repositories (id, owner, name, full_name) VALUES (1,'a','b','a/b')"
260
+ )
261
+ finally:
262
+ with psycopg.connect(database_url, autocommit=True) as admin:
263
+ admin.execute(f"DROP OWNED BY {role}")
264
+ admin.execute(f"DROP ROLE {role}")
265
+
266
+
267
+ def test_grant_read_access_requires_an_existing_role(migrated_store: Store) -> None:
268
+ with pytest.raises(psycopg.errors.UndefinedObject):
269
+ migrated_store.grant_read_access(["no_such_role_" + migrated_store.schema])
270
+ assert migrated_store.grant_read_access([]) == []
271
+
272
+
273
+ def _with_userinfo(url: str) -> str:
274
+ """pgserver URIs carry no user:pass; give the substitution something to replace."""
275
+ return url if "@" in url else url.replace("://", "://x:y@", 1)