frisch 0.1.1__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.
frisch/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ from importlib import metadata
2
+
3
+ try:
4
+ __version__ = metadata.version("frisch")
5
+ except metadata.PackageNotFoundError: # pragma: no cover - not installed
6
+ __version__ = "0.0.0"
frisch/cli/__init__.py ADDED
File without changes
frisch/cli/commands.py ADDED
@@ -0,0 +1,142 @@
1
+ from __future__ import annotations
2
+
3
+ import importlib.resources
4
+
5
+ from alembic import command as alembic_command
6
+ from alembic.config import Config as AlembicConfig
7
+ from cliff.command import Command
8
+ from cliff.lister import Lister
9
+
10
+ from frisch import collect as collect_mod
11
+ from frisch.config import load_config
12
+ from frisch.db import queries
13
+ from frisch.db.session import make_session_factory
14
+
15
+
16
+ class _ConfiguredCommand(Command):
17
+ def get_parser(self, prog_name):
18
+ parser = super().get_parser(prog_name)
19
+ parser.add_argument("--config", help="path to a config file")
20
+ return parser
21
+
22
+
23
+ class Collect(_ConfiguredCommand):
24
+ """Run the collectors once (incremental)."""
25
+
26
+ def get_parser(self, prog_name):
27
+ parser = super().get_parser(prog_name)
28
+ parser.add_argument("--source", action="append")
29
+ parser.add_argument("--env", action="append")
30
+ return parser
31
+
32
+ def take_action(self, parsed_args):
33
+ config = load_config(parsed_args.config)
34
+ ok = collect_mod.run_collection(
35
+ config, sources=parsed_args.source, envs=parsed_args.env
36
+ )
37
+ if not ok:
38
+ raise SystemExit(1)
39
+
40
+
41
+ class Backfill(_ConfiguredCommand):
42
+ """Re-walk all git history (idempotent)."""
43
+
44
+ def get_parser(self, prog_name):
45
+ parser = super().get_parser(prog_name)
46
+ parser.add_argument(
47
+ "--source", action="append", choices=["argocd", "puppet"]
48
+ )
49
+ parser.add_argument("--env", action="append")
50
+ return parser
51
+
52
+ def take_action(self, parsed_args):
53
+ config = load_config(parsed_args.config)
54
+ ok = collect_mod.run_collection(
55
+ config,
56
+ sources=parsed_args.source or ["argocd", "puppet"],
57
+ envs=parsed_args.env,
58
+ backfill=True,
59
+ )
60
+ if not ok:
61
+ raise SystemExit(1)
62
+
63
+
64
+ class DbUpgrade(_ConfiguredCommand):
65
+ """Create or upgrade the database schema (alembic upgrade head)."""
66
+
67
+ def take_action(self, parsed_args):
68
+ config = load_config(parsed_args.config)
69
+ alembic_cfg = AlembicConfig()
70
+ migrations = importlib.resources.files("frisch") / "migrations"
71
+ alembic_cfg.set_main_option("script_location", str(migrations))
72
+ alembic_cfg.set_main_option("sqlalchemy.url", config.database_url)
73
+ alembic_command.upgrade(alembic_cfg, "head")
74
+
75
+
76
+ class ShowMatrix(_ConfiguredCommand, Lister):
77
+ """The version matrix, in a terminal."""
78
+
79
+ def take_action(self, parsed_args):
80
+ config = load_config(parsed_args.config)
81
+ session_factory = make_session_factory(config.database_url)
82
+ with session_factory() as session:
83
+ data = queries.matrix(session, config.environments)
84
+ envs = data["environments"]
85
+ columns = ["Service", "Source"]
86
+ for env in envs:
87
+ columns.extend([env, f"{env} changed"])
88
+
89
+ def cell(row, env):
90
+ info = row["environments"].get(env)
91
+ if not info:
92
+ return "-", ""
93
+ if info.get("removed"):
94
+ return "(removed)", info.get("changed_at") or ""
95
+ if info.get("mixed"):
96
+ return (
97
+ "mixed: " + ", ".join(info.get("versions") or []),
98
+ info.get("changed_at") or "",
99
+ )
100
+ version = info.get("version") or "-"
101
+ if info.get("floating"):
102
+ version += " (floating)"
103
+ return version, info.get("changed_at") or ""
104
+
105
+ rows = []
106
+ for row in data["rows"]:
107
+ values = [row["service"], row["source"]]
108
+ for env in envs:
109
+ version, changed = cell(row, env)
110
+ values.extend([version, changed])
111
+ rows.append(values)
112
+ return columns, rows
113
+
114
+
115
+ class ShowStatus(_ConfiguredCommand, Lister):
116
+ """Collector run status per source and environment."""
117
+
118
+ def take_action(self, parsed_args):
119
+ config = load_config(parsed_args.config)
120
+ session_factory = make_session_factory(config.database_url)
121
+ with session_factory() as session:
122
+ data = queries.status(session)
123
+ columns = [
124
+ "Source",
125
+ "Env",
126
+ "Status",
127
+ "Finished",
128
+ "Last success",
129
+ "Error",
130
+ ]
131
+ rows = [
132
+ (
133
+ s["source"],
134
+ s["env"],
135
+ s["status"],
136
+ s["finished_at"] or "",
137
+ s["last_success_at"] or "",
138
+ (s["error"] or "")[:80],
139
+ )
140
+ for s in data["sources"]
141
+ ]
142
+ return columns, rows
frisch/cli/main.py ADDED
@@ -0,0 +1,26 @@
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+
5
+ from cliff.app import App
6
+ from cliff.commandmanager import CommandManager
7
+
8
+ from frisch import __version__
9
+
10
+
11
+ class FrischApp(App):
12
+ def __init__(self):
13
+ super().__init__(
14
+ description="Deployment version visibility for Nectar",
15
+ version=__version__,
16
+ command_manager=CommandManager("frisch.cli"),
17
+ deferred_help=True,
18
+ )
19
+
20
+
21
+ def main(argv: list[str] | None = None) -> int:
22
+ return FrischApp().run(argv if argv is not None else sys.argv[1:])
23
+
24
+
25
+ if __name__ == "__main__":
26
+ raise SystemExit(main())
frisch/collect.py ADDED
@@ -0,0 +1,174 @@
1
+ """The ``frisch-collect`` entry point: run collectors and ingest.
2
+
3
+ One shot by default (the CronJob entry point); ``--interval N`` loops for
4
+ compose/dev. Exits non-zero when any source fails or is unavailable, so the
5
+ CronJob goes red instead of silently serving stale data. Run bookkeeping
6
+ uses its own transaction, so a failed run is still recorded; the ingested
7
+ events and cursor updates commit atomically per (source, env).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import logging
14
+ import sys
15
+ import time
16
+
17
+ from sqlalchemy.orm import Session
18
+
19
+ from frisch.collectors.base import Collector
20
+ from frisch.collectors import build_collectors
21
+ from frisch.config import Config, load_config
22
+ from frisch.db.models import CollectorRun, GitCursor
23
+ from frisch.db.session import make_session_factory
24
+ from frisch.errors import FrischError, SourceUnavailable
25
+ from frisch.ingest import ingest, utcnow
26
+
27
+ LOG = logging.getLogger(__name__)
28
+
29
+
30
+ class DbCursorStore:
31
+ """Cursors staged in the same session (and transaction) as the events."""
32
+
33
+ def __init__(self, session: Session):
34
+ self.session = session
35
+
36
+ def get(self, key: str) -> str | None:
37
+ row = self.session.get(GitCursor, key)
38
+ return row.sha if row else None
39
+
40
+ def set(self, key: str, value: str) -> None:
41
+ row = self.session.get(GitCursor, key)
42
+ if row:
43
+ row.sha = value
44
+ else:
45
+ self.session.add(GitCursor(key=key, sha=value))
46
+
47
+ def delete(self, key: str) -> None:
48
+ row = self.session.get(GitCursor, key)
49
+ if row:
50
+ self.session.delete(row)
51
+ # Flush now: a backfill deletes the cursor and then re-adds the
52
+ # same key in this session, and an unflushed DELETE would win
53
+ # over the new row at commit, silently losing the cursor.
54
+ self.session.flush()
55
+
56
+
57
+ def _run_one(
58
+ session_factory, collector: Collector, env: str, backfill: bool
59
+ ) -> bool:
60
+ with session_factory() as bookkeeping:
61
+ run = CollectorRun(
62
+ source=collector.name,
63
+ env=env,
64
+ started_at=utcnow(),
65
+ status="running",
66
+ )
67
+ bookkeeping.add(run)
68
+ bookkeeping.commit()
69
+ run_id = run.id
70
+
71
+ status, error, stats = "success", None, {}
72
+ try:
73
+ with session_factory() as session:
74
+ cursors = DbCursorStore(session)
75
+ if backfill:
76
+ result = collector.backfill(env, cursors)
77
+ else:
78
+ result = collector.collect(env, cursors)
79
+ ingest_stats = ingest(
80
+ session,
81
+ collector.name,
82
+ env,
83
+ result.observations,
84
+ full_snapshot=result.full_snapshot,
85
+ snapshot_ref=result.snapshot_ref,
86
+ )
87
+ stats = {**result.stats, **ingest_stats.as_dict()}
88
+ session.commit()
89
+ LOG.info("%s/%s: %s", collector.name, env, stats)
90
+ except SourceUnavailable as exc:
91
+ status, error = "unavailable", str(exc)
92
+ LOG.warning("%s/%s unavailable: %s", collector.name, env, exc)
93
+ except (FrischError, Exception) as exc:
94
+ status, error = "error", f"{type(exc).__name__}: {exc}"
95
+ LOG.exception("%s/%s failed", collector.name, env)
96
+
97
+ with session_factory() as bookkeeping:
98
+ run = bookkeeping.get(CollectorRun, run_id)
99
+ if run:
100
+ run.finished_at = utcnow()
101
+ run.status = status
102
+ run.error = error
103
+ run.stats = stats
104
+ bookkeeping.commit()
105
+ return status == "success"
106
+
107
+
108
+ def run_collection(
109
+ config: Config,
110
+ sources: list[str] | None = None,
111
+ envs: list[str] | None = None,
112
+ backfill: bool = False,
113
+ ) -> bool:
114
+ session_factory = make_session_factory(config.database_url)
115
+ ok = True
116
+ for collector in build_collectors(config, sources):
117
+ for env in envs or config.environments:
118
+ if env not in collector.environments():
119
+ continue
120
+ if not _run_one(session_factory, collector, env, backfill):
121
+ ok = False
122
+ return ok
123
+
124
+
125
+ def main(argv: list[str] | None = None) -> int:
126
+ parser = argparse.ArgumentParser(
127
+ prog="frisch-collect",
128
+ description="Collect deployed versions from the configured sources.",
129
+ )
130
+ parser.add_argument("--config", help="path to a config file")
131
+ parser.add_argument(
132
+ "--source",
133
+ action="append",
134
+ choices=["argocd", "puppet", "deb"],
135
+ help="only these sources (repeatable; default all)",
136
+ )
137
+ parser.add_argument(
138
+ "--env",
139
+ action="append",
140
+ help="only these environments (repeatable; default all configured)",
141
+ )
142
+ parser.add_argument(
143
+ "--backfill",
144
+ action="store_true",
145
+ help="re-walk all git history (git sources only)",
146
+ )
147
+ parser.add_argument(
148
+ "--interval",
149
+ type=int,
150
+ metavar="SECONDS",
151
+ help="loop forever with this many seconds between runs",
152
+ )
153
+ args = parser.parse_args(argv)
154
+ logging.basicConfig(
155
+ level=logging.INFO,
156
+ format="%(asctime)s %(levelname)s %(name)s: %(message)s",
157
+ )
158
+
159
+ config = load_config(args.config)
160
+ while True:
161
+ ok = run_collection(
162
+ config,
163
+ sources=args.source,
164
+ envs=args.env,
165
+ backfill=args.backfill,
166
+ )
167
+ if args.interval is None:
168
+ return 0 if ok else 1
169
+ args.backfill = False # only ever backfill on the first loop
170
+ time.sleep(args.interval)
171
+
172
+
173
+ if __name__ == "__main__":
174
+ sys.exit(main())
@@ -0,0 +1,24 @@
1
+ """Collector registry."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from frisch.collectors.argocd import ArgocdCollector
6
+ from frisch.collectors.base import Collector
7
+ from frisch.collectors.debs import DebCollector
8
+ from frisch.collectors.puppet import PuppetCollector
9
+ from frisch.config import Config
10
+ from frisch.model import ServiceIdentity
11
+
12
+ COLLECTORS = {
13
+ ArgocdCollector.name: ArgocdCollector,
14
+ PuppetCollector.name: PuppetCollector,
15
+ DebCollector.name: DebCollector,
16
+ }
17
+
18
+
19
+ def build_collectors(
20
+ config: Config, sources: list[str] | None = None
21
+ ) -> list[Collector]:
22
+ identity = ServiceIdentity(config.services)
23
+ names = sources or list(COLLECTORS)
24
+ return [COLLECTORS[name](config, identity) for name in names]