mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-10 12:47:04 +00:00
Adds ``S`` (flake8-bandit) to the strict ruleset. ``PTH`` was scoped
out: it surfaced 1000+ violations with no auto-fix support (most PTH
sub-rules are warning-only because each ``str`` ↔ ``Path`` conversion
is context-sensitive). Reserved for its own dedicated commit.
Config tuning
=============
Global ``ignore`` additions with rationale:
- ``S603`` (subprocess call without ``shell=False``) and ``S607`` (start
process with partial path) are over-eager for a project whose CLI
invokes ``make`` / ``git`` / ``docker`` / ``ansible-playbook`` from
PATH using list-form args. The list-form already prevents shell
injection (Python's ``subprocess`` does not parse argv[0] as a shell
command), and resolving every binary's absolute path in the source
would couple the code to the host filesystem layout. The genuinely
dangerous shell-injection patterns (``shell=True``) are still caught
by S602 / S605.
New ``[tool.ruff.lint.per-file-ignores]`` for ``tests/**``:
- ``S101`` — ``assert`` is the unittest idiom.
- ``S102`` — ``exec`` is used to load script-under-test by file path.
- ``S105`` / ``S106`` — hardcoded credentials in fixtures / dummy creds.
- ``S108`` — ``/tmp`` paths in tempdir fixtures.
- ``S110`` / ``S112`` — ``try``/``except``/``pass``/``continue`` in cleanup helpers.
Hand-fixes (38 across 23 files)
================================
S101 × 3
Type-narrowing asserts (``cli/deploy/development/proc.py`` —
``p.stdout is not None`` after ``stdout=PIPE``) and an upstream-Odoo
invariant assert in ``roles/web-app-odoo/.../res_users.py``.
S105 × 1
``cli/core/app.py`` argv parser comparing ``token == "--log"`` —
``token`` is a CLI argv element, not a credential. Inline noqa.
S106 × 1
``utils/cache/users.py`` Jinja template string ``"{{ 42 |
strong_password }}"`` rendered to a real password at runtime; not a
literal credential. Inline noqa.
S108 × 6
Container-internal paths bind-mounted from the host:
- ``/tmp/infinito/ca/root-ca.crt`` and ``/tmp/infinito/bin/with-ca-trust.sh``
in ``roles/sys-svc-compose-ca/files/compose_ca.py`` and
``roles/sys-svc-container/files/container.py`` (CA-injection
contract between role and wrapper).
- ``/tmp/cache_nginx_general/`` and ``/tmp/cache_nginx_image/`` in
``plugins/lookup/nginx.py`` (well-known nginx cache dirs).
S110 × 1
``roles/web-app-bigbluebutton/filter_plugins/compose_mods.py``: filter
intentionally swallows YAML parse errors and falls back to the
unmodified text. Inline noqa.
S112 × 5
Filter plugins / inventory builders iterating over role config files
with skip-on-malformed semantics. The pattern is intentional
throughout (CLI tools / Ansible plugins shouldn't crash on a single
bad role file). Per-line noqa with rationale.
S324 × 2
``utils/cache/base.py`` uses md5 as a fast non-cryptographic
fingerprint for cache keying; ``utils/manager/value_generator.py``
uses sha1 when the caller selects ``algorithm == "sha1"`` for legacy
app compatibility. Both gained ``usedforsecurity=False`` (Python 3.9+
hashlib API) which silences the rule structurally; comments document
the non-security purpose.
S310 × 8
``urlopen`` calls to fixed HTTPS registries (``api.github.com``,
``hub.docker.com``, ``ghcr.io``). URL prefix is a literal; only the
``namespace`` / ``image`` segment varies and comes from the project's
own ``meta/services.yml``, never user input. No ``file://`` scheme
exposure. Inline noqa per call site.
S602 / S605 / S606 × 8
Role-deployed scripts that legitimately use ``shell=True`` /
``os.system`` for pipes/``&&``, or ``os.execvp`` in list-form (safe by
construction — no shell parses argv). Each call site gained an inline
noqa with a per-call rationale identifying where the command string
is sourced (Ansible-templated config, host-trusted, never untrusted
external input).
Verification
============
- ``ruff check .`` → All checks passed
- ``ruff format --check .`` → 894 files formatted
- 1621 unit tests → all pass
- Integration suite: 44 baseline failures (unchanged)
23 files changed, +97 / −38. Combined with the prior commits the
branch satisfies 21 ruff rule families end-to-end. ``PTH``
(modernise ``os.path`` to ``pathlib``) is the remaining big-impact
candidate, deferred to its own session.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
186 lines
5.5 KiB
Python
186 lines
5.5 KiB
Python
from __future__ import annotations
|
|
|
|
import subprocess
|
|
import sys
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
|
|
from cli.core.colors import Fore, Style, color_text
|
|
from cli.core.discovery import resolve_command_module
|
|
from cli.core.git import git_clean_repo
|
|
from cli.core.help import (
|
|
print_global_help,
|
|
show_full_help_for_all,
|
|
show_help_for_directory,
|
|
)
|
|
from cli.core.run import RunConfig, open_log_file, run_command_once
|
|
|
|
from . import PROJECT_ROOT
|
|
|
|
|
|
@dataclass
|
|
class Flags:
|
|
log_dir: Path | None = None
|
|
git_clean: bool = False
|
|
infinite: bool = False
|
|
help_all: bool = False
|
|
|
|
|
|
def _first_non_flag_token(argv: list[str]) -> str | None:
|
|
"""
|
|
Return the first non-flag token after argv[0], but treat '--log <ARG>' as a flag
|
|
with a required argument (skip both tokens).
|
|
"""
|
|
i = 1
|
|
while i < len(argv):
|
|
token = argv[i]
|
|
|
|
# Skip flags and their args
|
|
if token == "--log": # noqa: S105 `token` is a CLI argv element, not a credential
|
|
# Skip '--log' and its argument if present
|
|
i += 2
|
|
continue
|
|
|
|
if token.startswith("-"):
|
|
i += 1
|
|
continue
|
|
|
|
return token
|
|
|
|
return None
|
|
|
|
|
|
def _parse_log_dir(argv: list[str]) -> Path | None:
|
|
"""
|
|
Parse and remove '--log <LOG_DIR>' from argv.
|
|
|
|
- The log path argument is mandatory when --log is present.
|
|
- Logging is only allowed for `deploy` commands. If used with a different
|
|
top-level command, it is silently ignored (but still removed from argv).
|
|
"""
|
|
if "--log" not in argv:
|
|
return None
|
|
|
|
i = argv.index("--log")
|
|
if i + 1 >= len(argv):
|
|
print(
|
|
color_text(
|
|
"Error: --log requires a path argument (e.g. --log /tmp/infinito-logs).",
|
|
Fore.RED,
|
|
)
|
|
)
|
|
raise SystemExit(1)
|
|
|
|
raw = argv[i + 1]
|
|
if raw.startswith("-"):
|
|
print(
|
|
color_text(
|
|
"Error: --log requires a path argument (e.g. --log /tmp/infinito-logs).",
|
|
Fore.RED,
|
|
)
|
|
)
|
|
raise SystemExit(1)
|
|
|
|
# Determine the first command token (skipping '--log <ARG>' properly)
|
|
first_cmd = _first_non_flag_token(argv)
|
|
|
|
# Always remove '--log <ARG>' from argv
|
|
del argv[i : i + 2]
|
|
|
|
# Keep previous behavior: only enable logging for "deploy"
|
|
if first_cmd != "deploy":
|
|
return None
|
|
|
|
return Path(raw).expanduser()
|
|
|
|
|
|
def parse_flags(argv: list[str]) -> Flags:
|
|
flags = Flags()
|
|
flags.log_dir = _parse_log_dir(argv)
|
|
|
|
flags.git_clean = "--git-clean" in argv and (argv.remove("--git-clean") or True)
|
|
flags.infinite = "--infinite" in argv and (argv.remove("--infinite") or True)
|
|
flags.help_all = "--help-all" in argv and (argv.remove("--help-all") or True)
|
|
|
|
return flags
|
|
|
|
|
|
def main() -> None:
|
|
argv = sys.argv[:] # keep sys.argv for external tools, but parse on a copy
|
|
flags = parse_flags(argv)
|
|
args = argv[1:]
|
|
|
|
cli_dir = PROJECT_ROOT / "cli"
|
|
# sanity: cli_dir should contain __init__.py and __main__.py of dispatcher
|
|
# but we do not hard-fail here
|
|
|
|
if flags.git_clean:
|
|
git_clean_repo()
|
|
|
|
# Global "show help for all commands" mode
|
|
if flags.help_all:
|
|
print_global_help(cli_dir)
|
|
print(color_text("Full detailed help for all subcommands:", Style.BRIGHT))
|
|
print()
|
|
show_full_help_for_all(cli_dir)
|
|
raise SystemExit(0)
|
|
|
|
# Global help
|
|
if not args or args[0] in ("-h", "--help"):
|
|
print_global_help(cli_dir)
|
|
raise SystemExit(0)
|
|
|
|
# Directory-specific help: "<path> -h"
|
|
if len(args) > 1 and args[-1] in ("-h", "--help"):
|
|
# First: if "<path>" is a real command, forward help to its argparse
|
|
module, remaining = resolve_command_module(cli_dir, args[:-1])
|
|
if module and not remaining:
|
|
subprocess.run([sys.executable, "-m", module, "--help"])
|
|
raise SystemExit(0)
|
|
|
|
# Otherwise: treat it as a folder overview
|
|
dir_parts = args[:-1]
|
|
if show_help_for_directory(cli_dir, dir_parts):
|
|
raise SystemExit(0)
|
|
|
|
# Resolve command module by package folders with __main__.py
|
|
module, remaining = resolve_command_module(cli_dir, args)
|
|
if not module:
|
|
print(color_text(f"Error: command '{' '.join(args)}' not found.", Fore.RED))
|
|
raise SystemExit(1)
|
|
|
|
# If user requested help for the resolved command, forward directly
|
|
if remaining and remaining[0] in ("-h", "--help"):
|
|
subprocess.run([sys.executable, "-m", module, remaining[0]])
|
|
raise SystemExit(0)
|
|
|
|
log_file = None
|
|
if flags.log_dir is not None:
|
|
log_file, log_path = open_log_file(flags.log_dir)
|
|
print(color_text(f"Tip: Log file created at {log_path}", Fore.GREEN))
|
|
|
|
full_cmd = [sys.executable, "-m", module, *remaining]
|
|
|
|
cfg = RunConfig(
|
|
log_enabled=flags.log_dir is not None,
|
|
)
|
|
|
|
try:
|
|
if flags.infinite:
|
|
print(color_text("Starting infinite execution mode...", Fore.CYAN))
|
|
count = 1
|
|
while True:
|
|
print(color_text(f"Run #{count}", Style.BRIGHT))
|
|
run_command_once(full_cmd, cfg, log_file)
|
|
count += 1
|
|
else:
|
|
run_command_once(full_cmd, cfg, log_file)
|
|
raise SystemExit(0)
|
|
except KeyboardInterrupt:
|
|
print()
|
|
print(color_text("Execution interrupted by user (Ctrl+C).", Fore.YELLOW))
|
|
raise SystemExit(130) from None
|
|
finally:
|
|
if log_file:
|
|
log_file.close()
|