runspec-linux 0.4.3__tar.gz → 0.6.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 (23) hide show
  1. {runspec_linux-0.4.3 → runspec_linux-0.6.0}/CHANGELOG.md +50 -0
  2. {runspec_linux-0.4.3 → runspec_linux-0.6.0}/PKG-INFO +3 -2
  3. {runspec_linux-0.4.3 → runspec_linux-0.6.0}/pyproject.toml +13 -2
  4. {runspec_linux-0.4.3 → runspec_linux-0.6.0}/runspec_linux/__init__.py +2 -0
  5. runspec_linux-0.6.0/runspec_linux/commands.py +23 -0
  6. {runspec_linux-0.4.3 → runspec_linux-0.6.0}/runspec_linux/runspec.toml +269 -0
  7. runspec_linux-0.6.0/runspec_linux/webops.py +148 -0
  8. runspec_linux-0.6.0/tests/test_webops.py +107 -0
  9. {runspec_linux-0.4.3 → runspec_linux-0.6.0}/.gitignore +0 -0
  10. {runspec_linux-0.4.3 → runspec_linux-0.6.0}/runspec_linux/containers.py +0 -0
  11. {runspec_linux-0.4.3 → runspec_linux-0.6.0}/runspec_linux/files.py +0 -0
  12. {runspec_linux-0.4.3 → runspec_linux-0.6.0}/runspec_linux/logs.py +0 -0
  13. {runspec_linux-0.4.3 → runspec_linux-0.6.0}/runspec_linux/nc_command.py +0 -0
  14. {runspec_linux-0.4.3 → runspec_linux-0.6.0}/runspec_linux/network.py +0 -0
  15. {runspec_linux-0.4.3 → runspec_linux-0.6.0}/runspec_linux/packages.py +0 -0
  16. {runspec_linux-0.4.3 → runspec_linux-0.6.0}/runspec_linux/power.py +0 -0
  17. {runspec_linux-0.4.3 → runspec_linux-0.6.0}/runspec_linux/security.py +0 -0
  18. {runspec_linux-0.4.3 → runspec_linux-0.6.0}/runspec_linux/services.py +0 -0
  19. {runspec_linux-0.4.3 → runspec_linux-0.6.0}/runspec_linux/sudoers.py +0 -0
  20. {runspec_linux-0.4.3 → runspec_linux-0.6.0}/runspec_linux/system_info.py +0 -0
  21. {runspec_linux-0.4.3 → runspec_linux-0.6.0}/runspec_linux/venvs.py +0 -0
  22. {runspec_linux-0.4.3 → runspec_linux-0.6.0}/tests/__init__.py +0 -0
  23. {runspec_linux-0.4.3 → runspec_linux-0.6.0}/tests/test_nc_send.py +0 -0
@@ -1,5 +1,55 @@
1
1
  # runspec-linux Changelog
2
2
 
3
+ ## [0.6.0] — 2026-06-22
4
+
5
+ Add a **`which`** runnable — locate a command on the PATH and return its full
6
+ path, like the `which` command (`autonomy = "autonomous"`, `output = "json"`). A
7
+ thin wrapper over the new `runspec_linux_core.which` helper (requires
8
+ `runspec-linux-core >= 0.3.3`). Handy for finding an interpreter/binary path
9
+ (e.g. `which python3`) to feed another runnable such as `create-venv`, without
10
+ hunting through `/usr/bin` in the path browser.
11
+
12
+ - Positional `name` accepts one command or several space-separated
13
+ (`which "python3 pip3 git"`).
14
+ - `--all`/`-a` returns every match on the PATH, not just the first.
15
+ - `--path` overrides the search PATH (defaults to the host's `$PATH`).
16
+ - Prints `{path, all_found, results: [{name, found, path, paths}]}` and, mirroring
17
+ the `which` command, exits non-zero when any name was not found.
18
+
19
+ ## [0.5.0] — 2026-06-22
20
+
21
+ Add 8 cross-platform **web / TLS / API** runnables — thin wrappers over the new
22
+ `runspec-webops-core` (a new dependency). All are read-only network probes
23
+ (`autonomy = "autonomous"`, `output = "json"`) and use only the Python stdlib,
24
+ so the same runnables ship in `runspec-windows` too. They are a Python port of
25
+ the `runspec-webops` Node package (plus four extras), so a fleet that can't run
26
+ Node still gets them.
27
+
28
+ - **`cert-check`** — inspect a host's TLS certificate (days-to-expiry, issuer,
29
+ SANs, fingerprint, trust). Exits `1` when expiring/expired, `2` when
30
+ unreachable. Args: `--port`/`-p` (443), `--warn-days`/`-w` (30),
31
+ `--timeout`/`-t` (10).
32
+ - **`cert-chain`** — print the full certificate chain (leaf → intermediates →
33
+ root), flagging the self-signed root.
34
+ - **`tls-info`** — report the negotiated TLS protocol, cipher, and trust result.
35
+ - **`http-check`** — probe an HTTP(S) URL (status, response time, redirect
36
+ target); `--expect-status`/`-e` exits non-zero on a mismatch; `--insecure`/`-k`
37
+ skips TLS verification.
38
+ - **`http-headers`** — fetch response headers and flag missing security headers
39
+ (HSTS, CSP, X-Frame-Options, …); `--method`/`-m` (HEAD/GET).
40
+ - **`redirect-trace`** — follow the redirect chain hop by hop with loop
41
+ detection; `--max-hops`/`-n` (10).
42
+ - **`dns-lookup`** — resolve a hostname (A/AAAA + canonical name) or
43
+ reverse-resolve an IP; `--reverse`/`-r` also PTRs each resolved address.
44
+ - **`api-spec`** — read an OpenAPI/Swagger spec (`source` = an http(s) URL or a
45
+ local file) and condense its shape to bounded JSON, so an agent can write or
46
+ update the runnables that call an internal API (and re-check when it drifts).
47
+ Default is an overview (info, servers, every operation), narrowable with
48
+ `--tag` / `--match`; `--operation`/`-o` (or `--path` + `--method`/`-X`) drills
49
+ into one operation's params, request body and responses with `$ref`s resolved.
50
+ OpenAPI 3.x and Swagger 2.0; JSON needs no extra, YAML needs
51
+ `runspec-webops-core[yaml]`.
52
+
3
53
  ## [0.4.3] — 2026-06-21
4
54
 
5
55
  - **`install-into-venv --venv` and `configure-pip --venv` are now marked
@@ -1,9 +1,10 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: runspec-linux
3
- Version: 0.4.3
3
+ Version: 0.6.0
4
4
  Summary: Linux system admin runnables for runspec
5
5
  Requires-Python: >=3.10
6
- Requires-Dist: runspec-linux-core>=0.3.2
6
+ Requires-Dist: runspec-linux-core>=0.3.3
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.3"
7
+ version = "0.6.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
- "runspec-linux-core>=0.3.2",
12
+ "runspec-linux-core>=0.3.3",
13
+ "runspec-webops-core>=0.1.0",
13
14
  ]
14
15
 
15
16
  [project.scripts]
@@ -44,6 +45,16 @@ 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
+ which = "runspec_linux.commands:main_which"
49
+ # Web / TLS probes (runspec-webops-core; cross-platform)
50
+ cert-check = "runspec_linux.webops:main_cert_check"
51
+ cert-chain = "runspec_linux.webops:main_cert_chain"
52
+ tls-info = "runspec_linux.webops:main_tls_info"
53
+ http-check = "runspec_linux.webops:main_http_check"
54
+ http-headers = "runspec_linux.webops:main_http_headers"
55
+ redirect-trace = "runspec_linux.webops:main_redirect_trace"
56
+ dns-lookup = "runspec_linux.webops:main_dns_lookup"
57
+ api-spec = "runspec_linux.webops:main_api_spec"
47
58
 
48
59
  [project.optional-dependencies]
49
60
  dev = [
@@ -39,6 +39,7 @@ from runspec_linux_core import (
39
39
  system_info,
40
40
  tail_log,
41
41
  upgrade_packages,
42
+ which,
42
43
  who,
43
44
  )
44
45
 
@@ -74,5 +75,6 @@ __all__ = [
74
75
  "system_info",
75
76
  "tail_log",
76
77
  "upgrade_packages",
78
+ "which",
77
79
  "who",
78
80
  ]
@@ -0,0 +1,23 @@
1
+ import json
2
+ import sys
3
+
4
+ import runspec as rs
5
+ from runspec_linux_core import which
6
+ from runspec_linux_core.errors import LinuxCoreError
7
+
8
+
9
+ def main_which() -> None:
10
+ spec = rs.parse("which")
11
+ try:
12
+ result = which(
13
+ spec.name.value,
14
+ all_matches=spec.all.value,
15
+ path=spec.path.value or None,
16
+ )
17
+ except (ValueError, LinuxCoreError) as e:
18
+ print(json.dumps({"error": str(e)}))
19
+ sys.exit(1)
20
+ print(json.dumps(result))
21
+ # Mirror the `which` command: non-zero exit when any name was not found.
22
+ if not result["all_found"]:
23
+ sys.exit(1)
@@ -439,6 +439,36 @@ type = "float"
439
439
  description = "Per-chunk socket read timeout in seconds"
440
440
  default = 0.1
441
441
 
442
+ # ── Commands ──────────────────────────────────────────────────────────────────
443
+
444
+ [which]
445
+ serve = ["local"]
446
+ description = "Locate a command on the PATH and return its full path (like the `which` command) — handy for finding interpreter/binary paths to feed other runnables"
447
+ autonomy = "autonomous"
448
+ output = "json"
449
+
450
+ examples = [
451
+ {cmd = "which python3", description = "Resolve a command to its full path"},
452
+ {cmd = "which \"python3 pip3 git\"", description = "Resolve several commands at once (space-separated)"},
453
+ {cmd = "which python3 --all", description = "Show every match on the PATH, not just the first"},
454
+ ]
455
+
456
+ [which.args.name]
457
+ type = "str"
458
+ position = 1
459
+ description = "Command name(s) to locate on the PATH (space-separated for several)"
460
+
461
+ [which.args.all]
462
+ type = "flag"
463
+ short = "-a"
464
+ description = "Return every match found on the PATH, not just the first"
465
+ default = false
466
+
467
+ [which.args.path]
468
+ type = "str"
469
+ description = "Override the search PATH to use (defaults to the host's $PATH)"
470
+ required = false
471
+
442
472
  # ── Python venvs ──────────────────────────────────────────────────────────────
443
473
 
444
474
  # These deliberately declare NO `run_as` key. The service account that owns a
@@ -627,3 +657,242 @@ type = "choice"
627
657
  options = ["sudo", "su"]
628
658
  default = "sudo"
629
659
  description = "How to escalate to run-as: sudo (passwordless, -n) or su"
660
+
661
+ # ── Web / TLS probes (runspec-webops-core; cross-platform, read-only) ──────────
662
+
663
+ [cert-check]
664
+ serve = ["local"]
665
+ description = "Inspect a host's TLS certificate — days-to-expiry, issuer, SANs, fingerprint, trust"
666
+ autonomy = "autonomous"
667
+ output = "json"
668
+
669
+ [cert-check.args.host]
670
+ type = "str"
671
+ position = 1
672
+ description = "Hostname to connect to"
673
+
674
+ [cert-check.args.port]
675
+ type = "int"
676
+ short = "-p"
677
+ description = "TLS port"
678
+ default = 443
679
+
680
+ [cert-check.args.warn-days]
681
+ type = "int"
682
+ short = "-w"
683
+ description = "Report 'expiring' when fewer than N days remain"
684
+ default = 30
685
+
686
+ [cert-check.args.timeout]
687
+ type = "int"
688
+ short = "-t"
689
+ description = "Connection timeout in seconds"
690
+ default = 10
691
+
692
+ [cert-chain]
693
+ serve = ["local"]
694
+ description = "Show the full TLS certificate chain (leaf -> intermediates -> root)"
695
+ autonomy = "autonomous"
696
+ output = "json"
697
+
698
+ [cert-chain.args.host]
699
+ type = "str"
700
+ position = 1
701
+ description = "Hostname to connect to"
702
+
703
+ [cert-chain.args.port]
704
+ type = "int"
705
+ short = "-p"
706
+ description = "TLS port"
707
+ default = 443
708
+
709
+ [cert-chain.args.timeout]
710
+ type = "int"
711
+ short = "-t"
712
+ description = "Connection timeout in seconds"
713
+ default = 10
714
+
715
+ [tls-info]
716
+ serve = ["local"]
717
+ description = "Report the negotiated TLS protocol, cipher, and trust result for a host"
718
+ autonomy = "autonomous"
719
+ output = "json"
720
+
721
+ [tls-info.args.host]
722
+ type = "str"
723
+ position = 1
724
+ description = "Hostname to connect to"
725
+
726
+ [tls-info.args.port]
727
+ type = "int"
728
+ short = "-p"
729
+ description = "TLS port"
730
+ default = 443
731
+
732
+ [tls-info.args.timeout]
733
+ type = "int"
734
+ short = "-t"
735
+ description = "Connection timeout in seconds"
736
+ default = 10
737
+
738
+ [http-check]
739
+ serve = ["local"]
740
+ description = "Probe an HTTP(S) URL — status code, response time, redirect target"
741
+ autonomy = "autonomous"
742
+ output = "json"
743
+
744
+ [http-check.args.url]
745
+ type = "str"
746
+ position = 1
747
+ description = "URL to request"
748
+
749
+ [http-check.args.timeout]
750
+ type = "int"
751
+ short = "-t"
752
+ description = "Request timeout in seconds"
753
+ default = 10
754
+
755
+ [http-check.args.expect-status]
756
+ type = "int"
757
+ short = "-e"
758
+ description = "Exit non-zero unless the response status matches this code"
759
+ required = false
760
+
761
+ [http-check.args.insecure]
762
+ type = "flag"
763
+ short = "-k"
764
+ description = "Skip TLS certificate verification"
765
+ default = false
766
+
767
+ [http-headers]
768
+ serve = ["local"]
769
+ description = "Fetch an endpoint's response headers and flag missing security headers"
770
+ autonomy = "autonomous"
771
+ output = "json"
772
+
773
+ [http-headers.args.url]
774
+ type = "str"
775
+ position = 1
776
+ description = "URL to request"
777
+
778
+ [http-headers.args.method]
779
+ type = "choice"
780
+ options = ["HEAD", "GET"]
781
+ short = "-m"
782
+ description = "HTTP method to use for the header probe"
783
+ default = "HEAD"
784
+
785
+ [http-headers.args.timeout]
786
+ type = "int"
787
+ short = "-t"
788
+ description = "Request timeout in seconds"
789
+ default = 10
790
+
791
+ [http-headers.args.insecure]
792
+ type = "flag"
793
+ short = "-k"
794
+ description = "Skip TLS certificate verification"
795
+ default = false
796
+
797
+ [redirect-trace]
798
+ serve = ["local"]
799
+ description = "Follow an HTTP redirect chain and list each hop with status and Location"
800
+ autonomy = "autonomous"
801
+ output = "json"
802
+
803
+ [redirect-trace.args.url]
804
+ type = "str"
805
+ position = 1
806
+ description = "URL to start from"
807
+
808
+ [redirect-trace.args.timeout]
809
+ type = "int"
810
+ short = "-t"
811
+ description = "Per-request timeout in seconds"
812
+ default = 10
813
+
814
+ [redirect-trace.args.max-hops]
815
+ type = "int"
816
+ short = "-n"
817
+ description = "Maximum number of redirects to follow"
818
+ default = 10
819
+
820
+ [redirect-trace.args.insecure]
821
+ type = "flag"
822
+ short = "-k"
823
+ description = "Skip TLS certificate verification"
824
+ default = false
825
+
826
+ [dns-lookup]
827
+ serve = ["local"]
828
+ description = "Resolve a hostname (A/AAAA + canonical name), or reverse-resolve an IP"
829
+ autonomy = "autonomous"
830
+ output = "json"
831
+
832
+ [dns-lookup.args.host]
833
+ type = "str"
834
+ position = 1
835
+ description = "Hostname to resolve, or an IP address to reverse-resolve"
836
+
837
+ [dns-lookup.args.reverse]
838
+ type = "flag"
839
+ short = "-r"
840
+ description = "Also reverse-resolve (PTR) each resolved address"
841
+ default = false
842
+
843
+ [api-spec]
844
+ serve = ["local"]
845
+ description = "Summarise an OpenAPI/Swagger spec (URL or file) — operations, params, schemas — to help write/update runnables for an API"
846
+ autonomy = "autonomous"
847
+ output = "json"
848
+
849
+ examples = [
850
+ {cmd = "api-spec https://api.internal/openapi.json", description = "Overview: info, servers, and every operation"},
851
+ {cmd = "api-spec https://api.internal/openapi.json --tag orders", description = "Only operations tagged 'orders'"},
852
+ {cmd = "api-spec ./openapi.json --operation createOrder", description = "Full detail for one operation (params, body, responses)"},
853
+ {cmd = "api-spec ./openapi.json --path /orders --method post", description = "Drill into an operation by path + method"},
854
+ ]
855
+
856
+ [api-spec.args.source]
857
+ type = "str"
858
+ position = 1
859
+ description = "OpenAPI/Swagger spec — an http(s) URL or a local file path (JSON always; YAML needs the pyyaml extra)"
860
+
861
+ [api-spec.args.operation]
862
+ type = "str"
863
+ short = "-o"
864
+ description = "operationId to describe in full (params, request body, responses)"
865
+ required = false
866
+
867
+ [api-spec.args.path]
868
+ type = "str"
869
+ description = "API path to describe in full (pair with --method); alternative to --operation"
870
+ required = false
871
+
872
+ [api-spec.args.method]
873
+ type = "str"
874
+ short = "-X"
875
+ description = "HTTP method for --path (e.g. get, post)"
876
+ required = false
877
+
878
+ [api-spec.args.tag]
879
+ type = "str"
880
+ description = "Overview only: limit to operations carrying this tag"
881
+ required = false
882
+
883
+ [api-spec.args.match]
884
+ type = "str"
885
+ description = "Overview only: limit to operations whose path contains this substring"
886
+ required = false
887
+
888
+ [api-spec.args.timeout]
889
+ type = "int"
890
+ short = "-t"
891
+ description = "Fetch timeout in seconds (URL sources)"
892
+ default = 15
893
+
894
+ [api-spec.args.insecure]
895
+ type = "flag"
896
+ short = "-k"
897
+ description = "Skip TLS certificate verification when fetching the spec"
898
+ 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