- The audit-wording guard now reads both branches of a conditional action
("backup.made" if ok else "backup.failed"), and its self-test covers that.
- The Backups save test no longer asserts the session's tz, which sign-in
state sets, not the test.
- The restore integration test uses the app's own metadata. all_metadata
without plugin dirs imports the `plugins` package as the tests do; loading
by path under the loader's names is only for `steward restore`. Loading by
path in the shared test process had mapped traefik and unifi tables a
second time for later tests.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -41,7 +41,8 @@ def all_metadata(plugin_dirs: list[Path] | None = None) -> MetaData:
|
|||||||
restore runs before anything else would load them. Plugins are found in
|
restore runs before anything else would load them. Plugins are found in
|
||||||
`plugin_dirs` (the bootstrap's, bundled first) and loaded by path under
|
`plugin_dirs` (the bootstrap's, bundled first) and loaded by path under
|
||||||
the same module names the app gives them, so a plugin the app already
|
the same module names the app gives them, so a plugin the app already
|
||||||
loaded isn't mapped twice. Without dirs: the repository's plugins/."""
|
loaded isn't mapped twice. Without dirs (tests, a checkout): the
|
||||||
|
`plugins` package, as the tests import it."""
|
||||||
import importlib
|
import importlib
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
@@ -51,15 +52,18 @@ def all_metadata(plugin_dirs: list[Path] | None = None) -> MetaData:
|
|||||||
from steward.models.base import Base
|
from steward.models.base import Base
|
||||||
|
|
||||||
roots = [Path(d).resolve() for d in (plugin_dirs or [Path(steward.__file__).parent.parent / "plugins"])]
|
roots = [Path(d).resolve() for d in (plugin_dirs or [Path(steward.__file__).parent.parent / "plugins"])]
|
||||||
|
by_path = bool(plugin_dirs)
|
||||||
names = sorted({p.name for root in roots if root.is_dir() for p in root.iterdir()
|
names = sorted({p.name for root in roots if root.is_dir() for p in root.iterdir()
|
||||||
if (p / "__init__.py").exists() and (p / "models.py").exists()})
|
if (p / "__init__.py").exists() and (p / "models.py").exists()})
|
||||||
for name in names:
|
for name in names:
|
||||||
if f"plugins.{name}.models" in sys.modules or f"_steward_plugin_{name}.models" in sys.modules:
|
if f"plugins.{name}.models" in sys.modules or f"_steward_plugin_{name}.models" in sys.modules:
|
||||||
continue # already mapped, under one name or the other
|
continue # already mapped, under one name or the other
|
||||||
path = resolve_plugin_path(roots, name)
|
|
||||||
try:
|
try:
|
||||||
package = _import_plugin(name, path)
|
if by_path:
|
||||||
importlib.import_module(f"{package.__name__}.models")
|
package = _import_plugin(name, resolve_plugin_path(roots, name)).__name__
|
||||||
|
else:
|
||||||
|
package = f"plugins.{name}"
|
||||||
|
importlib.import_module(f"{package}.models")
|
||||||
except Exception: # a plugin that won't load: its tables are left as pg_restore put them
|
except Exception: # a plugin that won't load: its tables are left as pg_restore put them
|
||||||
logger.warning("Restore: couldn't load plugin %r's tables", name, exc_info=True)
|
logger.warning("Restore: couldn't load plugin %r's tables", name, exc_info=True)
|
||||||
return Base.metadata
|
return Base.metadata
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ def logged_actions(source: str) -> set[str]:
|
|||||||
if not (isinstance(call, ast.Call) and getattr(call.func, "id", getattr(call.func, "attr", "")) in _WRITERS):
|
if not (isinstance(call, ast.Call) and getattr(call.func, "id", getattr(call.func, "attr", "")) in _WRITERS):
|
||||||
continue
|
continue
|
||||||
args = list(call.args) + [k.value for k in call.keywords if k.arg == "action"]
|
args = list(call.args) + [k.value for k in call.keywords if k.arg == "action"]
|
||||||
|
# Both branches of `"a.made" if ok else "a.failed"` are actions too.
|
||||||
|
args += [b for a in args if isinstance(a, ast.IfExp) for b in (a.body, a.orelse)]
|
||||||
found |= {a.value for a in args
|
found |= {a.value for a in args
|
||||||
if isinstance(a, ast.Constant) and isinstance(a.value, str) and _CODE.fullmatch(a.value)}
|
if isinstance(a, ast.Constant) and isinstance(a.value, str) and _CODE.fullmatch(a.value)}
|
||||||
return found
|
return found
|
||||||
@@ -34,5 +36,7 @@ def test_every_logged_action_is_worded():
|
|||||||
|
|
||||||
|
|
||||||
def test_the_guard_can_fail():
|
def test_the_guard_can_fail():
|
||||||
code = 'async def f(app):\n await log_audit(app, None, "", "widget.exploded", entity_type="widget")\n'
|
code = ('async def f(app, ok):\n'
|
||||||
assert logged_actions(code) - set(ACTIONS) == {"widget.exploded"}
|
' await log_audit(app, None, "", "widget.exploded", entity_type="widget")\n'
|
||||||
|
' await log_audit(app, None, "", "widget.fixed" if ok else "widget.broke")\n')
|
||||||
|
assert logged_actions(code) - set(ACTIONS) == {"widget.exploded", "widget.fixed", "widget.broke"}
|
||||||
|
|||||||
@@ -165,5 +165,5 @@ def test_the_secret_key_is_sealed_and_a_blank_box_keeps_it(app):
|
|||||||
assert "enc:v1:" in stored and "new-secret" not in stored
|
assert "enc:v1:" in stored and "new-secret" not in stored
|
||||||
assert settings["backup.s3_secret_key"] == "new-secret"
|
assert settings["backup.s3_secret_key"] == "new-secret"
|
||||||
assert settings["backup.s3_access_key"] == "AK2" and settings["backup.s3_region"] == "us-east-1"
|
assert settings["backup.s3_access_key"] == "AK2" and settings["backup.s3_region"] == "us-east-1"
|
||||||
assert settings["backup.schedule_hour"] == 4 and settings["backup.schedule_tz"] == "UTC"
|
assert settings["backup.schedule_hour"] == 4
|
||||||
assert "new-secret" not in page and "kept; type to change" in page
|
assert "new-secret" not in page and "kept; type to change" in page
|
||||||
|
|||||||
@@ -23,7 +23,8 @@ def app():
|
|||||||
def test_a_dangling_ansible_run_link_is_cleared_and_the_event_kept(app):
|
def test_a_dangling_ansible_run_link_is_cleared_and_the_event_kept(app):
|
||||||
from sqlalchemy import select, text
|
from sqlalchemy import select, text
|
||||||
|
|
||||||
from steward.backup.restore import _execute, all_metadata, dangling_fixes
|
from steward.backup.restore import _execute, dangling_fixes
|
||||||
|
from steward.models.base import Base
|
||||||
from steward.models.alerts import AlertEvent, AlertOperator, AlertRule
|
from steward.models.alerts import AlertEvent, AlertOperator, AlertRule
|
||||||
from steward.models.users import User, UserRole
|
from steward.models.users import User, UserRole
|
||||||
|
|
||||||
@@ -49,7 +50,7 @@ def test_a_dangling_ansible_run_link_is_cleared_and_the_event_kept(app):
|
|||||||
s.add(AlertEvent(id=plain, rule_id=rule_id, from_state="firing",
|
s.add(AlertEvent(id=plain, rule_id=rule_id, from_state="firing",
|
||||||
to_state="inactive"))
|
to_state="inactive"))
|
||||||
|
|
||||||
fixed = await _execute(os.environ["STEWARD_DATABASE_URL"], dangling_fixes(all_metadata()))
|
fixed = await _execute(os.environ["STEWARD_DATABASE_URL"], dangling_fixes(Base.metadata))
|
||||||
|
|
||||||
async with app.db_sessionmaker() as s:
|
async with app.db_sessionmaker() as s:
|
||||||
rows = dict((await s.execute(
|
rows = dict((await s.execute(
|
||||||
|
|||||||
Reference in New Issue
Block a user