github-actions-ingester 0.1.0__tar.gz → 0.2.1__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.1}/CHANGELOG.md +32 -0
  2. {github_actions_ingester-0.1.0 → github_actions_ingester-0.2.1}/PKG-INFO +15 -1
  3. {github_actions_ingester-0.1.0 → github_actions_ingester-0.2.1}/README.md +14 -0
  4. {github_actions_ingester-0.1.0 → github_actions_ingester-0.2.1}/pyproject.toml +1 -1
  5. {github_actions_ingester-0.1.0 → github_actions_ingester-0.2.1}/src/github_actions_ingester/__init__.py +1 -1
  6. {github_actions_ingester-0.1.0 → github_actions_ingester-0.2.1}/src/github_actions_ingester/__main__.py +4 -0
  7. {github_actions_ingester-0.1.0 → github_actions_ingester-0.2.1}/src/github_actions_ingester/config.py +15 -0
  8. {github_actions_ingester-0.1.0 → github_actions_ingester-0.2.1}/src/github_actions_ingester/store.py +67 -0
  9. {github_actions_ingester-0.1.0 → github_actions_ingester-0.2.1}/tests/test_config.py +8 -0
  10. {github_actions_ingester-0.1.0 → github_actions_ingester-0.2.1}/tests/test_store.py +49 -0
  11. {github_actions_ingester-0.1.0 → github_actions_ingester-0.2.1}/.gitignore +0 -0
  12. {github_actions_ingester-0.1.0 → github_actions_ingester-0.2.1}/LICENSE +0 -0
  13. {github_actions_ingester-0.1.0 → github_actions_ingester-0.2.1}/src/github_actions_ingester/app_manifest.py +0 -0
  14. {github_actions_ingester-0.1.0 → github_actions_ingester-0.2.1}/src/github_actions_ingester/collector.py +0 -0
  15. {github_actions_ingester-0.1.0 → github_actions_ingester-0.2.1}/src/github_actions_ingester/github.py +0 -0
  16. {github_actions_ingester-0.1.0 → github_actions_ingester-0.2.1}/src/github_actions_ingester/metrics.py +0 -0
  17. {github_actions_ingester-0.1.0 → github_actions_ingester-0.2.1}/src/github_actions_ingester/migrations/0001_initial.sql +0 -0
  18. {github_actions_ingester-0.1.0 → github_actions_ingester-0.2.1}/src/github_actions_ingester/ratelimit.py +0 -0
  19. {github_actions_ingester-0.1.0 → github_actions_ingester-0.2.1}/src/github_actions_ingester/server.py +0 -0
  20. {github_actions_ingester-0.1.0 → github_actions_ingester-0.2.1}/src/github_actions_ingester/workflow_schedule.py +0 -0
  21. {github_actions_ingester-0.1.0 → github_actions_ingester-0.2.1}/tests/__init__.py +0 -0
  22. {github_actions_ingester-0.1.0 → github_actions_ingester-0.2.1}/tests/conftest.py +0 -0
  23. {github_actions_ingester-0.1.0 → github_actions_ingester-0.2.1}/tests/helpers.py +0 -0
  24. {github_actions_ingester-0.1.0 → github_actions_ingester-0.2.1}/tests/test_app_manifest.py +0 -0
  25. {github_actions_ingester-0.1.0 → github_actions_ingester-0.2.1}/tests/test_cli.py +0 -0
  26. {github_actions_ingester-0.1.0 → github_actions_ingester-0.2.1}/tests/test_collector.py +0 -0
  27. {github_actions_ingester-0.1.0 → github_actions_ingester-0.2.1}/tests/test_dashboard.py +0 -0
  28. {github_actions_ingester-0.1.0 → github_actions_ingester-0.2.1}/tests/test_github.py +0 -0
  29. {github_actions_ingester-0.1.0 → github_actions_ingester-0.2.1}/tests/test_metrics.py +0 -0
  30. {github_actions_ingester-0.1.0 → github_actions_ingester-0.2.1}/tests/test_ratelimit.py +0 -0
  31. {github_actions_ingester-0.1.0 → github_actions_ingester-0.2.1}/tests/test_server.py +0 -0
  32. {github_actions_ingester-0.1.0 → github_actions_ingester-0.2.1}/tests/test_workflow_schedule.py +0 -0
@@ -4,6 +4,38 @@ 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.1] — 2026-09-05
8
+
9
+ ### Added
10
+ - Chart: `deploymentAnnotations`, for a Reloader annotation on the
11
+ Deployment.
12
+
13
+ ### Fixed
14
+ - A read role with a name different from the schema could not resolve the
15
+ views by bare name, and the Grafana PostgreSQL datasource has no
16
+ search_path setting. After granting read access the ingester now sets
17
+ the database's default `search_path` to the schema (needs the ingester
18
+ role to own the database; skipped with a warning otherwise).
19
+
20
+ ## [0.2.0] — 2026-09-05
21
+
22
+ ### Added
23
+ - Chart: `image.digest` renders the image as `repo:tag@digest` for
24
+ clusters that require digest pins.
25
+ - Chart: `extraVolumes` and `extraVolumeMounts`, so a CA bundle can be
26
+ mounted for `sslmode=verify-full`.
27
+ - `GHA_DATABASE_READ_ROLES` (chart: `database.readRoles`): existing roles
28
+ that get read-only access to the schema after every migration run,
29
+ USAGE plus SELECT on every table and view and a default privilege for
30
+ tables added later. Lets Grafana read through its own role instead of
31
+ the ingester's credentials.
32
+
33
+ ### Fixed
34
+ - `examples/github-app/create-app.html` filled the `manifest` field only
35
+ on submit; a viewer that posts before the listener runs sent an empty
36
+ manifest and GitHub answered `"url" wasn't supplied`. The fields are
37
+ now filled on load and on every edit.
38
+
7
39
  ## [0.1.0] — 2026-09-05
8
40
 
9
41
  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.1
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,19 @@ 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`). In the same step the
166
+ ingester sets the database's default `search_path` to the schema, so a
167
+ Grafana datasource (which cannot send one) resolves the views by bare
168
+ name; that statement needs the ingester's role to own the database, and
169
+ is skipped with a warning otherwise.
170
+
158
171
  ### Tables and views
159
172
 
160
173
  | Object | Content |
@@ -280,6 +293,7 @@ directory is read too; see [`.env.example`](.env.example)).
280
293
  | `GHA_DATABASE_URL` | _required_ | libpq URL, e.g. `postgresql://user:pass@host:5432/db?sslmode=require` |
281
294
  | `GHA_DATABASE_SCHEMA` | `gha` | Schema holding every table; created on first start |
282
295
  | `GHA_DATABASE_CONNECT_TIMEOUT_SECONDS` | `10` | Connection timeout |
296
+ | `GHA_DATABASE_READ_ROLES` | `""` | Comma-separated existing roles granted read-only access to the schema on every start |
283
297
  | `GHA_POLL_INTERVAL_SECONDS` | `300` | Seconds between cycles (min 30) |
284
298
  | `GHA_BACKFILL_DAYS` | `30` | How far back the first cycle of a repository goes (1..3660) |
285
299
  | `GHA_LOOKBACK_MINUTES` | `180` | Window before the cursor re-listed every cycle to catch status changes |
@@ -92,6 +92,19 @@ 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`). In the same step the
103
+ ingester sets the database's default `search_path` to the schema, so a
104
+ Grafana datasource (which cannot send one) resolves the views by bare
105
+ name; that statement needs the ingester's role to own the database, and
106
+ is skipped with a warning otherwise.
107
+
95
108
  ### Tables and views
96
109
 
97
110
  | Object | Content |
@@ -217,6 +230,7 @@ directory is read too; see [`.env.example`](.env.example)).
217
230
  | `GHA_DATABASE_URL` | _required_ | libpq URL, e.g. `postgresql://user:pass@host:5432/db?sslmode=require` |
218
231
  | `GHA_DATABASE_SCHEMA` | `gha` | Schema holding every table; created on first start |
219
232
  | `GHA_DATABASE_CONNECT_TIMEOUT_SECONDS` | `10` | Connection timeout |
233
+ | `GHA_DATABASE_READ_ROLES` | `""` | Comma-separated existing roles granted read-only access to the schema on every start |
220
234
  | `GHA_POLL_INTERVAL_SECONDS` | `300` | Seconds between cycles (min 30) |
221
235
  | `GHA_BACKFILL_DAYS` | `30` | How far back the first cycle of a repository goes (1..3660) |
222
236
  | `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.1"
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.1"
@@ -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,73 @@ 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
+ self._set_database_search_path()
204
+ return granted
205
+
206
+ def _set_database_search_path(self) -> None:
207
+ """Make the schema resolve by bare name for every session on the database.
208
+
209
+ The ingester sets ``search_path`` on its own connections, and a role
210
+ named like the schema gets it for free (``"$user"`` is first in the
211
+ default path), which is why the Compose example works without this.
212
+ A read role with a different name (``grafana`` reading schema
213
+ ``gha``) resolves ``minion_workflow_runs`` to nothing, and the
214
+ Grafana PostgreSQL datasource has no search_path setting.
215
+
216
+ ``ALTER DATABASE ... SET`` needs the database owner. When the ingester
217
+ is not the owner this logs and moves on: the grants above still hold,
218
+ and the operator can run the statement once by hand.
219
+ """
220
+ conn = self.connect()
221
+ try:
222
+ with conn.transaction(), conn.cursor() as cur:
223
+ cur.execute("SELECT current_database() AS db")
224
+ row = cur.fetchone()
225
+ database = row["db"] if row else ""
226
+ cur.execute(
227
+ sql.SQL("ALTER DATABASE {} SET search_path TO {}, public").format(
228
+ sql.Identifier(database), sql.Identifier(self._schema)
229
+ )
230
+ )
231
+ except psycopg.errors.InsufficientPrivilege:
232
+ conn.rollback()
233
+ logger.warning(
234
+ "store.search_path_not_set",
235
+ schema=self._schema,
236
+ hint="ingester role does not own the database; run "
237
+ "ALTER DATABASE <db> SET search_path TO <schema>, public as the owner",
238
+ )
239
+ return
240
+ logger.info("store.search_path_set", schema=self._schema)
241
+
175
242
  def schema_version(self) -> int | None:
176
243
  """Highest applied migration, or None when the schema was never bootstrapped."""
177
244
  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,50 @@ 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
+ # No SET search_path on purpose: Grafana cannot send one, so the
254
+ # database-level default the ingester sets must resolve the views.
255
+ path = reader.execute("SHOW search_path").fetchone()[0]
256
+ assert path.split(",")[0].strip().strip('"') == schema
257
+ assert reader.execute("SELECT count(*) FROM minion_workflow_runs").fetchone()[0] == 0
258
+ assert reader.execute("SELECT count(*) FROM workflow_jobs").fetchone()[0] == 0
259
+ assert reader.execute("SELECT count(*) FROM later_migration").fetchone()[0] == 0
260
+ with pytest.raises(psycopg.errors.InsufficientPrivilege):
261
+ reader.execute(
262
+ "INSERT INTO repositories (id, owner, name, full_name) VALUES (1,'a','b','a/b')"
263
+ )
264
+ finally:
265
+ with psycopg.connect(database_url, autocommit=True) as admin:
266
+ admin.execute(f"DROP OWNED BY {role}")
267
+ admin.execute(f"DROP ROLE {role}")
268
+ db = admin.execute("SELECT current_database()").fetchone()[0]
269
+ admin.execute(f'ALTER DATABASE "{db}" RESET search_path')
270
+
271
+
272
+ def test_grant_read_access_requires_an_existing_role(migrated_store: Store) -> None:
273
+ with pytest.raises(psycopg.errors.UndefinedObject):
274
+ migrated_store.grant_read_access(["no_such_role_" + migrated_store.schema])
275
+ assert migrated_store.grant_read_access([]) == []
276
+
277
+
278
+ def _with_userinfo(url: str) -> str:
279
+ """pgserver URIs carry no user:pass; give the substitution something to replace."""
280
+ return url if "@" in url else url.replace("://", "://x:y@", 1)