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 +6 -0
- frisch/cli/__init__.py +0 -0
- frisch/cli/commands.py +142 -0
- frisch/cli/main.py +26 -0
- frisch/collect.py +174 -0
- frisch/collectors/__init__.py +24 -0
- frisch/collectors/argocd.py +367 -0
- frisch/collectors/base.py +65 -0
- frisch/collectors/debs.py +176 -0
- frisch/collectors/gitrepo.py +187 -0
- frisch/collectors/puppet.py +227 -0
- frisch/config.py +245 -0
- frisch/db/__init__.py +0 -0
- frisch/db/models.py +123 -0
- frisch/db/queries.py +256 -0
- frisch/db/session.py +22 -0
- frisch/errors.py +18 -0
- frisch/ingest.py +232 -0
- frisch/migrations/env.py +38 -0
- frisch/migrations/script.py.mako +23 -0
- frisch/migrations/versions/0001_initial.py +96 -0
- frisch/model.py +113 -0
- frisch/web/__init__.py +0 -0
- frisch/web/api.py +67 -0
- frisch/web/app.py +155 -0
- frisch-0.1.1.dist-info/METADATA +99 -0
- frisch-0.1.1.dist-info/RECORD +31 -0
- frisch-0.1.1.dist-info/WHEEL +5 -0
- frisch-0.1.1.dist-info/entry_points.txt +11 -0
- frisch-0.1.1.dist-info/licenses/LICENSE +176 -0
- frisch-0.1.1.dist-info/top_level.txt +1 -0
frisch/__init__.py
ADDED
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]
|