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.
- {github_actions_ingester-0.1.0 → github_actions_ingester-0.2.0}/CHANGELOG.md +19 -0
- {github_actions_ingester-0.1.0 → github_actions_ingester-0.2.0}/PKG-INFO +11 -1
- {github_actions_ingester-0.1.0 → github_actions_ingester-0.2.0}/README.md +10 -0
- {github_actions_ingester-0.1.0 → github_actions_ingester-0.2.0}/pyproject.toml +1 -1
- {github_actions_ingester-0.1.0 → github_actions_ingester-0.2.0}/src/github_actions_ingester/__init__.py +1 -1
- {github_actions_ingester-0.1.0 → github_actions_ingester-0.2.0}/src/github_actions_ingester/__main__.py +4 -0
- {github_actions_ingester-0.1.0 → github_actions_ingester-0.2.0}/src/github_actions_ingester/config.py +15 -0
- {github_actions_ingester-0.1.0 → github_actions_ingester-0.2.0}/src/github_actions_ingester/store.py +30 -0
- {github_actions_ingester-0.1.0 → github_actions_ingester-0.2.0}/tests/test_config.py +8 -0
- {github_actions_ingester-0.1.0 → github_actions_ingester-0.2.0}/tests/test_store.py +44 -0
- {github_actions_ingester-0.1.0 → github_actions_ingester-0.2.0}/.gitignore +0 -0
- {github_actions_ingester-0.1.0 → github_actions_ingester-0.2.0}/LICENSE +0 -0
- {github_actions_ingester-0.1.0 → github_actions_ingester-0.2.0}/src/github_actions_ingester/app_manifest.py +0 -0
- {github_actions_ingester-0.1.0 → github_actions_ingester-0.2.0}/src/github_actions_ingester/collector.py +0 -0
- {github_actions_ingester-0.1.0 → github_actions_ingester-0.2.0}/src/github_actions_ingester/github.py +0 -0
- {github_actions_ingester-0.1.0 → github_actions_ingester-0.2.0}/src/github_actions_ingester/metrics.py +0 -0
- {github_actions_ingester-0.1.0 → github_actions_ingester-0.2.0}/src/github_actions_ingester/migrations/0001_initial.sql +0 -0
- {github_actions_ingester-0.1.0 → github_actions_ingester-0.2.0}/src/github_actions_ingester/ratelimit.py +0 -0
- {github_actions_ingester-0.1.0 → github_actions_ingester-0.2.0}/src/github_actions_ingester/server.py +0 -0
- {github_actions_ingester-0.1.0 → github_actions_ingester-0.2.0}/src/github_actions_ingester/workflow_schedule.py +0 -0
- {github_actions_ingester-0.1.0 → github_actions_ingester-0.2.0}/tests/__init__.py +0 -0
- {github_actions_ingester-0.1.0 → github_actions_ingester-0.2.0}/tests/conftest.py +0 -0
- {github_actions_ingester-0.1.0 → github_actions_ingester-0.2.0}/tests/helpers.py +0 -0
- {github_actions_ingester-0.1.0 → github_actions_ingester-0.2.0}/tests/test_app_manifest.py +0 -0
- {github_actions_ingester-0.1.0 → github_actions_ingester-0.2.0}/tests/test_cli.py +0 -0
- {github_actions_ingester-0.1.0 → github_actions_ingester-0.2.0}/tests/test_collector.py +0 -0
- {github_actions_ingester-0.1.0 → github_actions_ingester-0.2.0}/tests/test_dashboard.py +0 -0
- {github_actions_ingester-0.1.0 → github_actions_ingester-0.2.0}/tests/test_github.py +0 -0
- {github_actions_ingester-0.1.0 → github_actions_ingester-0.2.0}/tests/test_metrics.py +0 -0
- {github_actions_ingester-0.1.0 → github_actions_ingester-0.2.0}/tests/test_ratelimit.py +0 -0
- {github_actions_ingester-0.1.0 → github_actions_ingester-0.2.0}/tests/test_server.py +0 -0
- {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.
|
|
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.
|
|
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"
|
|
@@ -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())
|
{github_actions_ingester-0.1.0 → github_actions_ingester-0.2.0}/src/github_actions_ingester/store.py
RENAMED
|
@@ -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)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{github_actions_ingester-0.1.0 → github_actions_ingester-0.2.0}/tests/test_workflow_schedule.py
RENAMED
|
File without changes
|