proxcli 0.2.0__tar.gz → 0.3.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.
Files changed (52) hide show
  1. {proxcli-0.2.0 → proxcli-0.3.0}/.github/workflows/ci.yml +22 -3
  2. proxcli-0.3.0/AGENTS.md +123 -0
  3. {proxcli-0.2.0 → proxcli-0.3.0}/CHANGELOG.md +21 -0
  4. {proxcli-0.2.0 → proxcli-0.3.0}/PKG-INFO +1 -1
  5. proxcli-0.3.0/proxmox/cli/cluster.py +239 -0
  6. proxcli-0.3.0/proxmox/cli/firewall_helpers.py +60 -0
  7. {proxcli-0.2.0 → proxcli-0.3.0}/proxmox/cli/main.py +18 -3
  8. proxcli-0.3.0/proxmox/cli/node.py +177 -0
  9. {proxcli-0.2.0 → proxcli-0.3.0}/proxmox/cli/vm.py +159 -0
  10. {proxcli-0.2.0 → proxcli-0.3.0}/pyproject.toml +1 -1
  11. {proxcli-0.2.0 → proxcli-0.3.0}/uv.lock +2 -2
  12. proxcli-0.2.0/proxmox/cli/cluster.py +0 -21
  13. proxcli-0.2.0/proxmox/cli/node.py +0 -55
  14. {proxcli-0.2.0 → proxcli-0.3.0}/.env.example +0 -0
  15. {proxcli-0.2.0 → proxcli-0.3.0}/.gitignore +0 -0
  16. {proxcli-0.2.0 → proxcli-0.3.0}/.python-version +0 -0
  17. {proxcli-0.2.0 → proxcli-0.3.0}/PLAN.md +0 -0
  18. {proxcli-0.2.0 → proxcli-0.3.0}/PROJECT.md +0 -0
  19. {proxcli-0.2.0 → proxcli-0.3.0}/PROMPT.md +0 -0
  20. {proxcli-0.2.0 → proxcli-0.3.0}/README.md +0 -0
  21. {proxcli-0.2.0 → proxcli-0.3.0}/TODO.md +0 -0
  22. {proxcli-0.2.0 → proxcli-0.3.0}/proxmox/__init__.py +0 -0
  23. {proxcli-0.2.0 → proxcli-0.3.0}/proxmox/cli/__init__.py +0 -0
  24. {proxcli-0.2.0 → proxcli-0.3.0}/proxmox/cli/auth.py +0 -0
  25. {proxcli-0.2.0 → proxcli-0.3.0}/proxmox/cli/container.py +0 -0
  26. {proxcli-0.2.0 → proxcli-0.3.0}/proxmox/cli/storage.py +0 -0
  27. {proxcli-0.2.0 → proxcli-0.3.0}/proxmox/cli/tasks.py +0 -0
  28. {proxcli-0.2.0 → proxcli-0.3.0}/proxmox/client/__init__.py +0 -0
  29. {proxcli-0.2.0 → proxcli-0.3.0}/proxmox/client/auth.py +0 -0
  30. {proxcli-0.2.0 → proxcli-0.3.0}/proxmox/client/client.py +0 -0
  31. {proxcli-0.2.0 → proxcli-0.3.0}/proxmox/client/exceptions.py +0 -0
  32. {proxcli-0.2.0 → proxcli-0.3.0}/proxmox/config/__init__.py +0 -0
  33. {proxcli-0.2.0 → proxcli-0.3.0}/proxmox/config/config.py +0 -0
  34. {proxcli-0.2.0 → proxcli-0.3.0}/proxmox/config/models.py +0 -0
  35. {proxcli-0.2.0 → proxcli-0.3.0}/proxmox/output/__init__.py +0 -0
  36. {proxcli-0.2.0 → proxcli-0.3.0}/proxmox/output/formatter.py +0 -0
  37. {proxcli-0.2.0 → proxcli-0.3.0}/proxmox/output/json_fmt.py +0 -0
  38. {proxcli-0.2.0 → proxcli-0.3.0}/proxmox/output/table_fmt.py +0 -0
  39. {proxcli-0.2.0 → proxcli-0.3.0}/proxmox/output/yaml_fmt.py +0 -0
  40. {proxcli-0.2.0 → proxcli-0.3.0}/proxmox/utils/__init__.py +0 -0
  41. {proxcli-0.2.0 → proxcli-0.3.0}/proxmox/utils/helpers.py +0 -0
  42. {proxcli-0.2.0 → proxcli-0.3.0}/proxmox/utils/logging.py +0 -0
  43. {proxcli-0.2.0 → proxcli-0.3.0}/tests/__init__.py +0 -0
  44. {proxcli-0.2.0 → proxcli-0.3.0}/tests/conftest.py +0 -0
  45. {proxcli-0.2.0 → proxcli-0.3.0}/tests/test_auth.py +0 -0
  46. {proxcli-0.2.0 → proxcli-0.3.0}/tests/test_cli/__init__.py +0 -0
  47. {proxcli-0.2.0 → proxcli-0.3.0}/tests/test_cli/test_main.py +0 -0
  48. {proxcli-0.2.0 → proxcli-0.3.0}/tests/test_client.py +0 -0
  49. {proxcli-0.2.0 → proxcli-0.3.0}/tests/test_config.py +0 -0
  50. {proxcli-0.2.0 → proxcli-0.3.0}/tests/test_integration/__init__.py +0 -0
  51. {proxcli-0.2.0 → proxcli-0.3.0}/tests/test_output/__init__.py +0 -0
  52. {proxcli-0.2.0 → proxcli-0.3.0}/tests/test_output/test_formatter.py +0 -0
@@ -12,7 +12,7 @@ jobs:
12
12
  steps:
13
13
  - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
14
14
 
15
- - uses: astral-sh/setup-uv@d4a5f06b29e0840685266df17132b0834efba237 # v5.2.2
15
+ - uses: astral-sh/setup-uv@4db96194c378173c656ce18a155ffc14a9fc4355 # v5.2.2
16
16
  with:
17
17
  enable-cache: true
18
18
 
@@ -27,7 +27,7 @@ jobs:
27
27
  steps:
28
28
  - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
29
29
 
30
- - uses: astral-sh/setup-uv@d4a5f06b29e0840685266df17132b0834efba237 # v5.2.2
30
+ - uses: astral-sh/setup-uv@4db96194c378173c656ce18a155ffc14a9fc4355 # v5.2.2
31
31
  with:
32
32
  enable-cache: true
33
33
 
@@ -41,7 +41,7 @@ jobs:
41
41
  steps:
42
42
  - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
43
43
 
44
- - uses: astral-sh/setup-uv@d4a5f06b29e0840685266df17132b0834efba237 # v5.2.2
44
+ - uses: astral-sh/setup-uv@4db96194c378173c656ce18a155ffc14a9fc4355 # v5.2.2
45
45
  with:
46
46
  enable-cache: true
47
47
 
@@ -52,3 +52,22 @@ jobs:
52
52
  with:
53
53
  name: dist
54
54
  path: dist/
55
+
56
+ publish:
57
+ runs-on: ubuntu-24.04
58
+ if: github.ref == 'refs/heads/main'
59
+ needs: [build]
60
+ environment: pypi
61
+ steps:
62
+ - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
63
+
64
+ - uses: astral-sh/setup-uv@4db96194c378173c656ce18a155ffc14a9fc4355 # v5.2.2
65
+ with:
66
+ enable-cache: true
67
+
68
+ - uses: actions/download-artifact@95815c38cf2ff2164869cbab79da8d1f422bc89e # v4.2.1
69
+ with:
70
+ name: dist
71
+ path: dist/
72
+
73
+ - run: uv publish --token "${{ secrets.PYPI_TOKEN }}"
@@ -0,0 +1,123 @@
1
+ # Agent Guidelines for proxmox CLI
2
+
3
+ ## CLI Command Convention
4
+
5
+ All commands follow a strict **`<resource> <action> [positional_id] [--flags]`** pattern:
6
+
7
+ ```
8
+ proxmox <resource> <action> [id_or_name] [--options]
9
+ ```
10
+
11
+ ### Resource-level (top-level subcommands)
12
+
13
+ Every resource is a noun: `vm`, `container`, `node`, `storage`, `cluster`, `task`, `auth`.
14
+
15
+ Each has actions as verbs: `list`, `show`, `create`, `start`, `stop`, `delete`, etc.
16
+
17
+ ```
18
+ proxmox vm list
19
+ proxmox vm show 100
20
+ proxmox vm start 100
21
+ proxmox node show pve01
22
+ proxmox container list
23
+ ```
24
+
25
+ ### Nested resources (firewall, etc.)
26
+
27
+ When a resource has sub-resources, use the same **`<resource> <action> <subresource> [subaction]`** pattern:
28
+
29
+ ```
30
+ proxmox cluster firewall rules # list (shorthand)
31
+ proxmox cluster firewall rules list # list (explicit)
32
+ proxmox cluster firewall rules add --action ACCEPT ...
33
+ proxmox cluster firewall rules show <pos>
34
+ proxmox cluster firewall rules delete <pos>
35
+ proxmox vm firewall rules list <vmid>
36
+ proxmox vm firewall rules add <vmid> --action ACCEPT ...
37
+ proxmox node firewall rules list <node_name>
38
+ proxmox node firewall rules add <node_name> --action ACCEPT ...
39
+ proxmox cluster firewall aliases # list (shorthand)
40
+ proxmox cluster firewall aliases add <name> --cidr ...
41
+ proxmox cluster firewall ipsets # list (shorthand)
42
+ proxmox cluster firewall ipsets add <name> ...
43
+ ```
44
+
45
+ ### Key rules
46
+
47
+ 1. **Nouns before verbs** — `proxmox vm firewall rules list`, NOT `proxmox vm firewall list-rules`.
48
+ 2. **Resource identifiers are positional arguments**, placed after the action verb:
49
+ - `proxmox vm show 100` (vmid is positional)
50
+ - `proxmox node show pve01` (node_name is positional)
51
+ - `proxmox vm firewall rules show 100 3` (vmid then pos)
52
+ 3. **`--node` is always an optional flag** for VM/container commands (auto-detected if omitted), except on `create` where it's required.
53
+ 4. **No shorthand noun squeezing** — `proxmox vm fw` is NOT acceptable. Always use full resource names.
54
+ 5. **Subcommands inherit `--flags` from parents** via `set_defaults(func=handler)`. Each handler function receives the merged `Namespace`.
55
+
56
+ ## Parser Registration
57
+
58
+ Each CLI module has a `register_<resource>_parser(subparsers)` function that adds subparsers to the passed-in `_SubParsersAction`. Example:
59
+
60
+ ```python
61
+ def register_vm_parser(subparsers: argparse._SubParsersAction) -> None:
62
+ vm_parser = subparsers.add_parser("vm", help="Manage QEMU virtual machines")
63
+ vm_sub = vm_parser.add_subparsers(dest="action", title="actions", required=True)
64
+
65
+ vm_list = vm_sub.add_parser("list", help="List virtual machines")
66
+ vm_list.add_argument("--node", help="Filter by node name")
67
+ vm_list.set_defaults(func=_vm_list)
68
+ ```
69
+
70
+ For nested resources (like firewall), use a second `dest` name to track the sub-resource:
71
+
72
+ ```python
73
+ fw = vm_sub.add_parser("firewall", help="Manage VM firewall")
74
+ fw_sub = fw.add_subparsers(dest="fw_resource", title="resources", required=True)
75
+
76
+ rules = fw_sub.add_parser("rules", help="Manage VM firewall rules")
77
+ rules_sub = rules.add_subparsers(dest="fw_action", title="rule actions", required=False)
78
+ rules_list = rules_sub.add_parser("list", help="List rules")
79
+ rules_list.add_argument("vmid", type=vmid_type, help="VM ID")
80
+ rules_list.set_defaults(func=_vm_fw_rules)
81
+ ```
82
+
83
+ ## Handler Functions
84
+
85
+ Every handler has the signature:
86
+
87
+ ```python
88
+ def _handler(args: argparse.Namespace, client: ProxmoxClient) -> dict | list:
89
+ ```
90
+
91
+ - `args` contains all parsed arguments (global + resource-specific)
92
+ - `client` is the authenticated `ProxmoxClient` instance
93
+ - Returns a dict or list that will be formatted by the output system
94
+ - For errors, return `{"error": "message"}` dict
95
+
96
+ ## Shared Helpers
97
+
98
+ Shared argument definitions go in helper modules (e.g., `firewall_helpers.py`). They export two functions:
99
+
100
+ ```python
101
+ def add_firewall_rule_args(parser: argparse.ArgumentParser) -> None:
102
+ """Add common rule-form arguments to a parser."""
103
+
104
+ def build_rule_data(args: argparse.Namespace) -> dict[str, Any]:
105
+ """Convert parsed args into the POST/PUT body dict."""
106
+ ```
107
+
108
+ ## Testing
109
+
110
+ - Unit tests use `pytest-httpx` to mock API responses
111
+ - Integration tests use `subprocess.run` to invoke the CLI binary
112
+ - Dry-run tests verify the URL, method, and body without real network calls
113
+
114
+ ## Versioning
115
+
116
+ The version string is read from `importlib.metadata.version('proxcli')` — never hardcoded in source.
117
+
118
+ ## PyPI
119
+
120
+ - Package name: `proxcli`
121
+ - CLI binary: `proxmox`
122
+ - Publish via `uv publish --token $PYPI_TOKEN`
123
+ - CI auto-publishes on push to `main` (after lint + test + build)
@@ -7,6 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.3.0] - 2026-06-20
11
+
12
+ ### Added
13
+ - Cluster firewall management: options, enable/disable, policy, rules (CRUD), aliases, ipsets (with CIDR management), refs.
14
+ - Node firewall management: options, enable/disable, policy, rules (CRUD), refs.
15
+ - VM firewall management: options, enable/disable, policy, rules (CRUD), refs.
16
+ - Shared `firewall_helpers.py` for consistent rule argument building across all levels.
17
+ - CI `publish` job: auto-publishes to PyPI on push to main (uses `PYPI_TOKEN` repo secret with `environment: pypi`).
18
+ - `AGENTS.md` with CLI convention and contribution guidelines.
19
+
20
+ ### Changed
21
+ - Removed `.env` file with PyPI token; now uses GitHub Actions secrets.
22
+ - Firewall subcommands refactored to consistent `<resource> <action> <subresource> [subaction]` pattern.
23
+
24
+ ## [0.2.1] - 2026-06-20
25
+
26
+ ### Fixed
27
+ - `--version` now reads from installed package metadata (`importlib.metadata`) instead of a hardcoded string.
28
+
10
29
  ## [0.2.0] - 2026-06-20
11
30
 
12
31
  ### Added
@@ -36,6 +55,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
36
55
  - CSRF ticket auto-refresh on 401.
37
56
  - AI-agent-friendly: default JSON output, strict exit codes, `--dry-run` mode.
38
57
 
58
+ [0.3.0]: https://github.com/xezpeleta/proxcli/releases/tag/v0.3.0
59
+ [0.2.1]: https://github.com/xezpeleta/proxcli/releases/tag/v0.2.1
39
60
  [0.2.0]: https://github.com/xezpeleta/proxcli/releases/tag/v0.2.0
40
61
  [0.1.1]: https://github.com/xezpeleta/proxcli/releases/tag/v0.1.1
41
62
  [0.1.0]: https://github.com/xezpeleta/proxcli/releases/tag/v0.1.0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: proxcli
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: A CLI tool to interact with Proxmox VE nodes and clusters via the REST API
5
5
  Author-email: Xabi Ezpeleta <xezpeleta@gmail.com>
6
6
  License: MIT
@@ -0,0 +1,239 @@
1
+ """`proxmox cluster` subcommand — cluster management."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+
7
+ from proxmox.cli.firewall_helpers import add_firewall_rule_args, build_rule_data
8
+ from proxmox.client.client import ProxmoxClient
9
+
10
+
11
+ def register_cluster_parser(subparsers: argparse._SubParsersAction) -> None:
12
+ """Register the `proxmox cluster` subcommand tree."""
13
+ cl_parser = subparsers.add_parser("cluster", help="Manage Proxmox cluster")
14
+ cl_sub = cl_parser.add_subparsers(dest="action", title="actions", required=True)
15
+
16
+ # --- cluster status ---
17
+ cl_status = cl_sub.add_parser("status", help="Show cluster status")
18
+ cl_status.set_defaults(func=_cl_status)
19
+
20
+ # --- firewall ---
21
+ fw = cl_sub.add_parser("firewall", help="Manage cluster firewall")
22
+ fw_sub = fw.add_subparsers(dest="fw_resource", title="resources", required=True)
23
+
24
+ # firewall options
25
+ fw_opts = fw_sub.add_parser("options", help="Show cluster firewall options")
26
+ fw_opts.set_defaults(func=_cl_fw_options)
27
+
28
+ fw_opts_set = fw_sub.add_parser("enable", help="Enable cluster firewall")
29
+ fw_opts_set.set_defaults(func=_cl_fw_enable)
30
+
31
+ fw_opts_disable = fw_sub.add_parser("disable", help="Disable cluster firewall")
32
+ fw_opts_disable.set_defaults(func=_cl_fw_disable)
33
+
34
+ fw_policy = fw_sub.add_parser("policy", help="Set default firewall policy")
35
+ fw_policy.add_argument("--in-policy", choices=["ACCEPT", "DENY", "REJECT"], default=None,
36
+ help="Default input policy")
37
+ fw_policy.add_argument("--out-policy", choices=["ACCEPT", "DENY", "REJECT"], default=None,
38
+ help="Default output policy")
39
+ fw_policy.set_defaults(func=_cl_fw_policy)
40
+
41
+ # firewall rules
42
+ rules = fw_sub.add_parser("rules", help="Manage cluster firewall rules")
43
+ rules.set_defaults(func=_cl_fw_rules)
44
+ rules_sub = rules.add_subparsers(dest="fw_action", title="rule actions", required=False)
45
+
46
+ rules_list = rules_sub.add_parser("list", help="List rules")
47
+ rules_list.set_defaults(func=_cl_fw_rules)
48
+
49
+ rules_add = rules_sub.add_parser("add", help="Add a rule")
50
+ add_firewall_rule_args(rules_add)
51
+ rules_add.set_defaults(func=_cl_fw_rule_add)
52
+
53
+ rules_show = rules_sub.add_parser("show", help="Show a rule by position")
54
+ rules_show.add_argument("pos", type=int, help="Rule position")
55
+ rules_show.set_defaults(func=_cl_fw_rule_show)
56
+
57
+ rules_upd = rules_sub.add_parser("update", help="Update a rule by position")
58
+ rules_upd.add_argument("pos", type=int, help="Rule position")
59
+ add_firewall_rule_args(rules_upd)
60
+ for action in rules_upd._actions:
61
+ if action.dest == "action":
62
+ action.required = False
63
+ action.default = None
64
+ rules_upd.set_defaults(func=_cl_fw_rule_upd)
65
+
66
+ rules_del = rules_sub.add_parser("delete", help="Delete a rule by position")
67
+ rules_del.add_argument("pos", type=int, help="Rule position")
68
+ rules_del.set_defaults(func=_cl_fw_rule_del)
69
+
70
+ # firewall aliases
71
+ aliases = fw_sub.add_parser("aliases", help="Manage firewall aliases")
72
+ aliases.set_defaults(func=_cl_fw_aliases)
73
+ aliases_sub = aliases.add_subparsers(dest="fw_action", title="alias actions", required=False)
74
+
75
+ aliases_list = aliases_sub.add_parser("list", help="List aliases")
76
+ aliases_list.set_defaults(func=_cl_fw_aliases)
77
+
78
+ aliases_add = aliases_sub.add_parser("add", help="Add an alias")
79
+ aliases_add.add_argument("name", help="Alias name")
80
+ aliases_add.add_argument("--cidr", required=True, help="CIDR notation (e.g. 10.0.0.0/8)")
81
+ aliases_add.add_argument("--comment", default=None, help="Comment / description")
82
+ aliases_add.set_defaults(func=_cl_fw_alias_add)
83
+
84
+ aliases_del = aliases_sub.add_parser("delete", help="Delete an alias")
85
+ aliases_del.add_argument("name", help="Alias name")
86
+ aliases_del.set_defaults(func=_cl_fw_alias_del)
87
+
88
+ # firewall ipsets
89
+ ipsets = fw_sub.add_parser("ipsets", help="Manage firewall ipsets")
90
+ ipsets.set_defaults(func=_cl_fw_ipsets)
91
+ ipsets_sub = ipsets.add_subparsers(dest="fw_action", title="ipset actions", required=False)
92
+
93
+ ipsets_list = ipsets_sub.add_parser("list", help="List ipsets")
94
+ ipsets_list.set_defaults(func=_cl_fw_ipsets)
95
+
96
+ ipsets_add = ipsets_sub.add_parser("add", help="Add an ipset")
97
+ ipsets_add.add_argument("name", help="IPset name")
98
+ ipsets_add.add_argument("--comment", default=None, help="Comment / description")
99
+ ipsets_add.set_defaults(func=_cl_fw_ipset_add)
100
+
101
+ ipsets_show = ipsets_sub.add_parser("show", help="Show ipset contents")
102
+ ipsets_show.add_argument("name", help="IPset name")
103
+ ipsets_show.set_defaults(func=_cl_fw_ipset_show)
104
+
105
+ ipsets_del = ipsets_sub.add_parser("delete", help="Delete an ipset")
106
+ ipsets_del.add_argument("name", help="IPset name")
107
+ ipsets_del.set_defaults(func=_cl_fw_ipset_del)
108
+
109
+ ipsets_add_cidr = ipsets_sub.add_parser("add-cidr", help="Add a CIDR to an ipset")
110
+ ipsets_add_cidr.add_argument("name", help="IPset name")
111
+ ipsets_add_cidr.add_argument("--cidr", required=True, help="CIDR to add")
112
+ ipsets_add_cidr.add_argument("--comment", default=None, help="Comment")
113
+ ipsets_add_cidr.add_argument("--nomatch", action="store_true", help="Exclude match")
114
+ ipsets_add_cidr.set_defaults(func=_cl_fw_ipset_add_cidr)
115
+
116
+ ipsets_del_cidr = ipsets_sub.add_parser("delete-cidr", help="Remove a CIDR from an ipset")
117
+ ipsets_del_cidr.add_argument("name", help="IPset name")
118
+ ipsets_del_cidr.add_argument("--cidr", required=True, help="CIDR to remove")
119
+ ipsets_del_cidr.set_defaults(func=_cl_fw_ipset_del_cidr)
120
+
121
+ # firewall refs
122
+ fw_refs = fw_sub.add_parser("refs", help="List firewall references")
123
+ fw_refs.add_argument("--type", default=None, choices=["alias", "ipset", "group"],
124
+ help="Filter by reference type")
125
+ fw_refs.set_defaults(func=_cl_fw_refs)
126
+
127
+
128
+ # ---------------------------------------------------------------------------
129
+ # Handlers
130
+ # ---------------------------------------------------------------------------
131
+
132
+ def _cl_status(_args: argparse.Namespace, client: ProxmoxClient) -> dict | list:
133
+ return client.get("/cluster/status")
134
+
135
+
136
+ # --- Firewall options ---
137
+
138
+ def _cl_fw_options(_args: argparse.Namespace, client: ProxmoxClient) -> dict:
139
+ return client.get("/cluster/firewall/options")
140
+
141
+
142
+ def _cl_fw_enable(_args: argparse.Namespace, client: ProxmoxClient) -> dict:
143
+ return client.put("/cluster/firewall/options", data={"enable": 1})
144
+
145
+
146
+ def _cl_fw_disable(_args: argparse.Namespace, client: ProxmoxClient) -> dict:
147
+ return client.put("/cluster/firewall/options", data={"enable": 0})
148
+
149
+
150
+ def _cl_fw_policy(args: argparse.Namespace, client: ProxmoxClient) -> dict:
151
+ data: dict = {}
152
+ if args.in_policy is not None:
153
+ data["policy_in"] = args.in_policy
154
+ if args.out_policy is not None:
155
+ data["policy_out"] = args.out_policy
156
+ if not data:
157
+ return {"error": "No policy specified. Use --in-policy or --out-policy"}
158
+ return client.put("/cluster/firewall/options", data=data)
159
+
160
+
161
+ # --- Firewall rules ---
162
+
163
+ def _cl_fw_rules(_args: argparse.Namespace, client: ProxmoxClient) -> dict | list:
164
+ return client.get("/cluster/firewall/rules")
165
+
166
+
167
+ def _cl_fw_rule_add(args: argparse.Namespace, client: ProxmoxClient) -> dict:
168
+ return client.post("/cluster/firewall/rules", data=build_rule_data(args))
169
+
170
+
171
+ def _cl_fw_rule_show(args: argparse.Namespace, client: ProxmoxClient) -> dict:
172
+ return client.get(f"/cluster/firewall/rules/{args.pos}")
173
+
174
+
175
+ def _cl_fw_rule_upd(args: argparse.Namespace, client: ProxmoxClient) -> dict:
176
+ data = {k: v for k, v in build_rule_data(args).items() if v is not None and v != 0}
177
+ return client.put(f"/cluster/firewall/rules/{args.pos}", data=data)
178
+
179
+
180
+ def _cl_fw_rule_del(args: argparse.Namespace, client: ProxmoxClient) -> dict:
181
+ return client.delete(f"/cluster/firewall/rules/{args.pos}")
182
+
183
+
184
+ # --- Firewall aliases ---
185
+
186
+ def _cl_fw_aliases(_args: argparse.Namespace, client: ProxmoxClient) -> dict | list:
187
+ return client.get("/cluster/firewall/aliases")
188
+
189
+
190
+ def _cl_fw_alias_add(args: argparse.Namespace, client: ProxmoxClient) -> dict:
191
+ data = {"name": args.name, "cidr": args.cidr}
192
+ if args.comment:
193
+ data["comment"] = args.comment
194
+ return client.post("/cluster/firewall/aliases", data=data)
195
+
196
+
197
+ def _cl_fw_alias_del(args: argparse.Namespace, client: ProxmoxClient) -> dict:
198
+ return client.delete(f"/cluster/firewall/aliases/{args.name}")
199
+
200
+
201
+ # --- Firewall ipsets ---
202
+
203
+ def _cl_fw_ipsets(_args: argparse.Namespace, client: ProxmoxClient) -> dict | list:
204
+ return client.get("/cluster/firewall/ipset")
205
+
206
+
207
+ def _cl_fw_ipset_add(args: argparse.Namespace, client: ProxmoxClient) -> dict:
208
+ data: dict = {"name": args.name}
209
+ if args.comment:
210
+ data["comment"] = args.comment
211
+ return client.post("/cluster/firewall/ipset", data=data)
212
+
213
+
214
+ def _cl_fw_ipset_show(args: argparse.Namespace, client: ProxmoxClient) -> dict:
215
+ return client.get(f"/cluster/firewall/ipset/{args.name}")
216
+
217
+
218
+ def _cl_fw_ipset_del(args: argparse.Namespace, client: ProxmoxClient) -> dict:
219
+ return client.delete(f"/cluster/firewall/ipset/{args.name}")
220
+
221
+
222
+ def _cl_fw_ipset_add_cidr(args: argparse.Namespace, client: ProxmoxClient) -> dict:
223
+ data: dict = {"cidr": args.cidr}
224
+ if args.comment:
225
+ data["comment"] = args.comment
226
+ if args.nomatch:
227
+ data["nomatch"] = 1
228
+ return client.post(f"/cluster/firewall/ipset/{args.name}", data=data)
229
+
230
+
231
+ def _cl_fw_ipset_del_cidr(args: argparse.Namespace, client: ProxmoxClient) -> dict:
232
+ return client.delete(f"/cluster/firewall/ipset/{args.name}/{args.cidr}")
233
+
234
+
235
+ # --- Firewall refs ---
236
+
237
+ def _cl_fw_refs(args: argparse.Namespace, client: ProxmoxClient) -> dict | list:
238
+ params = {"type": args.type} if args.type else None
239
+ return client.get("/cluster/firewall/refs", params=params)
@@ -0,0 +1,60 @@
1
+ """Firewall helpers shared across cluster, node, and VM subcommands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ from typing import Any
7
+
8
+ # ---------------------------------------------------------------------------
9
+ # Rule argument builder — shared across all firewall levels
10
+ # ---------------------------------------------------------------------------
11
+
12
+
13
+ def add_firewall_rule_args(parser: argparse.ArgumentParser) -> None:
14
+ """Add common rule-form arguments to a parser."""
15
+ parser.add_argument("--action", required=True, choices=["ACCEPT", "DENY", "REJECT"],
16
+ help="Rule action")
17
+ parser.add_argument("--type", default="in", choices=["in", "out"],
18
+ help="Traffic direction (default: in)")
19
+ parser.add_argument("--iface", default=None, help="Network interface (e.g. net0)")
20
+ parser.add_argument("--source", default=None, help="Source IP/CIDR")
21
+ parser.add_argument("--dest", default=None, help="Destination IP/CIDR")
22
+ parser.add_argument("--dport", default=None, help="Destination port")
23
+ parser.add_argument("--sport", default=None, help="Source port")
24
+ parser.add_argument("--proto", default=None, choices=["tcp", "udp", "icmp", "any"],
25
+ help="Protocol")
26
+ parser.add_argument("--comment", default=None, help="Comment / description")
27
+ parser.add_argument("--enable", type=int, default=1, choices=[0, 1],
28
+ help="Enable the rule (default: 1)")
29
+ parser.add_argument("--macro", default=None, help="Pre-defined macro (e.g. SSH, HTTP)")
30
+ parser.add_argument("--log", default=None, choices=["emerg", "alert", "crit", "err",
31
+ "warning", "notice", "info", "debug", "nolog"],
32
+ help="Log level")
33
+
34
+
35
+ def build_rule_data(args: argparse.Namespace) -> dict[str, Any]:
36
+ """Convert parsed firewall rule args into the POST body dict."""
37
+ data: dict[str, Any] = {
38
+ "action": args.action,
39
+ "type": args.type,
40
+ "enable": args.enable,
41
+ }
42
+ if args.macro:
43
+ data["macro"] = args.macro
44
+ if args.iface:
45
+ data["iface"] = args.iface
46
+ if args.source:
47
+ data["source"] = args.source
48
+ if args.dest:
49
+ data["dest"] = args.dest
50
+ if args.dport:
51
+ data["dport"] = args.dport
52
+ if args.sport:
53
+ data["sport"] = args.sport
54
+ if args.proto and not args.macro:
55
+ data["proto"] = args.proto
56
+ if args.comment:
57
+ data["comment"] = args.comment
58
+ if args.log:
59
+ data["log"] = args.log
60
+ return data
@@ -5,6 +5,7 @@ from __future__ import annotations
5
5
  import argparse
6
6
  import os
7
7
  import sys
8
+ from importlib.metadata import version
8
9
  from typing import Any
9
10
 
10
11
  from proxmox.client.auth import AuthManager
@@ -48,7 +49,7 @@ def build_root_parser() -> argparse.ArgumentParser:
48
49
  )
49
50
  parser.add_argument("--verbose", action="store_true", help="Enable debug output to stderr")
50
51
  parser.add_argument(
51
- "--version", action="version", version="proxmox 0.1.0"
52
+ "--version", action="version", version=f"proxmox {version('proxcli')}"
52
53
  )
53
54
 
54
55
  subparsers = parser.add_subparsers(dest="resource", title="resources", required=False)
@@ -176,6 +177,20 @@ def _build_client(overrides: dict[str, Any], args: argparse.Namespace) -> Proxmo
176
177
  return client
177
178
 
178
179
 
180
+ def _print_command_help(args: argparse.Namespace) -> None:
181
+ """When a subcommand has no explicit handler set, print the parent parser's help."""
182
+ # Try to reconstruct the appropriate parser and show its help
183
+ cmd_parts = ["proxmox", args.resource]
184
+ if hasattr(args, "fw_resource"):
185
+ cmd_parts.append("firewall")
186
+ cmd_parts.append(args.fw_resource)
187
+ elif hasattr(args, "action"):
188
+ cmd_parts.append(args.action)
189
+ log_error(
190
+ f"Missing required argument. Run '{' '.join(cmd_parts)} --help' for usage."
191
+ )
192
+
193
+
179
194
  def main(argv: list[str] | None = None) -> None:
180
195
  """Main entry point."""
181
196
  parser = build_root_parser()
@@ -206,8 +221,8 @@ def main(argv: list[str] | None = None) -> None:
206
221
  output = format_output(result, args.output)
207
222
  print(output)
208
223
  else:
209
- # Subcommand registered but no func set (cli module not implemented yet)
210
- log_error(f"Command 'proxmox {args.resource}' is not yet implemented.")
224
+ # No handler was set show relevant help
225
+ _print_command_help(args)
211
226
 
212
227
  except ConfigError as exc:
213
228
  log_error(str(exc))
@@ -0,0 +1,177 @@
1
+ """`proxmox node` subcommand — node management."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+
7
+ from proxmox.cli.firewall_helpers import add_firewall_rule_args, build_rule_data
8
+ from proxmox.client.client import ProxmoxClient
9
+
10
+
11
+ def register_node_parser(subparsers: argparse._SubParsersAction) -> None:
12
+ """Register the `proxmox node` subcommand tree."""
13
+ node_parser = subparsers.add_parser("node", help="Manage Proxmox nodes")
14
+ node_sub = node_parser.add_subparsers(dest="action", title="actions", required=True)
15
+
16
+ # --- node list ---
17
+ node_list = node_sub.add_parser("list", help="List all nodes")
18
+ node_list.set_defaults(func=_node_list)
19
+
20
+ # --- node show ---
21
+ node_show = node_sub.add_parser("show", help="Show node details")
22
+ node_show.add_argument("node_name", help="Node name")
23
+ node_show.set_defaults(func=_node_show)
24
+
25
+ # --- node status ---
26
+ node_status = node_sub.add_parser("status", help="Show node status")
27
+ node_status.add_argument("node_name", nargs="?", help="Node name (omit for all nodes)")
28
+ node_status.set_defaults(func=_node_status)
29
+
30
+ # --- firewall ---
31
+ fw = node_sub.add_parser("firewall", help="Manage node firewall")
32
+ fw_sub = fw.add_subparsers(dest="fw_resource", title="resources", required=True)
33
+
34
+ fw_opts = fw_sub.add_parser("options", help="Show node firewall options")
35
+ fw_opts.add_argument("node_name", help="Node name")
36
+ fw_opts.set_defaults(func=_node_fw_options)
37
+
38
+ fw_enable = fw_sub.add_parser("enable", help="Enable node firewall")
39
+ fw_enable.add_argument("node_name", help="Node name")
40
+ fw_enable.set_defaults(func=_node_fw_enable)
41
+
42
+ fw_disable = fw_sub.add_parser("disable", help="Disable node firewall")
43
+ fw_disable.add_argument("node_name", help="Node name")
44
+ fw_disable.set_defaults(func=_node_fw_disable)
45
+
46
+ fw_policy = fw_sub.add_parser("policy", help="Set default input/output policy")
47
+ fw_policy.add_argument("node_name", help="Node name")
48
+ fw_policy.add_argument("--in-policy", choices=["ACCEPT", "DENY", "REJECT"], default=None,
49
+ help="Default input policy")
50
+ fw_policy.add_argument("--out-policy", choices=["ACCEPT", "DENY", "REJECT"], default=None,
51
+ help="Default output policy")
52
+ fw_policy.set_defaults(func=_node_fw_policy)
53
+
54
+ # firewall rules
55
+ rules = fw_sub.add_parser("rules", help="Manage node firewall rules")
56
+ rules.set_defaults(func=_node_fw_rules)
57
+ rules_sub = rules.add_subparsers(dest="fw_action", title="rule actions", required=False)
58
+
59
+ rules_list = rules_sub.add_parser("list", help="List rules")
60
+ rules_list.add_argument("node_name", help="Node name")
61
+ rules_list.set_defaults(func=_node_fw_rules)
62
+
63
+ rules_add = rules_sub.add_parser("add", help="Add a rule")
64
+ rules_add.add_argument("node_name", help="Node name")
65
+ add_firewall_rule_args(rules_add)
66
+ rules_add.set_defaults(func=_node_fw_rule_add)
67
+
68
+ rules_show = rules_sub.add_parser("show", help="Show a rule by position")
69
+ rules_show.add_argument("node_name", help="Node name")
70
+ rules_show.add_argument("pos", type=int, help="Rule position")
71
+ rules_show.set_defaults(func=_node_fw_rule_show)
72
+
73
+ rules_upd = rules_sub.add_parser("update", help="Update a rule by position")
74
+ rules_upd.add_argument("node_name", help="Node name")
75
+ rules_upd.add_argument("pos", type=int, help="Rule position")
76
+ add_firewall_rule_args(rules_upd)
77
+ for action in rules_upd._actions:
78
+ if action.dest == "action":
79
+ action.required = False
80
+ action.default = None
81
+ rules_upd.set_defaults(func=_node_fw_rule_upd)
82
+
83
+ rules_del = rules_sub.add_parser("delete", help="Delete a rule by position")
84
+ rules_del.add_argument("node_name", help="Node name")
85
+ rules_del.add_argument("pos", type=int, help="Rule position")
86
+ rules_del.set_defaults(func=_node_fw_rule_del)
87
+
88
+ # firewall refs
89
+ fw_refs = fw_sub.add_parser("refs", help="List firewall references for node")
90
+ fw_refs.add_argument("node_name", help="Node name")
91
+ fw_refs.add_argument("--type", default=None, choices=["alias", "ipset", "group"],
92
+ help="Filter by reference type")
93
+ fw_refs.set_defaults(func=_node_fw_refs)
94
+
95
+
96
+ # ---------------------------------------------------------------------------
97
+ # Node handlers
98
+ # ---------------------------------------------------------------------------
99
+
100
+ def _node_list(_args: argparse.Namespace, client: ProxmoxClient) -> dict | list:
101
+ return client.get("/nodes")
102
+
103
+
104
+ def _node_show(args: argparse.Namespace, client: ProxmoxClient) -> dict:
105
+ return client.get(f"/nodes/{args.node_name}/status")
106
+
107
+
108
+ def _node_status(args: argparse.Namespace, client: ProxmoxClient) -> dict | list:
109
+ if args.node_name:
110
+ return client.get(f"/nodes/{args.node_name}/status")
111
+ nodes = client.get("/nodes")
112
+ if isinstance(nodes, list):
113
+ result = []
114
+ for n in nodes:
115
+ node_name = n.get("node") if isinstance(n, dict) else n
116
+ try:
117
+ status = client.get(f"/nodes/{node_name}/status")
118
+ if isinstance(status, dict):
119
+ status["node"] = node_name
120
+ result.append(status)
121
+ except Exception:
122
+ result.append({"node": node_name, "status": "error"})
123
+ return result
124
+ return nodes
125
+
126
+
127
+ # ---------------------------------------------------------------------------
128
+ # Node firewall handlers
129
+ # ---------------------------------------------------------------------------
130
+
131
+ def _node_fw_options(args: argparse.Namespace, client: ProxmoxClient) -> dict:
132
+ return client.get(f"/nodes/{args.node_name}/firewall/options")
133
+
134
+
135
+ def _node_fw_enable(args: argparse.Namespace, client: ProxmoxClient) -> dict:
136
+ return client.put(f"/nodes/{args.node_name}/firewall/options", data={"enable": 1})
137
+
138
+
139
+ def _node_fw_disable(args: argparse.Namespace, client: ProxmoxClient) -> dict:
140
+ return client.put(f"/nodes/{args.node_name}/firewall/options", data={"enable": 0})
141
+
142
+
143
+ def _node_fw_policy(args: argparse.Namespace, client: ProxmoxClient) -> dict:
144
+ data: dict = {}
145
+ if args.in_policy:
146
+ data["policy_in"] = args.in_policy
147
+ if args.out_policy:
148
+ data["policy_out"] = args.out_policy
149
+ if not data:
150
+ return {"error": "No policy specified. Use --in-policy or --out-policy"}
151
+ return client.put(f"/nodes/{args.node_name}/firewall/options", data=data)
152
+
153
+
154
+ def _node_fw_rules(args: argparse.Namespace, client: ProxmoxClient) -> dict | list:
155
+ return client.get(f"/nodes/{args.node_name}/firewall/rules")
156
+
157
+
158
+ def _node_fw_rule_add(args: argparse.Namespace, client: ProxmoxClient) -> dict:
159
+ return client.post(f"/nodes/{args.node_name}/firewall/rules", data=build_rule_data(args))
160
+
161
+
162
+ def _node_fw_rule_show(args: argparse.Namespace, client: ProxmoxClient) -> dict:
163
+ return client.get(f"/nodes/{args.node_name}/firewall/rules/{args.pos}")
164
+
165
+
166
+ def _node_fw_rule_upd(args: argparse.Namespace, client: ProxmoxClient) -> dict:
167
+ data = {k: v for k, v in build_rule_data(args).items() if v is not None and v != 0}
168
+ return client.put(f"/nodes/{args.node_name}/firewall/rules/{args.pos}", data=data)
169
+
170
+
171
+ def _node_fw_rule_del(args: argparse.Namespace, client: ProxmoxClient) -> dict:
172
+ return client.delete(f"/nodes/{args.node_name}/firewall/rules/{args.pos}")
173
+
174
+
175
+ def _node_fw_refs(args: argparse.Namespace, client: ProxmoxClient) -> dict | list:
176
+ params = {"type": args.type} if args.type else None
177
+ return client.get(f"/nodes/{args.node_name}/firewall/refs", params=params)
@@ -4,6 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  import argparse
6
6
 
7
+ from proxmox.cli.firewall_helpers import add_firewall_rule_args, build_rule_data
7
8
  from proxmox.client.client import ProxmoxClient
8
9
  from proxmox.utils.helpers import resolve_vmid, vmid_type
9
10
 
@@ -74,6 +75,80 @@ def register_vm_parser(subparsers: argparse._SubParsersAction) -> None:
74
75
  vm_delete.add_argument("--purge", action="store_true", help="Purge VM from all configurations")
75
76
  vm_delete.set_defaults(func=_vm_delete)
76
77
 
78
+ # --- firewall ---
79
+ fw = vm_sub.add_parser("firewall", help="Manage VM firewall")
80
+ fw_sub = fw.add_subparsers(dest="fw_resource", title="resources", required=True)
81
+
82
+ fw_opts = fw_sub.add_parser("options", help="Show VM firewall options")
83
+ fw_opts.add_argument("vmid", type=vmid_type, help="VM ID")
84
+ fw_opts.add_argument("--node", help="Node name (auto-detected if omitted)")
85
+ fw_opts.set_defaults(func=_vm_fw_options)
86
+
87
+ fw_enable = fw_sub.add_parser("enable", help="Enable VM firewall")
88
+ fw_enable.add_argument("vmid", type=vmid_type, help="VM ID")
89
+ fw_enable.add_argument("--node", help="Node name (auto-detected if omitted)")
90
+ fw_enable.set_defaults(func=_vm_fw_enable)
91
+
92
+ fw_disable = fw_sub.add_parser("disable", help="Disable VM firewall")
93
+ fw_disable.add_argument("vmid", type=vmid_type, help="VM ID")
94
+ fw_disable.add_argument("--node", help="Node name (auto-detected if omitted)")
95
+ fw_disable.set_defaults(func=_vm_fw_disable)
96
+
97
+ fw_policy = fw_sub.add_parser("policy", help="Set default input/output policy for VM")
98
+ fw_policy.add_argument("vmid", type=vmid_type, help="VM ID")
99
+ fw_policy.add_argument("--node", help="Node name (auto-detected if omitted)")
100
+ fw_policy.add_argument("--in-policy", choices=["ACCEPT", "DENY", "REJECT"], default=None,
101
+ help="Default input policy")
102
+ fw_policy.add_argument("--out-policy", choices=["ACCEPT", "DENY", "REJECT"], default=None,
103
+ help="Default output policy")
104
+ fw_policy.set_defaults(func=_vm_fw_policy)
105
+
106
+ # firewall rules
107
+ rules = fw_sub.add_parser("rules", help="Manage VM firewall rules")
108
+ rules_sub = rules.add_subparsers(dest="fw_action", title="rule actions", required=False)
109
+
110
+ rules_list = rules_sub.add_parser("list", help="List rules")
111
+ rules_list.add_argument("vmid", type=vmid_type, help="VM ID")
112
+ rules_list.add_argument("--node", help="Node name (auto-detected if omitted)")
113
+ rules_list.set_defaults(func=_vm_fw_rules)
114
+
115
+ rules_add = rules_sub.add_parser("add", help="Add a rule")
116
+ rules_add.add_argument("vmid", type=vmid_type, help="VM ID")
117
+ rules_add.add_argument("--node", help="Node name (auto-detected if omitted)")
118
+ add_firewall_rule_args(rules_add)
119
+ rules_add.set_defaults(func=_vm_fw_rule_add)
120
+
121
+ rules_show = rules_sub.add_parser("show", help="Show a rule by position")
122
+ rules_show.add_argument("vmid", type=vmid_type, help="VM ID")
123
+ rules_show.add_argument("--node", help="Node name (auto-detected if omitted)")
124
+ rules_show.add_argument("pos", type=int, help="Rule position")
125
+ rules_show.set_defaults(func=_vm_fw_rule_show)
126
+
127
+ rules_upd = rules_sub.add_parser("update", help="Update a rule by position")
128
+ rules_upd.add_argument("vmid", type=vmid_type, help="VM ID")
129
+ rules_upd.add_argument("--node", help="Node name (auto-detected if omitted)")
130
+ rules_upd.add_argument("pos", type=int, help="Rule position")
131
+ add_firewall_rule_args(rules_upd)
132
+ for action in rules_upd._actions:
133
+ if action.dest == "action":
134
+ action.required = False
135
+ action.default = None
136
+ rules_upd.set_defaults(func=_vm_fw_rule_upd)
137
+
138
+ rules_del = rules_sub.add_parser("delete", help="Delete a rule by position")
139
+ rules_del.add_argument("vmid", type=vmid_type, help="VM ID")
140
+ rules_del.add_argument("--node", help="Node name (auto-detected if omitted)")
141
+ rules_del.add_argument("pos", type=int, help="Rule position")
142
+ rules_del.set_defaults(func=_vm_fw_rule_del)
143
+
144
+ # firewall refs
145
+ fw_refs = fw_sub.add_parser("refs", help="List VM firewall references")
146
+ fw_refs.add_argument("vmid", type=vmid_type, help="VM ID")
147
+ fw_refs.add_argument("--node", help="Node name (auto-detected if omitted)")
148
+ fw_refs.add_argument("--type", default=None, choices=["alias", "ipset", "group"],
149
+ help="Filter by reference type")
150
+ fw_refs.set_defaults(func=_vm_fw_refs)
151
+
77
152
 
78
153
  # ---------------------------------------------------------------------------
79
154
  # Helpers
@@ -209,3 +284,87 @@ def _vm_delete(args: argparse.Namespace, client: ProxmoxClient) -> dict:
209
284
  params["purge"] = 1
210
285
  result = client.delete(f"/nodes/{node}/qemu/{args.vmid}", params=params or None)
211
286
  return result if isinstance(result, dict) else {"data": result}
287
+
288
+
289
+ # ---------------------------------------------------------------------------
290
+ # VM firewall handlers
291
+ # ---------------------------------------------------------------------------
292
+
293
+ def _vm_fw_options(args: argparse.Namespace, client: ProxmoxClient) -> dict:
294
+ node = _resolve_node(client, args.node, args.vmid)
295
+ if not node:
296
+ return {"error": f"VM {args.vmid} not found"}
297
+ return client.get(f"/nodes/{node}/qemu/{args.vmid}/firewall/options")
298
+
299
+
300
+ def _vm_fw_enable(args: argparse.Namespace, client: ProxmoxClient) -> dict:
301
+ node = _resolve_node(client, args.node, args.vmid)
302
+ if not node:
303
+ return {"error": f"VM {args.vmid} not found"}
304
+ return client.put(f"/nodes/{node}/qemu/{args.vmid}/firewall/options", data={"enable": 1})
305
+
306
+
307
+ def _vm_fw_disable(args: argparse.Namespace, client: ProxmoxClient) -> dict:
308
+ node = _resolve_node(client, args.node, args.vmid)
309
+ if not node:
310
+ return {"error": f"VM {args.vmid} not found"}
311
+ return client.put(f"/nodes/{node}/qemu/{args.vmid}/firewall/options", data={"enable": 0})
312
+
313
+
314
+ def _vm_fw_policy(args: argparse.Namespace, client: ProxmoxClient) -> dict:
315
+ node = _resolve_node(client, args.node, args.vmid)
316
+ if not node:
317
+ return {"error": f"VM {args.vmid} not found"}
318
+ data: dict = {}
319
+ if args.in_policy:
320
+ data["policy_in"] = args.in_policy
321
+ if args.out_policy:
322
+ data["policy_out"] = args.out_policy
323
+ if not data:
324
+ return {"error": "No policy specified. Use --in-policy or --out-policy"}
325
+ return client.put(f"/nodes/{node}/qemu/{args.vmid}/firewall/options", data=data)
326
+
327
+
328
+ def _vm_fw_rules(args: argparse.Namespace, client: ProxmoxClient) -> dict | list:
329
+ node = _resolve_node(client, args.node, args.vmid)
330
+ if not node:
331
+ return {"error": f"VM {args.vmid} not found"}
332
+ return client.get(f"/nodes/{node}/qemu/{args.vmid}/firewall/rules")
333
+
334
+
335
+ def _vm_fw_rule_add(args: argparse.Namespace, client: ProxmoxClient) -> dict:
336
+ node = _resolve_node(client, args.node, args.vmid)
337
+ if not node:
338
+ return {"error": f"VM {args.vmid} not found"}
339
+ return client.post(f"/nodes/{node}/qemu/{args.vmid}/firewall/rules",
340
+ data=build_rule_data(args))
341
+
342
+
343
+ def _vm_fw_rule_show(args: argparse.Namespace, client: ProxmoxClient) -> dict:
344
+ node = _resolve_node(client, args.node, args.vmid)
345
+ if not node:
346
+ return {"error": f"VM {args.vmid} not found"}
347
+ return client.get(f"/nodes/{node}/qemu/{args.vmid}/firewall/rules/{args.pos}")
348
+
349
+
350
+ def _vm_fw_rule_upd(args: argparse.Namespace, client: ProxmoxClient) -> dict:
351
+ node = _resolve_node(client, args.node, args.vmid)
352
+ if not node:
353
+ return {"error": f"VM {args.vmid} not found"}
354
+ data = {k: v for k, v in build_rule_data(args).items() if v is not None and v != 0}
355
+ return client.put(f"/nodes/{node}/qemu/{args.vmid}/firewall/rules/{args.pos}", data=data)
356
+
357
+
358
+ def _vm_fw_rule_del(args: argparse.Namespace, client: ProxmoxClient) -> dict:
359
+ node = _resolve_node(client, args.node, args.vmid)
360
+ if not node:
361
+ return {"error": f"VM {args.vmid} not found"}
362
+ return client.delete(f"/nodes/{node}/qemu/{args.vmid}/firewall/rules/{args.pos}")
363
+
364
+
365
+ def _vm_fw_refs(args: argparse.Namespace, client: ProxmoxClient) -> dict | list:
366
+ node = _resolve_node(client, args.node, args.vmid)
367
+ if not node:
368
+ return {"error": f"VM {args.vmid} not found"}
369
+ params: dict | None = {"type": args.type} if args.type else None
370
+ return client.get(f"/nodes/{node}/qemu/{args.vmid}/firewall/refs", params=params)
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "proxcli"
3
- version = "0.2.0"
3
+ version = "0.3.0"
4
4
  description = "A CLI tool to interact with Proxmox VE nodes and clusters via the REST API"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -1,5 +1,5 @@
1
1
  version = 1
2
- revision = 2
2
+ revision = 3
3
3
  requires-python = ">=3.10"
4
4
 
5
5
  [[package]]
@@ -254,7 +254,7 @@ wheels = [
254
254
 
255
255
  [[package]]
256
256
  name = "proxcli"
257
- version = "0.1.1"
257
+ version = "0.3.0"
258
258
  source = { editable = "." }
259
259
  dependencies = [
260
260
  { name = "httpx" },
@@ -1,21 +0,0 @@
1
- """`proxmox cluster` subcommand — cluster management."""
2
-
3
- from __future__ import annotations
4
-
5
- import argparse
6
-
7
- from proxmox.client.client import ProxmoxClient
8
-
9
-
10
- def register_cluster_parser(subparsers: argparse._SubParsersAction) -> None:
11
- """Register the `proxmox cluster` subcommand tree."""
12
- cl_parser = subparsers.add_parser("cluster", help="Manage Proxmox cluster")
13
- cl_sub = cl_parser.add_subparsers(dest="action", title="actions", required=True)
14
-
15
- # --- cluster status ---
16
- cl_status = cl_sub.add_parser("status", help="Show cluster status")
17
- cl_status.set_defaults(func=_cl_status)
18
-
19
-
20
- def _cl_status(args: argparse.Namespace, client: ProxmoxClient) -> dict | list:
21
- return client.get("/cluster/status")
@@ -1,55 +0,0 @@
1
- """`proxmox node` subcommand — node management."""
2
-
3
- from __future__ import annotations
4
-
5
- import argparse
6
-
7
- from proxmox.client.client import ProxmoxClient
8
-
9
-
10
- def register_node_parser(subparsers: argparse._SubParsersAction) -> None:
11
- """Register the `proxmox node` subcommand tree."""
12
- node_parser = subparsers.add_parser("node", help="Manage Proxmox nodes")
13
- node_sub = node_parser.add_subparsers(dest="action", title="actions", required=True)
14
-
15
- # --- node list ---
16
- node_list = node_sub.add_parser("list", help="List all nodes")
17
- node_list.set_defaults(func=_node_list)
18
-
19
- # --- node show ---
20
- node_show = node_sub.add_parser("show", help="Show node details")
21
- node_show.add_argument("node_name", help="Node name")
22
- node_show.set_defaults(func=_node_show)
23
-
24
- # --- node status ---
25
- node_status = node_sub.add_parser("status", help="Show node status")
26
- node_status.add_argument("node_name", nargs="?", help="Node name (omit for all nodes)")
27
- node_status.set_defaults(func=_node_status)
28
-
29
-
30
- def _node_list(args: argparse.Namespace, client: ProxmoxClient) -> dict | list:
31
- return client.get("/nodes")
32
-
33
-
34
- def _node_show(args: argparse.Namespace, client: ProxmoxClient) -> dict:
35
- return client.get(f"/nodes/{args.node_name}/status")
36
-
37
-
38
- def _node_status(args: argparse.Namespace, client: ProxmoxClient) -> dict | list:
39
- if args.node_name:
40
- return client.get(f"/nodes/{args.node_name}/status")
41
- # Return all nodes' statuses
42
- nodes = client.get("/nodes")
43
- if isinstance(nodes, list):
44
- result = []
45
- for n in nodes:
46
- node_name = n.get("node") if isinstance(n, dict) else n
47
- try:
48
- status = client.get(f"/nodes/{node_name}/status")
49
- if isinstance(status, dict):
50
- status["node"] = node_name
51
- result.append(status)
52
- except Exception:
53
- result.append({"node": node_name, "status": "error"})
54
- return result
55
- return nodes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes