proxcli 0.8.2__tar.gz → 0.9.1__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.
- {proxcli-0.8.2 → proxcli-0.9.1}/CHANGELOG.md +32 -0
- {proxcli-0.8.2 → proxcli-0.9.1}/PKG-INFO +1 -1
- {proxcli-0.8.2 → proxcli-0.9.1}/TODO.md +4 -5
- proxcli-0.9.1/docs/api-permissions.md +153 -0
- proxcli-0.9.1/docs/cloud-init.md +255 -0
- proxcli-0.9.1/proxmox/cli/acl.py +95 -0
- {proxcli-0.8.2 → proxcli-0.9.1}/proxmox/cli/main.py +7 -1
- proxcli-0.9.1/proxmox/cli/role.py +78 -0
- proxcli-0.9.1/proxmox/cli/user.py +124 -0
- {proxcli-0.8.2 → proxcli-0.9.1}/proxmox/cli/vm.py +87 -0
- {proxcli-0.8.2 → proxcli-0.9.1}/pyproject.toml +1 -1
- {proxcli-0.8.2 → proxcli-0.9.1}/uv.lock +1 -1
- {proxcli-0.8.2 → proxcli-0.9.1}/.env.example +0 -0
- {proxcli-0.8.2 → proxcli-0.9.1}/.github/workflows/ci.yml +0 -0
- {proxcli-0.8.2 → proxcli-0.9.1}/.gitignore +0 -0
- {proxcli-0.8.2 → proxcli-0.9.1}/.python-version +0 -0
- {proxcli-0.8.2 → proxcli-0.9.1}/AGENTS.md +0 -0
- {proxcli-0.8.2 → proxcli-0.9.1}/PLAN.md +0 -0
- {proxcli-0.8.2 → proxcli-0.9.1}/PROJECT.md +0 -0
- {proxcli-0.8.2 → proxcli-0.9.1}/PROMPT.md +0 -0
- {proxcli-0.8.2 → proxcli-0.9.1}/README.md +0 -0
- {proxcli-0.8.2 → proxcli-0.9.1}/proxmox/__init__.py +0 -0
- {proxcli-0.8.2 → proxcli-0.9.1}/proxmox/cli/__init__.py +0 -0
- {proxcli-0.8.2 → proxcli-0.9.1}/proxmox/cli/auth.py +0 -0
- {proxcli-0.8.2 → proxcli-0.9.1}/proxmox/cli/cluster.py +0 -0
- {proxcli-0.8.2 → proxcli-0.9.1}/proxmox/cli/completion.py +0 -0
- {proxcli-0.8.2 → proxcli-0.9.1}/proxmox/cli/container.py +0 -0
- {proxcli-0.8.2 → proxcli-0.9.1}/proxmox/cli/firewall_helpers.py +0 -0
- {proxcli-0.8.2 → proxcli-0.9.1}/proxmox/cli/node.py +0 -0
- {proxcli-0.8.2 → proxcli-0.9.1}/proxmox/cli/pool.py +0 -0
- {proxcli-0.8.2 → proxcli-0.9.1}/proxmox/cli/storage.py +0 -0
- {proxcli-0.8.2 → proxcli-0.9.1}/proxmox/cli/tasks.py +0 -0
- {proxcli-0.8.2 → proxcli-0.9.1}/proxmox/client/__init__.py +0 -0
- {proxcli-0.8.2 → proxcli-0.9.1}/proxmox/client/auth.py +0 -0
- {proxcli-0.8.2 → proxcli-0.9.1}/proxmox/client/client.py +0 -0
- {proxcli-0.8.2 → proxcli-0.9.1}/proxmox/client/exceptions.py +0 -0
- {proxcli-0.8.2 → proxcli-0.9.1}/proxmox/config/__init__.py +0 -0
- {proxcli-0.8.2 → proxcli-0.9.1}/proxmox/config/config.py +0 -0
- {proxcli-0.8.2 → proxcli-0.9.1}/proxmox/config/models.py +0 -0
- {proxcli-0.8.2 → proxcli-0.9.1}/proxmox/output/__init__.py +0 -0
- {proxcli-0.8.2 → proxcli-0.9.1}/proxmox/output/formatter.py +0 -0
- {proxcli-0.8.2 → proxcli-0.9.1}/proxmox/output/json_fmt.py +0 -0
- {proxcli-0.8.2 → proxcli-0.9.1}/proxmox/output/table_fmt.py +0 -0
- {proxcli-0.8.2 → proxcli-0.9.1}/proxmox/output/yaml_fmt.py +0 -0
- {proxcli-0.8.2 → proxcli-0.9.1}/proxmox/utils/__init__.py +0 -0
- {proxcli-0.8.2 → proxcli-0.9.1}/proxmox/utils/helpers.py +0 -0
- {proxcli-0.8.2 → proxcli-0.9.1}/proxmox/utils/logging.py +0 -0
- {proxcli-0.8.2 → proxcli-0.9.1}/tests/__init__.py +0 -0
- {proxcli-0.8.2 → proxcli-0.9.1}/tests/conftest.py +0 -0
- {proxcli-0.8.2 → proxcli-0.9.1}/tests/test_auth.py +0 -0
- {proxcli-0.8.2 → proxcli-0.9.1}/tests/test_cli/__init__.py +0 -0
- {proxcli-0.8.2 → proxcli-0.9.1}/tests/test_cli/test_main.py +0 -0
- {proxcli-0.8.2 → proxcli-0.9.1}/tests/test_client.py +0 -0
- {proxcli-0.8.2 → proxcli-0.9.1}/tests/test_config.py +0 -0
- {proxcli-0.8.2 → proxcli-0.9.1}/tests/test_integration/__init__.py +0 -0
- {proxcli-0.8.2 → proxcli-0.9.1}/tests/test_output/__init__.py +0 -0
- {proxcli-0.8.2 → proxcli-0.9.1}/tests/test_output/test_formatter.py +0 -0
|
@@ -7,6 +7,36 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.9.1] - 2026-06-20
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- **user, role, and ACL management**: ``proxmox user`` (list, show, create,
|
|
14
|
+
update, delete), ``proxmox role`` (list, show, create, update, delete),
|
|
15
|
+
``proxmox acl`` (list, show, add, delete). Wraps ``/access/users``,
|
|
16
|
+
``/access/roles``, and ``/access/acl`` endpoints. ACL write operations
|
|
17
|
+
require ``Permissions.Modify`` (Administrator role).
|
|
18
|
+
|
|
19
|
+
## [0.9.0] - 2026-06-20
|
|
20
|
+
|
|
21
|
+
### Added
|
|
22
|
+
- **vm create cloud-init support**: ``--citype``, ``--ciuser``,
|
|
23
|
+
``--cipassword``, ``--sshkeys`` (file path or inline),
|
|
24
|
+
``--nameserver``, ``--searchdomain``, ``--cicustom``.
|
|
25
|
+
- **vm create --import-from**: import an existing disk image from storage
|
|
26
|
+
as the VM's boot disk (e.g. ``--import-from local:import/deb12.qcow2``).
|
|
27
|
+
Requires a Proxmox storage with ``images`` or ``import`` content types.
|
|
28
|
+
- **vm cloud-init drive auto-creation**: when cloud-init flags are used
|
|
29
|
+
on ``vm create``, an ``ide2`` cloud-init drive is automatically attached.
|
|
30
|
+
Proxmox VE 9 regenerates the ISO on config change — no separate generate step.
|
|
31
|
+
- **vm cloudinit generate**: re-submits the current ``citype`` to trigger
|
|
32
|
+
regeneration. Adapted for Proxmox VE 9 which removed the
|
|
33
|
+
``POST /cloudinit`` endpoint.
|
|
34
|
+
- **docs/cloud-init.md**: complete guide on creating and managing
|
|
35
|
+
cloud-init VMs with proxcli, including prerequisites, examples,
|
|
36
|
+
custom user-data, and troubleshooting.
|
|
37
|
+
- **docs/api-permissions.md**: minimum API privilege reference for the
|
|
38
|
+
cloud-init VM workflow and other proxcli operations.
|
|
39
|
+
|
|
10
40
|
## [0.8.2] - 2026-06-20
|
|
11
41
|
|
|
12
42
|
### Added
|
|
@@ -136,6 +166,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
136
166
|
- CSRF ticket auto-refresh on 401.
|
|
137
167
|
- AI-agent-friendly: default JSON output, strict exit codes, `--dry-run` mode.
|
|
138
168
|
|
|
169
|
+
[0.9.1]: https://github.com/xezpeleta/proxcli/releases/tag/v0.9.1
|
|
170
|
+
[0.9.0]: https://github.com/xezpeleta/proxcli/releases/tag/v0.9.0
|
|
139
171
|
[0.8.2]: https://github.com/xezpeleta/proxcli/releases/tag/v0.8.2
|
|
140
172
|
[0.8.1]: https://github.com/xezpeleta/proxcli/releases/tag/v0.8.1
|
|
141
173
|
[0.8.0]: https://github.com/xezpeleta/proxcli/releases/tag/v0.8.0
|
|
@@ -15,6 +15,10 @@ Completed items are marked with a check. Implementation notes are preserved for
|
|
|
15
15
|
- [x] **QEMU guest agent interfaces** — `proxmox vm agent interfaces <vmid>`. Wraps `/nodes/{node}/qemu/{vmid}/agent/network-get-interfaces`.
|
|
16
16
|
- [x] **Streaming task logs** — `proxmox task log <upid> [--follow]`. Polls `/nodes/{node}/tasks/{upid}/log`.
|
|
17
17
|
- [x] **Global flag hint** — If user places `--output` / `--dry-run` / etc. after the resource, a hint suggests the correct order.
|
|
18
|
+
- [x] **User & permission management** — `proxmox user` (list/show/create/update/delete), `proxmox role` (list/show/create/update/delete), `proxmox acl` (list/show/add/delete). Wraps `/access/users`, `/access/roles`, `/access/acl`. ACL write requires `Permissions.Modify` (Administrator).
|
|
19
|
+
- [x] **VM cloud-init support** — `vm create` flags for citype, ciuser, cipassword, sshkeys, nameserver, searchdomain, cicustom + auto cloud-init drive creation. `vm cloudinit generate` for regeneration.
|
|
20
|
+
- [x] **VM disk import** — `vm create --import-from <storage:path>` imports an existing disk image as VM boot disk.
|
|
21
|
+
- [x] **Docs** — `docs/cloud-init.md` (cloud-init VM workflow), `docs/api-permissions.md` (minimum API privileges).
|
|
18
22
|
|
|
19
23
|
## v1.1 — Polish & Usability
|
|
20
24
|
|
|
@@ -34,11 +38,6 @@ Completed items are marked with a check. Implementation notes are preserved for
|
|
|
34
38
|
- [ ] **Backup (`vzdump`) management**
|
|
35
39
|
- `proxmox backup` subcommand: `list`, `create`, `show`, `delete`. Wrap `/nodes/{node}/vzdump` and `/nodes/{node}/storage/{storage}/content` for backup files.
|
|
36
40
|
|
|
37
|
-
- [ ] **User & permission management**
|
|
38
|
-
- `proxmox user` subcommand: `list`, `show`, `create`, `update`, `delete`.
|
|
39
|
-
- `proxmox role` subcommand: `list`, `show`, `create`, `update`, `delete`.
|
|
40
|
-
- `proxmox acl` subcommand: `list`, `show`. Wraps `/access/users`, `/access/roles`, `/access/acl`, `/access/groups`.
|
|
41
|
-
|
|
42
41
|
- [ ] **Network management**
|
|
43
42
|
- `proxmox network` subcommand: `list`, `show`, `update` for bridges, bonds, VLANs. Wraps `/nodes/{node}/network`.
|
|
44
43
|
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# API Token Permissions
|
|
2
|
+
|
|
3
|
+
proxcli uses the Proxmox VE REST API. This document describes how to create
|
|
4
|
+
a minimal-permission API token for common workflows.
|
|
5
|
+
|
|
6
|
+
## Creating an API Token
|
|
7
|
+
|
|
8
|
+
In the Proxmox VE UI: **Datacenter → Permissions → API Tokens → Add**
|
|
9
|
+
|
|
10
|
+
1. Select **User**
|
|
11
|
+
2. Enter **Token ID** (e.g. `proxcli`)
|
|
12
|
+
3. Uncheck **Privilege Separation** (for simplicity) or leave it checked
|
|
13
|
+
if you want to limit the token's permissions
|
|
14
|
+
|
|
15
|
+
The **Secret** is shown only once — save it immediately.
|
|
16
|
+
|
|
17
|
+
## Permission Model
|
|
18
|
+
|
|
19
|
+
Proxmox permissions follow the pattern:
|
|
20
|
+
|
|
21
|
+
```
|
|
22
|
+
/path/to/resource PrivilegeName[,PrivilegeName...]
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
- Paths can be broad (`/`) or specific (`/vms/100`)
|
|
26
|
+
- Privileges are inherited — permissions on `/` propagate to all sub-paths
|
|
27
|
+
- An API token's effective permissions are the **intersection** of:
|
|
28
|
+
1. The token's own ACLs
|
|
29
|
+
2. The user's ACLs (if privilege separation is enabled)
|
|
30
|
+
|
|
31
|
+
## Step-by-Step: Cloud-Init VM Workflow
|
|
32
|
+
|
|
33
|
+
The complete workflow of uploading a cloud image, creating a VM with
|
|
34
|
+
cloud-init, and starting it uses these endpoints:
|
|
35
|
+
|
|
36
|
+
| Step | Method | Endpoint | Privilege |
|
|
37
|
+
|------|--------|----------|-----------|
|
|
38
|
+
| 1 | GET | `/cluster/nextid` | `Sys.Audit` |
|
|
39
|
+
| 2 | POST | `/nodes/{node}/storage/{storage}/upload` | `Datastore.AllocateTemplate` |
|
|
40
|
+
| 3 | POST | `/nodes/{node}/qemu` | `VM.Allocate` |
|
|
41
|
+
| 3 | — | (reads imported image from source storage) | `Datastore.Allocate` |
|
|
42
|
+
| 3 | — | (allocates disk on target storage) | `Datastore.AllocateSpace` |
|
|
43
|
+
| 3 | — | (attaches scsi0 disk) | `VM.Config.Disk` |
|
|
44
|
+
| 3 | — | (sets net0) | `VM.Config.Network` |
|
|
45
|
+
| 3 | — | (sets cloud-init: citype, ciuser, sshkeys, etc.) | `VM.Config.Cloudinit` |
|
|
46
|
+
| 3 | — | (sets bios, machine, boot order) | `VM.Config.Options` |
|
|
47
|
+
| 4 | POST | `/nodes/{node}/qemu/{vmid}/status/start` | `VM.PowerMgmt` |
|
|
48
|
+
| 5 | GET | `/nodes/{node}/qemu/{vmid}/status/current` | `VM.Audit` |
|
|
49
|
+
| 5 | GET | `/cluster/resources` | `Sys.Audit` |
|
|
50
|
+
|
|
51
|
+
## Minimal Role: PVECloudInitAdmin
|
|
52
|
+
|
|
53
|
+
Create a role with only the 11 required privileges:
|
|
54
|
+
|
|
55
|
+
```
|
|
56
|
+
Role name: PVECloudInitAdmin
|
|
57
|
+
|
|
58
|
+
Privileges:
|
|
59
|
+
Sys.Audit
|
|
60
|
+
Datastore.Allocate
|
|
61
|
+
Datastore.AllocateSpace
|
|
62
|
+
Datastore.AllocateTemplate
|
|
63
|
+
VM.Allocate
|
|
64
|
+
VM.Audit
|
|
65
|
+
VM.Config.Cloudinit
|
|
66
|
+
VM.Config.Disk
|
|
67
|
+
VM.Config.Network
|
|
68
|
+
VM.Config.Options
|
|
69
|
+
VM.PowerMgmt
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
### ACLs (broad — covers all storages and VMs)
|
|
73
|
+
|
|
74
|
+
```
|
|
75
|
+
Add → Path: /
|
|
76
|
+
Add → Path: /storage
|
|
77
|
+
Add → Path: /vms
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Assign the `PVECloudInitAdmin` role to all three paths for your user or
|
|
81
|
+
token. Since permissions inherit, `/vms` covers all VMs and
|
|
82
|
+
`/storage` covers all storages.
|
|
83
|
+
|
|
84
|
+
### ACLs (narrow — single storage, single node)
|
|
85
|
+
|
|
86
|
+
If you know exactly which storages you'll use, lock it down further:
|
|
87
|
+
|
|
88
|
+
```
|
|
89
|
+
Path: / Role: PVECloudInitAdmin (only for Sys.Audit)
|
|
90
|
+
Path: /storage/local Role: PVECloudInitAdmin (for upload + import)
|
|
91
|
+
Path: /storage/rbd_ssd Role: PVECloudInitAdmin (for disk allocation)
|
|
92
|
+
Path: /vms Role: PVECloudInitAdmin (for VM operations)
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Or even narrower per-VM:
|
|
96
|
+
|
|
97
|
+
```
|
|
98
|
+
Path: /vms/100-199 Role: PVECloudInitAdmin
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
## Additional Privileges for Other Workflows
|
|
102
|
+
|
|
103
|
+
| Feature | Extra Privileges |
|
|
104
|
+
|---------|-----------------|
|
|
105
|
+
| Snapshots | `VM.Snapshot`, `VM.Snapshot.Rollback` |
|
|
106
|
+
| Clone VM | `VM.Clone` |
|
|
107
|
+
| Change memory/CPU | `VM.Config.Memory`, `VM.Config.CPU` |
|
|
108
|
+
| Attach ISOs | `VM.Config.CDROM` |
|
|
109
|
+
| Migrate VM | `VM.Migrate` |
|
|
110
|
+
| Backup VM | `VM.Backup` |
|
|
111
|
+
| Delete VM | `VM.Allocate` (already included), `Datastore.AllocateSpace` |
|
|
112
|
+
| QEMU guest agent | `VM.GuestAgent.Audit`, `VM.GuestAgent.FileRead` |
|
|
113
|
+
| Containers | `VM.Allocate` (LXC creation), `VM.Audit`, `VM.PowerMgmt` |
|
|
114
|
+
| Pools | `Pool.Allocate`, `Pool.Audit` |
|
|
115
|
+
| Cluster firewall | `Sys.Modify`, `Sys.Audit` |
|
|
116
|
+
| Node firewall | `Sys.Modify` |
|
|
117
|
+
| VM firewall | `VM.Allocate` (already included) |
|
|
118
|
+
| ACL management | `Permissions.Modify` (Administrator role) |
|
|
119
|
+
|
|
120
|
+
## Full Admin Role (for comparison)
|
|
121
|
+
|
|
122
|
+
The built-in **PVEVMAdmin** role includes:
|
|
123
|
+
|
|
124
|
+
```
|
|
125
|
+
VM.Allocate, VM.Audit, VM.Backup, VM.Clone, VM.Config.CDROM,
|
|
126
|
+
VM.Config.Cloudinit, VM.Config.CPU, VM.Config.Disk, VM.Config.HWType,
|
|
127
|
+
VM.Config.Memory, VM.Config.Network, VM.Config.Options, VM.Console,
|
|
128
|
+
VM.Migrate, VM.Monitor, VM.PowerMgmt, VM.Snapshot, VM.Snapshot.Rollback
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
The built-in **PVEDatastoreAdmin** adds:
|
|
132
|
+
|
|
133
|
+
```
|
|
134
|
+
Datastore.Allocate, Datastore.AllocateSpace, Datastore.AllocateTemplate,
|
|
135
|
+
Datastore.Audit, Datastore.Copy
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
## Verifying Permissions
|
|
139
|
+
|
|
140
|
+
Check your current effective permissions:
|
|
141
|
+
|
|
142
|
+
```bash
|
|
143
|
+
proxmox auth permissions
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
Or test a specific action with dry-run:
|
|
147
|
+
|
|
148
|
+
```bash
|
|
149
|
+
proxmox --dry-run vm create --node <node> --memory 512 --cores 1
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
The dry-run output shows the exact endpoint and HTTP method — if any
|
|
153
|
+
privilege is missing, Proxmox will return a **403 Forbidden**.
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
# Cloud-Init VM Management with proxcli
|
|
2
|
+
|
|
3
|
+
Cloud-init allows you to create and fully configure VMs declaratively — users,
|
|
4
|
+
SSH keys, networking, and packages are defined via `proxcli` flags rather than
|
|
5
|
+
manual post-install steps.
|
|
6
|
+
|
|
7
|
+
## Prerequisites
|
|
8
|
+
|
|
9
|
+
### 1. Storage with `images` and `import` content types
|
|
10
|
+
|
|
11
|
+
You need a Proxmox storage that supports both `images` (for VM disks) and
|
|
12
|
+
`import` (for uploading disk images). On Proxmox VE, edit the storage in
|
|
13
|
+
**Datacenter → Storage → Edit** and add the content types:
|
|
14
|
+
|
|
15
|
+
- `Disk image` — to store VM disks
|
|
16
|
+
- `ISO image` — optional, if you also want to store ISOs there
|
|
17
|
+
|
|
18
|
+
Example: a `dir` type storage with path `/var/lib/vz` that has `images`, `iso`,
|
|
19
|
+
and `import` enabled.
|
|
20
|
+
|
|
21
|
+
Verify with:
|
|
22
|
+
```bash
|
|
23
|
+
proxmox storage list
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Look for a storage where `content` includes `images` and (for uploads) `import`.
|
|
27
|
+
|
|
28
|
+
### 2. Download a cloud-init image
|
|
29
|
+
|
|
30
|
+
Cloud images are pre-built OS images with cloud-init installed. You can find
|
|
31
|
+
them at:
|
|
32
|
+
|
|
33
|
+
- **Debian**: https://cloud.debian.org/images/cloud/bookworm/latest/
|
|
34
|
+
- File: `debian-12-genericcloud-amd64.qcow2` (333 MB)
|
|
35
|
+
- **Ubuntu**: https://cloud-images.ubuntu.com/releases/24.04/release/
|
|
36
|
+
- File: `ubuntu-24.04-server-cloudimg-amd64.img` (593 MB)
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
# Download examples
|
|
40
|
+
curl -LO https://cloud.debian.org/images/cloud/bookworm/latest/debian-12-genericcloud-amd64.qcow2
|
|
41
|
+
curl -LO https://cloud-images.ubuntu.com/releases/noble/release/ubuntu-24.04-server-cloudimg-amd64.img
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
### 3. Upload the image to Proxmox storage
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
proxmox storage upload \
|
|
48
|
+
--node <node> \
|
|
49
|
+
--storage <storage> \
|
|
50
|
+
--content-type import \
|
|
51
|
+
--file debian-12-genericcloud-amd64.qcow2
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
After upload, the image will be available as `<storage>:import/<filename>`.
|
|
55
|
+
You can verify with:
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
proxmox storage content <storage> --node <node>
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
---
|
|
62
|
+
|
|
63
|
+
## Creating a Cloud-Init VM
|
|
64
|
+
|
|
65
|
+
The complete workflow is a **single `vm create` command** — Proxmox handles
|
|
66
|
+
disk import, cloud-init drive creation, and ISO generation automatically:
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
proxmox vm create \
|
|
70
|
+
--node sanmarko \
|
|
71
|
+
--memory 4096 \
|
|
72
|
+
--cores 2 \
|
|
73
|
+
--name my-cloud-vm \
|
|
74
|
+
--import-from local:import/debian-12-genericcloud-amd64.qcow2 \
|
|
75
|
+
--citype nocloud \
|
|
76
|
+
--ciuser debian \
|
|
77
|
+
--cipassword 'SecurePassword123!' \
|
|
78
|
+
--sshkeys ~/.ssh/id_rsa.pub \
|
|
79
|
+
--nameserver 1.1.1.1 \
|
|
80
|
+
--searchdomain mydomain.lan \
|
|
81
|
+
--net 'virtio,bridge=vmbr0'
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
### Flag reference
|
|
85
|
+
|
|
86
|
+
| Flag | Description |
|
|
87
|
+
|------|-------------|
|
|
88
|
+
| `--import-from <storage:path>` | Import the boot disk from an existing volume |
|
|
89
|
+
| `--citype nocloud\|configdrive2` | Cloud-init data source type |
|
|
90
|
+
| `--ciuser <username>` | Default user (e.g. `debian`, `ubuntu`) |
|
|
91
|
+
| `--cipassword <password>` | User password (hashed by cloud-init) |
|
|
92
|
+
| `--sshkeys <file\|inline>` | SSH public keys (reads file if it exists, otherwise uses inline) |
|
|
93
|
+
| `--nameserver <ip>` | DNS server |
|
|
94
|
+
| `--searchdomain <domain>` | DNS search domain |
|
|
95
|
+
| `--cicustom <config>` | Custom cloud-init config (`user=...,vendor=...`) |
|
|
96
|
+
| `--storage <name>` | Target storage for the imported disk (defaults to `rbd_ssd`) |
|
|
97
|
+
|
|
98
|
+
### What happens
|
|
99
|
+
|
|
100
|
+
1. Proxmox assigns a VM ID (or use `--vmid` to specify)
|
|
101
|
+
2. The cloud image is **streamed from the import storage to the target storage**
|
|
102
|
+
and attached as `scsi0`
|
|
103
|
+
3. A cloud-init drive is created (`ide2`) with the configured user, password,
|
|
104
|
+
SSH keys, and network settings
|
|
105
|
+
4. The VM is created in *stopped* state, ready to start
|
|
106
|
+
|
|
107
|
+
### Verify the configuration
|
|
108
|
+
|
|
109
|
+
```bash
|
|
110
|
+
proxmox vm show <vmid>
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
The output includes:
|
|
114
|
+
- `scsi0` pointing to the imported disk on the target storage
|
|
115
|
+
- `citype`, `ciuser`, `cipassword`, `nameserver`, etc.
|
|
116
|
+
- `ide2` with the cloud-init ISO
|
|
117
|
+
|
|
118
|
+
---
|
|
119
|
+
|
|
120
|
+
## Managing Cloud-Init VMs
|
|
121
|
+
|
|
122
|
+
### Updating cloud-init configuration
|
|
123
|
+
|
|
124
|
+
After the VM is created, you can update cloud-init parameters using
|
|
125
|
+
`PUT /nodes/{node}/qemu/{vmid}/config` (no dedicated proxcli command yet):
|
|
126
|
+
|
|
127
|
+
```bash
|
|
128
|
+
# Currently via curl; a proxcli vm update command is planned
|
|
129
|
+
# This triggers automatic cloud-init ISO regeneration in Proxmox VE 9
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
In Proxmox VE 9+, the cloud-init ISO is **regenerated automatically** whenever
|
|
133
|
+
cloud-init config parameters change. There is no separate "generate" step.
|
|
134
|
+
|
|
135
|
+
### Dumping current cloud-init config
|
|
136
|
+
|
|
137
|
+
```bash
|
|
138
|
+
proxmox vm cloudinit generate <vmid>
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
This reads the current `citype` from the VM config and re-submits it to
|
|
142
|
+
trigger regeneration. (In Proxmox VE 9, config changes auto-regenerate,
|
|
143
|
+
but this command is provided for environments where explicit generation
|
|
144
|
+
is needed.)
|
|
145
|
+
|
|
146
|
+
### Starting the VM
|
|
147
|
+
|
|
148
|
+
```bash
|
|
149
|
+
proxmox vm start <vmid>
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
On first boot, cloud-init will:
|
|
153
|
+
1. Set the hostname
|
|
154
|
+
2. Create the specified user with the given password
|
|
155
|
+
3. Install the SSH keys
|
|
156
|
+
4. Configure networking
|
|
157
|
+
5. Run any `#cloud-config` directives (if using `--cicustom`)
|
|
158
|
+
|
|
159
|
+
---
|
|
160
|
+
|
|
161
|
+
## Complete Example: Debian 12 Cloud VM
|
|
162
|
+
|
|
163
|
+
```bash
|
|
164
|
+
# 1. Upload the cloud image (one-time)
|
|
165
|
+
proxmox storage upload \
|
|
166
|
+
--node sanmarko --storage local --content-type import \
|
|
167
|
+
--file debian-12-genericcloud-amd64.qcow2
|
|
168
|
+
|
|
169
|
+
# 2. Create the VM
|
|
170
|
+
proxmox vm create \
|
|
171
|
+
--node sanmarko \
|
|
172
|
+
--name webserver \
|
|
173
|
+
--memory 4096 --cores 2 \
|
|
174
|
+
--import-from local:import/debian-12-genericcloud-amd64.qcow2 \
|
|
175
|
+
--citype nocloud \
|
|
176
|
+
--ciuser debian \
|
|
177
|
+
--cipassword 'ChangeMe123!' \
|
|
178
|
+
--sshkeys ~/.ssh/id_rsa.pub \
|
|
179
|
+
--nameserver 1.1.1.1 \
|
|
180
|
+
--searchdomain tknika.net \
|
|
181
|
+
--net 'virtio,bridge=vmbr0'
|
|
182
|
+
|
|
183
|
+
# 3. Start it
|
|
184
|
+
proxmox vm start <vmid>
|
|
185
|
+
|
|
186
|
+
# 4. SSH in (after boot completes)
|
|
187
|
+
ssh debian@<ip>
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
---
|
|
191
|
+
|
|
192
|
+
## Custom Cloud-Init Config
|
|
193
|
+
|
|
194
|
+
For advanced customization (packages, runcmd, write_files), use `--cicustom`
|
|
195
|
+
with a user-data YAML file:
|
|
196
|
+
|
|
197
|
+
```bash
|
|
198
|
+
proxmox vm create \
|
|
199
|
+
... \
|
|
200
|
+
--cicustom 'user=local:snippets/user-data.yaml'
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
Upload the snippet first:
|
|
204
|
+
```bash
|
|
205
|
+
proxmox storage upload \
|
|
206
|
+
--node sanmarko --storage local --content-type snippets \
|
|
207
|
+
--file user-data.yaml
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
Example `user-data.yaml`:
|
|
211
|
+
```yaml
|
|
212
|
+
#cloud-config
|
|
213
|
+
packages:
|
|
214
|
+
- nginx
|
|
215
|
+
- htop
|
|
216
|
+
- curl
|
|
217
|
+
|
|
218
|
+
package_update: true
|
|
219
|
+
package_upgrade: true
|
|
220
|
+
|
|
221
|
+
runcmd:
|
|
222
|
+
- systemctl enable --now nginx
|
|
223
|
+
- echo "Hello from cloud-init" > /etc/motd
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
---
|
|
227
|
+
|
|
228
|
+
## Troubleshooting
|
|
229
|
+
|
|
230
|
+
### "storage does not support 'import' content"
|
|
231
|
+
|
|
232
|
+
Your storage only has `iso` or `images` enabled. Go to **Datacenter → Storage**
|
|
233
|
+
in the Proxmox UI and add `Disk image` and `Import` content types to the storage.
|
|
234
|
+
|
|
235
|
+
### "has wrong type 'iso' - needs to be 'images' or 'import'"
|
|
236
|
+
|
|
237
|
+
You uploaded the image with `--content-type iso` instead of `--content-type import`.
|
|
238
|
+
Re-upload with the correct content type, or change the storage's content types.
|
|
239
|
+
|
|
240
|
+
### "Only root can pass arbitrary filesystem paths"
|
|
241
|
+
|
|
242
|
+
The `import-from` parameter uses a **volume ID** (`local:import/file.qcow2`),
|
|
243
|
+
not a filesystem path (`/var/lib/vz/...`). Make sure the image is uploaded to
|
|
244
|
+
Proxmox storage first via `storage upload`.
|
|
245
|
+
|
|
246
|
+
### Cloud-init not running on boot
|
|
247
|
+
|
|
248
|
+
Check that the cloud-init drive is attached:
|
|
249
|
+
```bash
|
|
250
|
+
proxmox vm show <vmid>
|
|
251
|
+
# Look for: ide2: local:XXX/vm-XXX-cloudinit.qcow2,media=cdrom
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
If missing, the cloud-init drive wasn't created. This happens when no
|
|
255
|
+
cloud-init flags (`--ciuser`, `--citype`, etc.) are passed to `vm create`.
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""CLI subcommand for ACL management (`proxmox acl`)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
|
|
7
|
+
from ..client.client import ProxmoxClient
|
|
8
|
+
|
|
9
|
+
# ---------------------------------------------------------------------------
|
|
10
|
+
# Handlers
|
|
11
|
+
# ---------------------------------------------------------------------------
|
|
12
|
+
|
|
13
|
+
def _acl_list(args: argparse.Namespace, client: ProxmoxClient) -> list | dict:
|
|
14
|
+
result = client.get("/access/acl")
|
|
15
|
+
if isinstance(result, list):
|
|
16
|
+
return result
|
|
17
|
+
if isinstance(result, dict) and "data" in result:
|
|
18
|
+
return result["data"]
|
|
19
|
+
return result
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _acl_show(args: argparse.Namespace, client: ProxmoxClient) -> dict | list:
|
|
23
|
+
params = {"path": args.path}
|
|
24
|
+
result = client.get("/access/acl", params=params)
|
|
25
|
+
if isinstance(result, dict) and "data" in result:
|
|
26
|
+
return result["data"]
|
|
27
|
+
return result
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _acl_create(args: argparse.Namespace, client: ProxmoxClient) -> dict:
|
|
31
|
+
data: dict = {
|
|
32
|
+
"path": args.path,
|
|
33
|
+
"roles": args.roles,
|
|
34
|
+
}
|
|
35
|
+
if args.users:
|
|
36
|
+
data["users"] = args.users
|
|
37
|
+
if args.groups:
|
|
38
|
+
data["groups"] = args.groups
|
|
39
|
+
if args.tokens:
|
|
40
|
+
data["tokens"] = args.tokens
|
|
41
|
+
if args.propagate is False:
|
|
42
|
+
data["propagate"] = 0
|
|
43
|
+
return client.put("/access/acl", data=data)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _acl_delete(args: argparse.Namespace, client: ProxmoxClient) -> dict:
|
|
47
|
+
data: dict = {"path": args.path}
|
|
48
|
+
if args.roles:
|
|
49
|
+
data["roles"] = args.roles
|
|
50
|
+
if args.users:
|
|
51
|
+
data["users"] = args.users
|
|
52
|
+
if args.groups:
|
|
53
|
+
data["groups"] = args.groups
|
|
54
|
+
if args.tokens:
|
|
55
|
+
data["tokens"] = args.tokens
|
|
56
|
+
return client.put("/access/acl", data={"delete": 1, **data})
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# ---------------------------------------------------------------------------
|
|
60
|
+
# Parser registration
|
|
61
|
+
# ---------------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
def register_acl_parser(subparsers: argparse._SubParsersAction) -> None:
|
|
64
|
+
acl_parser = subparsers.add_parser("acl", help="Manage ACLs")
|
|
65
|
+
acl_sub = acl_parser.add_subparsers(dest="action", title="actions", required=True)
|
|
66
|
+
|
|
67
|
+
# acl list
|
|
68
|
+
acl_list = acl_sub.add_parser("list", help="List all ACLs")
|
|
69
|
+
acl_list.set_defaults(func=_acl_list)
|
|
70
|
+
|
|
71
|
+
# acl show
|
|
72
|
+
acl_show = acl_sub.add_parser("show", help="Show ACLs for a path")
|
|
73
|
+
acl_show.add_argument("path", help="ACL path (e.g. /vms/100)")
|
|
74
|
+
acl_show.set_defaults(func=_acl_show)
|
|
75
|
+
|
|
76
|
+
# acl create
|
|
77
|
+
acl_create = acl_sub.add_parser("add", help="Add ACL entry")
|
|
78
|
+
acl_create.add_argument("path", help="ACL path (e.g. /vms)")
|
|
79
|
+
acl_create.add_argument("--roles", help="Comma-separated role IDs")
|
|
80
|
+
acl_create.add_argument("--users", help="Comma-separated user IDs")
|
|
81
|
+
acl_create.add_argument("--groups", help="Comma-separated group IDs")
|
|
82
|
+
acl_create.add_argument("--tokens", help="Comma-separated token IDs")
|
|
83
|
+
acl_create.add_argument("--no-propagate", dest="propagate", action="store_false",
|
|
84
|
+
default=True,
|
|
85
|
+
help="Disable permission propagation to children")
|
|
86
|
+
acl_create.set_defaults(func=_acl_create)
|
|
87
|
+
|
|
88
|
+
# acl delete
|
|
89
|
+
acl_delete = acl_sub.add_parser("delete", help="Remove ACL entry")
|
|
90
|
+
acl_delete.add_argument("path", help="ACL path (e.g. /vms)")
|
|
91
|
+
acl_delete.add_argument("--roles", help="Comma-separated role IDs")
|
|
92
|
+
acl_delete.add_argument("--users", help="Comma-separated user IDs")
|
|
93
|
+
acl_delete.add_argument("--groups", help="Comma-separated group IDs")
|
|
94
|
+
acl_delete.add_argument("--tokens", help="Comma-separated token IDs")
|
|
95
|
+
acl_delete.set_defaults(func=_acl_delete)
|
|
@@ -55,16 +55,20 @@ def build_root_parser() -> argparse.ArgumentParser:
|
|
|
55
55
|
subparsers = parser.add_subparsers(dest="resource", title="resources", required=False)
|
|
56
56
|
|
|
57
57
|
# Import and register subcommands
|
|
58
|
+
from proxmox.cli.acl import register_acl_parser
|
|
58
59
|
from proxmox.cli.auth import register_auth_parser
|
|
59
60
|
from proxmox.cli.cluster import register_cluster_parser
|
|
60
61
|
from proxmox.cli.completion import register_completion_parser
|
|
61
62
|
from proxmox.cli.container import register_container_parser
|
|
62
63
|
from proxmox.cli.node import register_node_parser
|
|
63
64
|
from proxmox.cli.pool import register_pool_parser
|
|
65
|
+
from proxmox.cli.role import register_role_parser
|
|
64
66
|
from proxmox.cli.storage import register_storage_parser
|
|
65
67
|
from proxmox.cli.tasks import register_task_parser
|
|
68
|
+
from proxmox.cli.user import register_user_parser
|
|
66
69
|
from proxmox.cli.vm import register_vm_parser
|
|
67
70
|
|
|
71
|
+
register_acl_parser(subparsers)
|
|
68
72
|
register_auth_parser(subparsers)
|
|
69
73
|
register_vm_parser(subparsers)
|
|
70
74
|
register_node_parser(subparsers)
|
|
@@ -74,6 +78,8 @@ def build_root_parser() -> argparse.ArgumentParser:
|
|
|
74
78
|
register_cluster_parser(subparsers)
|
|
75
79
|
register_completion_parser(subparsers)
|
|
76
80
|
register_task_parser(subparsers)
|
|
81
|
+
register_role_parser(subparsers)
|
|
82
|
+
register_user_parser(subparsers)
|
|
77
83
|
|
|
78
84
|
return parser
|
|
79
85
|
|
|
@@ -206,7 +212,7 @@ GLOBAL_FLAGS_WITH_VALUE = {"--url", "--username", "--password", "--api-token", "
|
|
|
206
212
|
def _hint_global_flags_order(argv: list[str]) -> None:
|
|
207
213
|
"""If user placed global flags after the resource, show a helpful hint."""
|
|
208
214
|
resource_pos = -1
|
|
209
|
-
resources = {"auth", "vm", "node", "pool", "container", "storage", "cluster", "completion", "task"}
|
|
215
|
+
resources = {"auth", "vm", "node", "pool", "container", "storage", "cluster", "completion", "task", "user", "role", "acl"}
|
|
210
216
|
for i, arg in enumerate(argv):
|
|
211
217
|
if arg in resources:
|
|
212
218
|
resource_pos = i
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""CLI subcommand for role management (`proxmox role`)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
|
|
7
|
+
from ..client.client import ProxmoxClient
|
|
8
|
+
|
|
9
|
+
# ---------------------------------------------------------------------------
|
|
10
|
+
# Handlers
|
|
11
|
+
# ---------------------------------------------------------------------------
|
|
12
|
+
|
|
13
|
+
def _role_list(args: argparse.Namespace, client: ProxmoxClient) -> list | dict:
|
|
14
|
+
result = client.get("/access/roles")
|
|
15
|
+
if isinstance(result, list):
|
|
16
|
+
return result
|
|
17
|
+
if isinstance(result, dict) and "data" in result:
|
|
18
|
+
return result["data"]
|
|
19
|
+
return result
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _role_show(args: argparse.Namespace, client: ProxmoxClient) -> dict:
|
|
23
|
+
return client.get(f"/access/roles/{args.roleid}")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _role_create(args: argparse.Namespace, client: ProxmoxClient) -> dict:
|
|
27
|
+
data: dict = {"roleid": args.roleid}
|
|
28
|
+
if args.privs:
|
|
29
|
+
data["privs"] = args.privs
|
|
30
|
+
return client.post("/access/roles", data=data)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _role_update(args: argparse.Namespace, client: ProxmoxClient) -> dict:
|
|
34
|
+
data: dict = {}
|
|
35
|
+
if args.privs:
|
|
36
|
+
data["privs"] = args.privs
|
|
37
|
+
if not data:
|
|
38
|
+
return {"error": "No fields to update"}
|
|
39
|
+
return client.put(f"/access/roles/{args.roleid}", data=data)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _role_delete(args: argparse.Namespace, client: ProxmoxClient) -> dict:
|
|
43
|
+
return client.delete(f"/access/roles/{args.roleid}")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# ---------------------------------------------------------------------------
|
|
47
|
+
# Parser registration
|
|
48
|
+
# ---------------------------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
def register_role_parser(subparsers: argparse._SubParsersAction) -> None:
|
|
51
|
+
role_parser = subparsers.add_parser("role", help="Manage roles")
|
|
52
|
+
role_sub = role_parser.add_subparsers(dest="action", title="actions", required=True)
|
|
53
|
+
|
|
54
|
+
# role list
|
|
55
|
+
role_list = role_sub.add_parser("list", help="List roles")
|
|
56
|
+
role_list.set_defaults(func=_role_list)
|
|
57
|
+
|
|
58
|
+
# role show
|
|
59
|
+
role_show = role_sub.add_parser("show", help="Show role details")
|
|
60
|
+
role_show.add_argument("roleid", help="Role ID (e.g. PVEAdmin)")
|
|
61
|
+
role_show.set_defaults(func=_role_show)
|
|
62
|
+
|
|
63
|
+
# role create
|
|
64
|
+
role_create = role_sub.add_parser("create", help="Create role")
|
|
65
|
+
role_create.add_argument("roleid", help="Role ID")
|
|
66
|
+
role_create.add_argument("--privs", help="Comma-separated privilege list")
|
|
67
|
+
role_create.set_defaults(func=_role_create)
|
|
68
|
+
|
|
69
|
+
# role update
|
|
70
|
+
role_update = role_sub.add_parser("update", help="Update role privileges")
|
|
71
|
+
role_update.add_argument("roleid", help="Role ID")
|
|
72
|
+
role_update.add_argument("--privs", help="Comma-separated privilege list")
|
|
73
|
+
role_update.set_defaults(func=_role_update)
|
|
74
|
+
|
|
75
|
+
# role delete
|
|
76
|
+
role_delete = role_sub.add_parser("delete", help="Delete role")
|
|
77
|
+
role_delete.add_argument("roleid", help="Role ID")
|
|
78
|
+
role_delete.set_defaults(func=_role_delete)
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""CLI subcommand for user management (`proxmox user`)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
|
|
7
|
+
from ..client.client import ProxmoxClient
|
|
8
|
+
|
|
9
|
+
# ---------------------------------------------------------------------------
|
|
10
|
+
# Handlers
|
|
11
|
+
# ---------------------------------------------------------------------------
|
|
12
|
+
|
|
13
|
+
def _user_list(args: argparse.Namespace, client: ProxmoxClient) -> list | dict:
|
|
14
|
+
result = client.get("/access/users")
|
|
15
|
+
if isinstance(result, list):
|
|
16
|
+
return result
|
|
17
|
+
if isinstance(result, dict) and "data" in result:
|
|
18
|
+
return result["data"]
|
|
19
|
+
return result
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _user_show(args: argparse.Namespace, client: ProxmoxClient) -> dict:
|
|
23
|
+
return client.get(f"/access/users/{args.userid}")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _user_create(args: argparse.Namespace, client: ProxmoxClient) -> dict:
|
|
27
|
+
data: dict = {"userid": args.userid}
|
|
28
|
+
if args.password:
|
|
29
|
+
data["password"] = args.password
|
|
30
|
+
if args.email:
|
|
31
|
+
data["email"] = args.email
|
|
32
|
+
if args.firstname:
|
|
33
|
+
data["firstname"] = args.firstname
|
|
34
|
+
if args.lastname:
|
|
35
|
+
data["lastname"] = args.lastname
|
|
36
|
+
if args.enable is False:
|
|
37
|
+
data["enable"] = 0
|
|
38
|
+
if args.expire is not None:
|
|
39
|
+
data["expire"] = args.expire
|
|
40
|
+
if args.group:
|
|
41
|
+
data["groups"] = ",".join(args.group)
|
|
42
|
+
return client.post("/access/users", data=data)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _user_update(args: argparse.Namespace, client: ProxmoxClient) -> dict:
|
|
46
|
+
data: dict = {}
|
|
47
|
+
if args.password:
|
|
48
|
+
data["password"] = args.password
|
|
49
|
+
if args.email:
|
|
50
|
+
data["email"] = args.email
|
|
51
|
+
if args.firstname:
|
|
52
|
+
data["firstname"] = args.firstname
|
|
53
|
+
if args.lastname:
|
|
54
|
+
data["lastname"] = args.lastname
|
|
55
|
+
if args.enable is not False:
|
|
56
|
+
data["enable"] = 0
|
|
57
|
+
if args.expire is not None:
|
|
58
|
+
data["expire"] = args.expire
|
|
59
|
+
if args.group:
|
|
60
|
+
data["groups"] = ",".join(args.group)
|
|
61
|
+
if not data:
|
|
62
|
+
return {"error": "No fields to update"}
|
|
63
|
+
return client.put(f"/access/users/{args.userid}", data=data)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _user_delete(args: argparse.Namespace, client: ProxmoxClient) -> dict:
|
|
67
|
+
return client.delete(f"/access/users/{args.userid}")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# ---------------------------------------------------------------------------
|
|
71
|
+
# Parser registration
|
|
72
|
+
# ---------------------------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
def register_user_parser(subparsers: argparse._SubParsersAction) -> None:
|
|
75
|
+
user_parser = subparsers.add_parser("user", help="Manage users")
|
|
76
|
+
user_sub = user_parser.add_subparsers(dest="action", title="actions", required=True)
|
|
77
|
+
|
|
78
|
+
# user list
|
|
79
|
+
user_list = user_sub.add_parser("list", help="List users")
|
|
80
|
+
user_list.set_defaults(func=_user_list)
|
|
81
|
+
|
|
82
|
+
# user show
|
|
83
|
+
user_show = user_sub.add_parser("show", help="Show user details")
|
|
84
|
+
user_show.add_argument("userid", help="User ID (e.g. xezpeleta@pve)")
|
|
85
|
+
user_show.set_defaults(func=_user_show)
|
|
86
|
+
|
|
87
|
+
# user create
|
|
88
|
+
user_create = user_sub.add_parser("create", help="Create user")
|
|
89
|
+
user_create.add_argument("userid", help="User ID (e.g. newuser@pve)")
|
|
90
|
+
user_create.add_argument("--password", help="User password")
|
|
91
|
+
user_create.add_argument("--enable", action="store_true", default=True,
|
|
92
|
+
help="Enable user (default)")
|
|
93
|
+
user_create.add_argument("--disable", dest="enable", action="store_false",
|
|
94
|
+
help="Disable user account")
|
|
95
|
+
user_create.add_argument("--firstname", help="First name")
|
|
96
|
+
user_create.add_argument("--lastname", help="Last name")
|
|
97
|
+
user_create.add_argument("--email", help="Email address")
|
|
98
|
+
user_create.add_argument("--expire", type=int, default=0,
|
|
99
|
+
help="Account expiration (Unix timestamp, 0 = never)")
|
|
100
|
+
user_create.add_argument("--group", action="append",
|
|
101
|
+
help="Group to add user to (repeatable)")
|
|
102
|
+
user_create.set_defaults(func=_user_create)
|
|
103
|
+
|
|
104
|
+
# user update
|
|
105
|
+
user_update = user_sub.add_parser("update", help="Update user")
|
|
106
|
+
user_update.add_argument("userid", help="User ID (e.g. xezpeleta@pve)")
|
|
107
|
+
user_update.add_argument("--password", help="New password")
|
|
108
|
+
user_update.add_argument("--enable", action="store_true", default=None,
|
|
109
|
+
help="Enable user")
|
|
110
|
+
user_update.add_argument("--disable", dest="enable", action="store_false",
|
|
111
|
+
help="Disable user account")
|
|
112
|
+
user_update.add_argument("--firstname", help="First name")
|
|
113
|
+
user_update.add_argument("--lastname", help="Last name")
|
|
114
|
+
user_update.add_argument("--email", help="Email address")
|
|
115
|
+
user_update.add_argument("--expire", type=int, default=None,
|
|
116
|
+
help="Account expiration (Unix timestamp, 0 = never)")
|
|
117
|
+
user_update.add_argument("--group", action="append",
|
|
118
|
+
help="Group to add user to (repeatable)")
|
|
119
|
+
user_update.set_defaults(func=_user_update)
|
|
120
|
+
|
|
121
|
+
# user delete
|
|
122
|
+
user_delete = user_sub.add_parser("delete", help="Delete user")
|
|
123
|
+
user_delete.add_argument("userid", help="User ID (e.g. newuser@pve)")
|
|
124
|
+
user_delete.set_defaults(func=_user_delete)
|
|
@@ -44,6 +44,16 @@ def register_vm_parser(subparsers: argparse._SubParsersAction) -> None:
|
|
|
44
44
|
vm_create.add_argument("--machine", default=None, help="Machine type (e.g. q35)")
|
|
45
45
|
vm_create.add_argument("--boot", default=None, help="Boot order (e.g. order=cd;net)")
|
|
46
46
|
vm_create.add_argument("--disk", default=None, help="Disk size (e.g. 32G). Uses --storage if set, else local-lvm.")
|
|
47
|
+
vm_create.add_argument("--citype", default=None, choices=["nocloud", "configdrive2"],
|
|
48
|
+
help="Cloud-init type")
|
|
49
|
+
vm_create.add_argument("--ciuser", default=None, help="Cloud-init user")
|
|
50
|
+
vm_create.add_argument("--cipassword", default=None, help="Cloud-init password")
|
|
51
|
+
vm_create.add_argument("--sshkeys", default=None, help="Cloud-init SSH public keys (file path or inline)")
|
|
52
|
+
vm_create.add_argument("--nameserver", default=None, help="Cloud-init DNS server")
|
|
53
|
+
vm_create.add_argument("--searchdomain", default=None, help="Cloud-init DNS search domain")
|
|
54
|
+
vm_create.add_argument("--cicustom", default=None, help="Cloud-init custom config (user=...,vendor=...)")
|
|
55
|
+
vm_create.add_argument("--import-from", default=None, dest="import_from",
|
|
56
|
+
help="Import disk from existing volume (e.g. local:import/deb12.qcow2)")
|
|
47
57
|
vm_create.set_defaults(func=_vm_create)
|
|
48
58
|
|
|
49
59
|
# --- vm start ---
|
|
@@ -133,6 +143,15 @@ def register_vm_parser(subparsers: argparse._SubParsersAction) -> None:
|
|
|
133
143
|
agent_ifaces.add_argument("--node", help="Node name (auto-detected if omitted)")
|
|
134
144
|
agent_ifaces.set_defaults(func=_vm_agent_interfaces)
|
|
135
145
|
|
|
146
|
+
# --- cloudinit ---
|
|
147
|
+
cloudinit = vm_sub.add_parser("cloudinit", help="Manage cloud-init")
|
|
148
|
+
cloudinit_sub = cloudinit.add_subparsers(dest="cloudinit_action", title="cloud-init actions", required=True)
|
|
149
|
+
|
|
150
|
+
ci_generate = cloudinit_sub.add_parser("generate", help="Regenerate cloud-init ISO from current config")
|
|
151
|
+
ci_generate.add_argument("vmid", type=vmid_type, help="VM ID")
|
|
152
|
+
ci_generate.add_argument("--node", help="Node name (auto-detected if omitted)")
|
|
153
|
+
ci_generate.set_defaults(func=_vm_cloudinit_generate)
|
|
154
|
+
|
|
136
155
|
# --- firewall ---
|
|
137
156
|
fw = vm_sub.add_parser("firewall", help="Manage VM firewall")
|
|
138
157
|
fw_sub = fw.add_subparsers(dest="fw_resource", title="resources", required=True)
|
|
@@ -312,6 +331,46 @@ def _vm_create(args: argparse.Namespace, client: ProxmoxClient) -> dict:
|
|
|
312
331
|
else:
|
|
313
332
|
disk_raw = args.disk
|
|
314
333
|
data["scsi0"] = disk_raw.replace("=", "%3D").replace(":", "%3A").replace(",", "%2C")
|
|
334
|
+
elif args.import_from:
|
|
335
|
+
# Import from existing volume: storage_id=0,import-from=<volid>
|
|
336
|
+
parts = args.import_from.split(":", 1)
|
|
337
|
+
if len(parts) == 2:
|
|
338
|
+
src_storage, src_file = parts
|
|
339
|
+
else:
|
|
340
|
+
return {"error": f"Invalid import-from format: {args.import_from}. Use <storage>:<path>"}
|
|
341
|
+
# Build: dst_storage:0,import-from=src_storage:path
|
|
342
|
+
target_storage = args.storage or "rbd_ssd"
|
|
343
|
+
import_raw = f"{target_storage}:0,import-from={args.import_from}"
|
|
344
|
+
data["scsi0"] = import_raw.replace("=", "%3D").replace(":", "%3A").replace(",", "%2C")
|
|
345
|
+
|
|
346
|
+
# Cloud-init
|
|
347
|
+
cloudinit_used = bool(args.citype or args.ciuser or args.cipassword or args.sshkeys
|
|
348
|
+
or args.nameserver or args.searchdomain or args.cicustom)
|
|
349
|
+
if args.citype:
|
|
350
|
+
data["citype"] = args.citype
|
|
351
|
+
if args.ciuser:
|
|
352
|
+
data["ciuser"] = args.ciuser
|
|
353
|
+
if args.cipassword:
|
|
354
|
+
data["cipassword"] = args.cipassword
|
|
355
|
+
if args.nameserver:
|
|
356
|
+
data["nameserver"] = args.nameserver
|
|
357
|
+
if args.searchdomain:
|
|
358
|
+
data["searchdomain"] = args.searchdomain
|
|
359
|
+
if args.cicustom:
|
|
360
|
+
data["cicustom"] = args.cicustom
|
|
361
|
+
if args.sshkeys:
|
|
362
|
+
# sshkeys can be a file path or inline content
|
|
363
|
+
try:
|
|
364
|
+
with open(args.sshkeys) as f:
|
|
365
|
+
sshkeys_value = f.read().strip()
|
|
366
|
+
except (OSError, FileNotFoundError):
|
|
367
|
+
sshkeys_value = args.sshkeys
|
|
368
|
+
# URL-encode the SSH keys for form body
|
|
369
|
+
# Replace newlines with %0A
|
|
370
|
+
data["sshkeys"] = sshkeys_value.replace("\n", "%0A").replace("\r", "%0D").replace("=", "%3D").replace(",", "%2C").replace(":", "%3A")
|
|
371
|
+
# Add cloud-init drive if cloud-init is being used and no cdrom is set
|
|
372
|
+
if cloudinit_used and not args.cdrom:
|
|
373
|
+
data["ide2"] = "local:cloudinit,media=cdrom"
|
|
315
374
|
|
|
316
375
|
# Build form-encoded body manually — httpx's data= would double-encode %
|
|
317
376
|
from urllib.parse import urlencode
|
|
@@ -396,6 +455,34 @@ def _vm_agent_interfaces(args: argparse.Namespace, client: ProxmoxClient) -> dic
|
|
|
396
455
|
return result
|
|
397
456
|
|
|
398
457
|
|
|
458
|
+
# ---------------------------------------------------------------------------
|
|
459
|
+
# VM cloud-init handlers
|
|
460
|
+
# ---------------------------------------------------------------------------
|
|
461
|
+
|
|
462
|
+
def _vm_cloudinit_generate(args: argparse.Namespace, client: ProxmoxClient) -> dict:
|
|
463
|
+
"""Regenerate the cloud-init ISO from the VM's current cloud-init config.
|
|
464
|
+
|
|
465
|
+
In Proxmox VE 9, cloud-init ISOs are regenerated automatically when
|
|
466
|
+
config changes. This triggers regeneration by re-setting the citype.
|
|
467
|
+
"""
|
|
468
|
+
node = _resolve_node(client, args.node, args.vmid)
|
|
469
|
+
if not node:
|
|
470
|
+
return {"error": f"VM {args.vmid} not found"}
|
|
471
|
+
# Proxmox VE 9 regenerates cloud-init on config change.
|
|
472
|
+
# Re-submit the current citype to trigger regeneration.
|
|
473
|
+
config = client.get(f"/nodes/{node}/qemu/{args.vmid}/config")
|
|
474
|
+
if not isinstance(config, dict):
|
|
475
|
+
return {"error": f"Could not read config for VM {args.vmid}"}
|
|
476
|
+
citype = config.get("citype")
|
|
477
|
+
if not citype:
|
|
478
|
+
return {"error": f"VM {args.vmid} has no cloud-init configured (missing citype)"}
|
|
479
|
+
result = client.put(
|
|
480
|
+
f"/nodes/{node}/qemu/{args.vmid}/config",
|
|
481
|
+
data={"citype": citype},
|
|
482
|
+
)
|
|
483
|
+
return {"data": result} if isinstance(result, (str, type(None))) or not isinstance(result, dict) else result
|
|
484
|
+
|
|
485
|
+
|
|
399
486
|
# ---------------------------------------------------------------------------
|
|
400
487
|
# VM snapshot handlers
|
|
401
488
|
# ---------------------------------------------------------------------------
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|