runspec-linux 0.4.2__tar.gz → 0.5.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.

Potentially problematic release.


This version of runspec-linux might be problematic. Click here for more details.

Files changed (22) hide show
  1. {runspec_linux-0.4.2 → runspec_linux-0.5.0}/CHANGELOG.md +43 -0
  2. {runspec_linux-0.4.2 → runspec_linux-0.5.0}/PKG-INFO +2 -1
  3. {runspec_linux-0.4.2 → runspec_linux-0.5.0}/pyproject.toml +11 -1
  4. {runspec_linux-0.4.2 → runspec_linux-0.5.0}/runspec_linux/runspec.toml +241 -0
  5. runspec_linux-0.5.0/runspec_linux/webops.py +148 -0
  6. runspec_linux-0.5.0/tests/test_webops.py +107 -0
  7. {runspec_linux-0.4.2 → runspec_linux-0.5.0}/.gitignore +0 -0
  8. {runspec_linux-0.4.2 → runspec_linux-0.5.0}/runspec_linux/__init__.py +0 -0
  9. {runspec_linux-0.4.2 → runspec_linux-0.5.0}/runspec_linux/containers.py +0 -0
  10. {runspec_linux-0.4.2 → runspec_linux-0.5.0}/runspec_linux/files.py +0 -0
  11. {runspec_linux-0.4.2 → runspec_linux-0.5.0}/runspec_linux/logs.py +0 -0
  12. {runspec_linux-0.4.2 → runspec_linux-0.5.0}/runspec_linux/nc_command.py +0 -0
  13. {runspec_linux-0.4.2 → runspec_linux-0.5.0}/runspec_linux/network.py +0 -0
  14. {runspec_linux-0.4.2 → runspec_linux-0.5.0}/runspec_linux/packages.py +0 -0
  15. {runspec_linux-0.4.2 → runspec_linux-0.5.0}/runspec_linux/power.py +0 -0
  16. {runspec_linux-0.4.2 → runspec_linux-0.5.0}/runspec_linux/security.py +0 -0
  17. {runspec_linux-0.4.2 → runspec_linux-0.5.0}/runspec_linux/services.py +0 -0
  18. {runspec_linux-0.4.2 → runspec_linux-0.5.0}/runspec_linux/sudoers.py +0 -0
  19. {runspec_linux-0.4.2 → runspec_linux-0.5.0}/runspec_linux/system_info.py +0 -0
  20. {runspec_linux-0.4.2 → runspec_linux-0.5.0}/runspec_linux/venvs.py +0 -0
  21. {runspec_linux-0.4.2 → runspec_linux-0.5.0}/tests/__init__.py +0 -0
  22. {runspec_linux-0.4.2 → runspec_linux-0.5.0}/tests/test_nc_send.py +0 -0
@@ -1,5 +1,48 @@
1
1
  # runspec-linux Changelog
2
2
 
3
+ ## [0.5.0] — 2026-06-22
4
+
5
+ Add 8 cross-platform **web / TLS / API** runnables — thin wrappers over the new
6
+ `runspec-webops-core` (a new dependency). All are read-only network probes
7
+ (`autonomy = "autonomous"`, `output = "json"`) and use only the Python stdlib,
8
+ so the same runnables ship in `runspec-windows` too. They are a Python port of
9
+ the `runspec-webops` Node package (plus four extras), so a fleet that can't run
10
+ Node still gets them.
11
+
12
+ - **`cert-check`** — inspect a host's TLS certificate (days-to-expiry, issuer,
13
+ SANs, fingerprint, trust). Exits `1` when expiring/expired, `2` when
14
+ unreachable. Args: `--port`/`-p` (443), `--warn-days`/`-w` (30),
15
+ `--timeout`/`-t` (10).
16
+ - **`cert-chain`** — print the full certificate chain (leaf → intermediates →
17
+ root), flagging the self-signed root.
18
+ - **`tls-info`** — report the negotiated TLS protocol, cipher, and trust result.
19
+ - **`http-check`** — probe an HTTP(S) URL (status, response time, redirect
20
+ target); `--expect-status`/`-e` exits non-zero on a mismatch; `--insecure`/`-k`
21
+ skips TLS verification.
22
+ - **`http-headers`** — fetch response headers and flag missing security headers
23
+ (HSTS, CSP, X-Frame-Options, …); `--method`/`-m` (HEAD/GET).
24
+ - **`redirect-trace`** — follow the redirect chain hop by hop with loop
25
+ detection; `--max-hops`/`-n` (10).
26
+ - **`dns-lookup`** — resolve a hostname (A/AAAA + canonical name) or
27
+ reverse-resolve an IP; `--reverse`/`-r` also PTRs each resolved address.
28
+ - **`api-spec`** — read an OpenAPI/Swagger spec (`source` = an http(s) URL or a
29
+ local file) and condense its shape to bounded JSON, so an agent can write or
30
+ update the runnables that call an internal API (and re-check when it drifts).
31
+ Default is an overview (info, servers, every operation), narrowable with
32
+ `--tag` / `--match`; `--operation`/`-o` (or `--path` + `--method`/`-X`) drills
33
+ into one operation's params, request body and responses with `$ref`s resolved.
34
+ OpenAPI 3.x and Swagger 2.0; JSON needs no extra, YAML needs
35
+ `runspec-webops-core[yaml]`.
36
+
37
+ ## [0.4.3] — 2026-06-21
38
+
39
+ - **`install-into-venv --venv` and `configure-pip --venv` are now marked
40
+ `ui = "directory"`.** Both point at an existing venv *folder*, so the console's
41
+ path picker opens a folder picker ("Use this folder") instead of a file picker
42
+ — it was previously unclear the arg wanted the venv directory, not the
43
+ `bin/python` inside it. (`install-into-venv --project` stays a generic `path`
44
+ since it accepts either a project directory or a wheel/sdist file.)
45
+
3
46
  ## [0.4.2] — 2026-06-21
4
47
 
5
48
  Add 3 runnables for managing Python virtualenvs on a fleet host — thin wrappers
@@ -1,9 +1,10 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: runspec-linux
3
- Version: 0.4.2
3
+ Version: 0.5.0
4
4
  Summary: Linux system admin runnables for runspec
5
5
  Requires-Python: >=3.10
6
6
  Requires-Dist: runspec-linux-core>=0.3.2
7
+ Requires-Dist: runspec-webops-core>=0.1.0
7
8
  Requires-Dist: runspec>=0.27.0
8
9
  Provides-Extra: dev
9
10
  Requires-Dist: mypy; extra == 'dev'
@@ -4,12 +4,13 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "runspec-linux"
7
- version = "0.4.2"
7
+ version = "0.5.0"
8
8
  requires-python = ">=3.10"
9
9
  description = "Linux system admin runnables for runspec"
10
10
  dependencies = [
11
11
  "runspec>=0.27.0",
12
12
  "runspec-linux-core>=0.3.2",
13
+ "runspec-webops-core>=0.1.0",
13
14
  ]
14
15
 
15
16
  [project.scripts]
@@ -44,6 +45,15 @@ create-venv = "runspec_linux.venvs:main_create_venv"
44
45
  install-into-venv = "runspec_linux.venvs:main_install_into_venv"
45
46
  configure-pip = "runspec_linux.venvs:main_configure_pip"
46
47
  nc-command = "runspec_linux.nc_command:main"
48
+ # Web / TLS probes (runspec-webops-core; cross-platform)
49
+ cert-check = "runspec_linux.webops:main_cert_check"
50
+ cert-chain = "runspec_linux.webops:main_cert_chain"
51
+ tls-info = "runspec_linux.webops:main_tls_info"
52
+ http-check = "runspec_linux.webops:main_http_check"
53
+ http-headers = "runspec_linux.webops:main_http_headers"
54
+ redirect-trace = "runspec_linux.webops:main_redirect_trace"
55
+ dns-lookup = "runspec_linux.webops:main_dns_lookup"
56
+ api-spec = "runspec_linux.webops:main_api_spec"
47
57
 
48
58
  [project.optional-dependencies]
49
59
  dev = [
@@ -508,6 +508,7 @@ output = "json"
508
508
 
509
509
  [install-into-venv.args.venv]
510
510
  type = "path"
511
+ ui = "directory"
511
512
  short = "-v"
512
513
  description = "Path to the venv to install into (its bundled python runs pip)"
513
514
 
@@ -611,6 +612,7 @@ description = "Where to write pip.conf: user (~/.config/pip/pip.conf) or venv (<
611
612
 
612
613
  [configure-pip.args.venv]
613
614
  type = "path"
615
+ ui = "directory"
614
616
  description = "Venv path for --scope venv (the file is written to <venv>/pip.conf)"
615
617
  required = false
616
618
 
@@ -625,3 +627,242 @@ type = "choice"
625
627
  options = ["sudo", "su"]
626
628
  default = "sudo"
627
629
  description = "How to escalate to run-as: sudo (passwordless, -n) or su"
630
+
631
+ # ── Web / TLS probes (runspec-webops-core; cross-platform, read-only) ──────────
632
+
633
+ [cert-check]
634
+ serve = ["local"]
635
+ description = "Inspect a host's TLS certificate — days-to-expiry, issuer, SANs, fingerprint, trust"
636
+ autonomy = "autonomous"
637
+ output = "json"
638
+
639
+ [cert-check.args.host]
640
+ type = "str"
641
+ position = 1
642
+ description = "Hostname to connect to"
643
+
644
+ [cert-check.args.port]
645
+ type = "int"
646
+ short = "-p"
647
+ description = "TLS port"
648
+ default = 443
649
+
650
+ [cert-check.args.warn-days]
651
+ type = "int"
652
+ short = "-w"
653
+ description = "Report 'expiring' when fewer than N days remain"
654
+ default = 30
655
+
656
+ [cert-check.args.timeout]
657
+ type = "int"
658
+ short = "-t"
659
+ description = "Connection timeout in seconds"
660
+ default = 10
661
+
662
+ [cert-chain]
663
+ serve = ["local"]
664
+ description = "Show the full TLS certificate chain (leaf -> intermediates -> root)"
665
+ autonomy = "autonomous"
666
+ output = "json"
667
+
668
+ [cert-chain.args.host]
669
+ type = "str"
670
+ position = 1
671
+ description = "Hostname to connect to"
672
+
673
+ [cert-chain.args.port]
674
+ type = "int"
675
+ short = "-p"
676
+ description = "TLS port"
677
+ default = 443
678
+
679
+ [cert-chain.args.timeout]
680
+ type = "int"
681
+ short = "-t"
682
+ description = "Connection timeout in seconds"
683
+ default = 10
684
+
685
+ [tls-info]
686
+ serve = ["local"]
687
+ description = "Report the negotiated TLS protocol, cipher, and trust result for a host"
688
+ autonomy = "autonomous"
689
+ output = "json"
690
+
691
+ [tls-info.args.host]
692
+ type = "str"
693
+ position = 1
694
+ description = "Hostname to connect to"
695
+
696
+ [tls-info.args.port]
697
+ type = "int"
698
+ short = "-p"
699
+ description = "TLS port"
700
+ default = 443
701
+
702
+ [tls-info.args.timeout]
703
+ type = "int"
704
+ short = "-t"
705
+ description = "Connection timeout in seconds"
706
+ default = 10
707
+
708
+ [http-check]
709
+ serve = ["local"]
710
+ description = "Probe an HTTP(S) URL — status code, response time, redirect target"
711
+ autonomy = "autonomous"
712
+ output = "json"
713
+
714
+ [http-check.args.url]
715
+ type = "str"
716
+ position = 1
717
+ description = "URL to request"
718
+
719
+ [http-check.args.timeout]
720
+ type = "int"
721
+ short = "-t"
722
+ description = "Request timeout in seconds"
723
+ default = 10
724
+
725
+ [http-check.args.expect-status]
726
+ type = "int"
727
+ short = "-e"
728
+ description = "Exit non-zero unless the response status matches this code"
729
+ required = false
730
+
731
+ [http-check.args.insecure]
732
+ type = "flag"
733
+ short = "-k"
734
+ description = "Skip TLS certificate verification"
735
+ default = false
736
+
737
+ [http-headers]
738
+ serve = ["local"]
739
+ description = "Fetch an endpoint's response headers and flag missing security headers"
740
+ autonomy = "autonomous"
741
+ output = "json"
742
+
743
+ [http-headers.args.url]
744
+ type = "str"
745
+ position = 1
746
+ description = "URL to request"
747
+
748
+ [http-headers.args.method]
749
+ type = "choice"
750
+ options = ["HEAD", "GET"]
751
+ short = "-m"
752
+ description = "HTTP method to use for the header probe"
753
+ default = "HEAD"
754
+
755
+ [http-headers.args.timeout]
756
+ type = "int"
757
+ short = "-t"
758
+ description = "Request timeout in seconds"
759
+ default = 10
760
+
761
+ [http-headers.args.insecure]
762
+ type = "flag"
763
+ short = "-k"
764
+ description = "Skip TLS certificate verification"
765
+ default = false
766
+
767
+ [redirect-trace]
768
+ serve = ["local"]
769
+ description = "Follow an HTTP redirect chain and list each hop with status and Location"
770
+ autonomy = "autonomous"
771
+ output = "json"
772
+
773
+ [redirect-trace.args.url]
774
+ type = "str"
775
+ position = 1
776
+ description = "URL to start from"
777
+
778
+ [redirect-trace.args.timeout]
779
+ type = "int"
780
+ short = "-t"
781
+ description = "Per-request timeout in seconds"
782
+ default = 10
783
+
784
+ [redirect-trace.args.max-hops]
785
+ type = "int"
786
+ short = "-n"
787
+ description = "Maximum number of redirects to follow"
788
+ default = 10
789
+
790
+ [redirect-trace.args.insecure]
791
+ type = "flag"
792
+ short = "-k"
793
+ description = "Skip TLS certificate verification"
794
+ default = false
795
+
796
+ [dns-lookup]
797
+ serve = ["local"]
798
+ description = "Resolve a hostname (A/AAAA + canonical name), or reverse-resolve an IP"
799
+ autonomy = "autonomous"
800
+ output = "json"
801
+
802
+ [dns-lookup.args.host]
803
+ type = "str"
804
+ position = 1
805
+ description = "Hostname to resolve, or an IP address to reverse-resolve"
806
+
807
+ [dns-lookup.args.reverse]
808
+ type = "flag"
809
+ short = "-r"
810
+ description = "Also reverse-resolve (PTR) each resolved address"
811
+ default = false
812
+
813
+ [api-spec]
814
+ serve = ["local"]
815
+ description = "Summarise an OpenAPI/Swagger spec (URL or file) — operations, params, schemas — to help write/update runnables for an API"
816
+ autonomy = "autonomous"
817
+ output = "json"
818
+
819
+ examples = [
820
+ {cmd = "api-spec https://api.internal/openapi.json", description = "Overview: info, servers, and every operation"},
821
+ {cmd = "api-spec https://api.internal/openapi.json --tag orders", description = "Only operations tagged 'orders'"},
822
+ {cmd = "api-spec ./openapi.json --operation createOrder", description = "Full detail for one operation (params, body, responses)"},
823
+ {cmd = "api-spec ./openapi.json --path /orders --method post", description = "Drill into an operation by path + method"},
824
+ ]
825
+
826
+ [api-spec.args.source]
827
+ type = "str"
828
+ position = 1
829
+ description = "OpenAPI/Swagger spec — an http(s) URL or a local file path (JSON always; YAML needs the pyyaml extra)"
830
+
831
+ [api-spec.args.operation]
832
+ type = "str"
833
+ short = "-o"
834
+ description = "operationId to describe in full (params, request body, responses)"
835
+ required = false
836
+
837
+ [api-spec.args.path]
838
+ type = "str"
839
+ description = "API path to describe in full (pair with --method); alternative to --operation"
840
+ required = false
841
+
842
+ [api-spec.args.method]
843
+ type = "str"
844
+ short = "-X"
845
+ description = "HTTP method for --path (e.g. get, post)"
846
+ required = false
847
+
848
+ [api-spec.args.tag]
849
+ type = "str"
850
+ description = "Overview only: limit to operations carrying this tag"
851
+ required = false
852
+
853
+ [api-spec.args.match]
854
+ type = "str"
855
+ description = "Overview only: limit to operations whose path contains this substring"
856
+ required = false
857
+
858
+ [api-spec.args.timeout]
859
+ type = "int"
860
+ short = "-t"
861
+ description = "Fetch timeout in seconds (URL sources)"
862
+ default = 15
863
+
864
+ [api-spec.args.insecure]
865
+ type = "flag"
866
+ short = "-k"
867
+ description = "Skip TLS certificate verification when fetching the spec"
868
+ default = false
@@ -0,0 +1,148 @@
1
+ """Web/TLS runnables — thin cross-platform wrappers over runspec-webops-core.
2
+
3
+ These probe remote HTTP(S)/TLS endpoints and resolve DNS using only the Python
4
+ stdlib (via ``runspec-webops-core``), so they behave identically on Linux and
5
+ Windows. All are read-only network probes (``autonomy = "autonomous"``).
6
+
7
+ Exit codes follow the web-monitor convention so a caller can branch on them:
8
+ ``0`` success, ``1`` a reportable problem (expiring/expired cert, unexpected
9
+ status), ``2`` the endpoint was unreachable.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import sys
16
+
17
+ import runspec as rs
18
+ from runspec_webops_core import (
19
+ ConnectError,
20
+ ResolveError,
21
+ SpecError,
22
+ chain_report,
23
+ connect_peer,
24
+ describe_operation,
25
+ fetch_headers,
26
+ leaf_report,
27
+ load_document,
28
+ probe,
29
+ resolve,
30
+ summarize_spec,
31
+ trace_redirects,
32
+ )
33
+
34
+
35
+ def _emit(obj: object) -> None:
36
+ print(json.dumps(obj))
37
+
38
+
39
+ def main_cert_check() -> None:
40
+ spec = rs.parse("cert-check")
41
+ host, port = spec.host.value, spec.port.value
42
+ try:
43
+ peer = connect_peer(host, port, spec.timeout.value)
44
+ report = leaf_report(peer, spec.warn_days.value)
45
+ except ConnectError as e:
46
+ _emit({"error": str(e), "host": host, "port": port})
47
+ sys.exit(2)
48
+ _emit({"host": host, "port": port, **report})
49
+ if report["status"] != "ok":
50
+ sys.exit(1)
51
+
52
+
53
+ def main_cert_chain() -> None:
54
+ spec = rs.parse("cert-chain")
55
+ host, port = spec.host.value, spec.port.value
56
+ try:
57
+ peer = connect_peer(host, port, spec.timeout.value)
58
+ except ConnectError as e:
59
+ _emit({"error": str(e), "host": host, "port": port})
60
+ sys.exit(2)
61
+ _emit({"host": host, "port": port, "chain": chain_report(peer)})
62
+
63
+
64
+ def main_tls_info() -> None:
65
+ spec = rs.parse("tls-info")
66
+ host, port = spec.host.value, spec.port.value
67
+ try:
68
+ peer = connect_peer(host, port, spec.timeout.value)
69
+ except ConnectError as e:
70
+ _emit({"error": str(e), "host": host, "port": port})
71
+ sys.exit(2)
72
+ _emit(
73
+ {
74
+ "host": host,
75
+ "port": port,
76
+ "protocol": peer.protocol,
77
+ "cipher": peer.cipher_name,
78
+ "cipher_version": peer.cipher_version,
79
+ "authorized": peer.authorized,
80
+ "authorization_error": peer.authorization_error,
81
+ }
82
+ )
83
+
84
+
85
+ def main_http_check() -> None:
86
+ spec = rs.parse("http-check")
87
+ url = spec.url.value
88
+ expect = spec.expect_status.value
89
+ try:
90
+ result = probe(url, spec.timeout.value, insecure=spec.insecure.value)
91
+ except ConnectError as e:
92
+ _emit({"error": str(e), "url": url})
93
+ sys.exit(2)
94
+ _emit(result)
95
+ if expect is not None and result["status"] != expect:
96
+ sys.exit(1)
97
+
98
+
99
+ def main_http_headers() -> None:
100
+ spec = rs.parse("http-headers")
101
+ url = spec.url.value
102
+ try:
103
+ result = fetch_headers(url, spec.timeout.value, method=spec.method.value, insecure=spec.insecure.value)
104
+ except ConnectError as e:
105
+ _emit({"error": str(e), "url": url})
106
+ sys.exit(2)
107
+ _emit(result)
108
+
109
+
110
+ def main_redirect_trace() -> None:
111
+ spec = rs.parse("redirect-trace")
112
+ url = spec.url.value
113
+ try:
114
+ result = trace_redirects(url, spec.timeout.value, max_hops=spec.max_hops.value, insecure=spec.insecure.value)
115
+ except ConnectError as e:
116
+ _emit({"error": str(e), "url": url})
117
+ sys.exit(2)
118
+ _emit(result)
119
+
120
+
121
+ def main_dns_lookup() -> None:
122
+ spec = rs.parse("dns-lookup")
123
+ host = spec.host.value
124
+ try:
125
+ _emit(resolve(host, want_reverse=spec.reverse.value))
126
+ except ResolveError as e:
127
+ _emit({"error": str(e), "host": host})
128
+ sys.exit(1)
129
+
130
+
131
+ def main_api_spec() -> None:
132
+ spec = rs.parse("api-spec")
133
+ source = spec.source.value
134
+ try:
135
+ doc = load_document(source, spec.timeout.value, insecure=spec.insecure.value)
136
+ if spec.operation.value or spec.path.value:
137
+ result: object = describe_operation(
138
+ doc,
139
+ operation_id=spec.operation.value or None,
140
+ path=spec.path.value or None,
141
+ method=spec.method.value or None,
142
+ )
143
+ else:
144
+ result = summarize_spec(doc, tag=spec.tag.value or None, path_filter=spec.match.value or None)
145
+ except SpecError as e:
146
+ _emit({"error": str(e), "source": source})
147
+ sys.exit(2)
148
+ _emit(result)
@@ -0,0 +1,107 @@
1
+ """The webops runnables parse from runspec.toml and the wrappers emit JSON + exit codes."""
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+ import pytest
7
+ import runspec as rs
8
+
9
+ from runspec_linux import webops
10
+
11
+ TOML = Path(__file__).resolve().parent.parent / "runspec_linux" / "runspec.toml"
12
+
13
+ # Each new runnable plus a minimal valid argv (its required positional).
14
+ WEBOPS_RUNNABLES = {
15
+ "cert-check": ["example.com"],
16
+ "cert-chain": ["example.com"],
17
+ "tls-info": ["example.com"],
18
+ "http-check": ["https://example.com"],
19
+ "http-headers": ["https://example.com"],
20
+ "redirect-trace": ["https://example.com"],
21
+ "dns-lookup": ["example.com"],
22
+ "api-spec": ["/tmp/spec.json"],
23
+ }
24
+
25
+
26
+ @pytest.mark.parametrize("name", list(WEBOPS_RUNNABLES))
27
+ def test_runnable_is_defined(name: str) -> None:
28
+ spec = rs.parse(name, WEBOPS_RUNNABLES[name], config_path=TOML)
29
+ assert spec.runspec_runnable == name
30
+
31
+
32
+ def test_cert_check_defaults_and_positional() -> None:
33
+ spec = rs.parse("cert-check", ["example.com"], config_path=TOML)
34
+ assert spec.host.value == "example.com"
35
+ assert spec.port.value == 443
36
+ assert spec.warn_days.value == 30
37
+ assert spec.timeout.value == 10
38
+
39
+
40
+ def test_http_check_flags() -> None:
41
+ spec = rs.parse("http-check", ["https://x.test", "--expect-status", "200", "--insecure"], config_path=TOML)
42
+ assert spec.url.value == "https://x.test"
43
+ assert spec.expect_status.value == 200
44
+ assert spec.insecure.value is True
45
+
46
+
47
+ def _fixed_parse(name: str, argv: list[str]):
48
+ """A real parse bound to a runnable + argv, for patching ``webops.rs.parse``."""
49
+ real = rs.parse
50
+ return lambda *_a, **_k: real(name, argv, config_path=TOML)
51
+
52
+
53
+ def test_main_cert_check_problem_exits_1(monkeypatch, capsys) -> None:
54
+ monkeypatch.setattr(webops.rs, "parse", _fixed_parse("cert-check", ["expired.test"]))
55
+ monkeypatch.setattr(webops, "connect_peer", lambda *a, **k: object())
56
+ monkeypatch.setattr(webops, "leaf_report", lambda *a, **k: {"status": "expired", "days_remaining": -3})
57
+ with pytest.raises(SystemExit) as exc:
58
+ webops.main_cert_check()
59
+ assert exc.value.code == 1
60
+ out = json.loads(capsys.readouterr().out)
61
+ assert out["host"] == "expired.test"
62
+ assert out["status"] == "expired"
63
+
64
+
65
+ def test_main_cert_check_unreachable_exits_2(monkeypatch, capsys) -> None:
66
+ monkeypatch.setattr(webops.rs, "parse", _fixed_parse("cert-check", ["down.test"]))
67
+
68
+ def boom(*_a, **_k):
69
+ raise webops.ConnectError("refused")
70
+
71
+ monkeypatch.setattr(webops, "connect_peer", boom)
72
+ with pytest.raises(SystemExit) as exc:
73
+ webops.main_cert_check()
74
+ assert exc.value.code == 2
75
+ out = json.loads(capsys.readouterr().out)
76
+ assert "error" in out
77
+
78
+
79
+ def test_main_api_spec_overview(monkeypatch, capsys, tmp_path) -> None:
80
+ spec_file = tmp_path / "openapi.json"
81
+ spec_file.write_text(json.dumps({"openapi": "3.0.0", "info": {"title": "T", "version": "1"}, "paths": {"/x": {"get": {"operationId": "getX", "responses": {"200": {"description": "ok"}}}}}}))
82
+ monkeypatch.setattr(webops.rs, "parse", _fixed_parse("api-spec", [str(spec_file)]))
83
+ webops.main_api_spec()
84
+ out = json.loads(capsys.readouterr().out)
85
+ assert out["title"] == "T"
86
+ assert out["operations"][0]["operation_id"] == "getX"
87
+
88
+
89
+ def test_main_api_spec_bad_source_exits_2(monkeypatch, capsys) -> None:
90
+ monkeypatch.setattr(webops.rs, "parse", _fixed_parse("api-spec", ["/no/such/spec.json"]))
91
+ with pytest.raises(SystemExit) as exc:
92
+ webops.main_api_spec()
93
+ assert exc.value.code == 2
94
+ assert "error" in json.loads(capsys.readouterr().out)
95
+
96
+
97
+ def test_main_dns_lookup_resolve_error_exits_1(monkeypatch, capsys) -> None:
98
+ monkeypatch.setattr(webops.rs, "parse", _fixed_parse("dns-lookup", ["bad.invalid"]))
99
+
100
+ def boom(*_a, **_k):
101
+ raise webops.ResolveError("nxdomain")
102
+
103
+ monkeypatch.setattr(webops, "resolve", boom)
104
+ with pytest.raises(SystemExit) as exc:
105
+ webops.main_dns_lookup()
106
+ assert exc.value.code == 1
107
+ assert "error" in json.loads(capsys.readouterr().out)
File without changes