proxmox: Delete with seed cleanup, health Checker, CRD type comment
Delete stops and destroys a VM of ours under the per-name lock, then deletes its seed on the VM's node, or on every node when no VM of ours was left. A hibernated VM of ours is Permanent, as in Create. The Checker verifies each node concurrently (version, node status, ISO storage, templates) and adds CertificateInvalid, CredentialsUnavailable, StorageNotFound and TemplateNotFound to the shared reasons. The ProviderHealth type comment gains proxmox. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -68,7 +68,7 @@ type ProviderTarget struct {
|
|||||||
// ProviderHealthStatus is the result of the latest check of one provider.
|
// ProviderHealthStatus is the result of the latest check of one provider.
|
||||||
type ProviderHealthStatus struct {
|
type ProviderHealthStatus struct {
|
||||||
// type is the provider's type from the providers config: kubernetes,
|
// type is the provider's type from the providers config: kubernetes,
|
||||||
// gcp, kvm or kvm2.
|
// gcp, kvm, kvm2 or proxmox.
|
||||||
// +optional
|
// +optional
|
||||||
Type string `json:"type,omitempty"`
|
Type string `json:"type,omitempty"`
|
||||||
|
|
||||||
|
|||||||
@@ -183,7 +183,7 @@ spec:
|
|||||||
type:
|
type:
|
||||||
description: |-
|
description: |-
|
||||||
type is the provider's type from the providers config: kubernetes,
|
type is the provider's type from the providers config: kubernetes,
|
||||||
gcp, kvm or kvm2.
|
gcp, kvm, kvm2 or proxmox.
|
||||||
type: string
|
type: string
|
||||||
type: object
|
type: object
|
||||||
type: object
|
type: object
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ Pairs with [docs/plans/2026-09-18-1502-proxmox-provider.md](../plans/2026-09-18-
|
|||||||
- [x] Phase 4 — Seam, HTTP client, decorator, fake
|
- [x] Phase 4 — Seam, HTTP client, decorator, fake
|
||||||
- [x] Phase 5 — Get, ListByTag, task helpers
|
- [x] Phase 5 — Get, ListByTag, task helpers
|
||||||
- [x] Phase 6 — Create
|
- [x] Phase 6 — Create
|
||||||
- [ ] Phase 7 — Delete, Checker, CRD comment
|
- [x] Phase 7 — Delete, Checker, CRD comment
|
||||||
- [ ] Phase 8 — Contract and observability sweep, integration test, testing.md
|
- [ ] Phase 8 — Contract and observability sweep, integration test, testing.md
|
||||||
- [ ] Phase 9 — Deployment and documentation
|
- [ ] Phase 9 — Deployment and documentation
|
||||||
- [ ] Phase 10 — Review, real-host validation, changelog, MR
|
- [ ] Phase 10 — Review, real-host validation, changelog, MR
|
||||||
@@ -789,3 +789,153 @@ Worth noting:
|
|||||||
would find the VM. This stays untested, like every cross-node path
|
would find the VM. This stays untested, like every cross-node path
|
||||||
(entry 24).
|
(entry 24).
|
||||||
- Nothing in this phase talked to the host.
|
- Nothing in this phase talked to the host.
|
||||||
|
|
||||||
|
## Phase 7 — Delete, Checker, CRD comment
|
||||||
|
|
||||||
|
`delete.go` holds `Delete`: parse, the per-name lock, `destroy` (resolve,
|
||||||
|
the lock and status checks, config, ownership, stop, destroy) and the seed
|
||||||
|
delete. `check.go` holds `Check`, which fans out one `checkNode` per node
|
||||||
|
with `wg.Go` and returns the verdicts in config order, plus `checkReason`
|
||||||
|
and the four provider reasons. The Delete stub and
|
||||||
|
`TestStubs_answerTransient` are gone, and `grep -rn 'not implemented'
|
||||||
|
internal/provider/proxmox/` is empty. All four contract methods and
|
||||||
|
`Check` are real. The `status.type` comment in
|
||||||
|
`api/v1alpha1/providerhealth_types.go` now ends `kvm2 or proxmox`, and
|
||||||
|
`make manifests` changed only that description line in the ProviderHealth
|
||||||
|
CRD.
|
||||||
|
|
||||||
|
The spec won over the plan as the Phase 7 notes said. An absent VM still
|
||||||
|
gets its seed deleted, on every configured node. A task waits on the seed
|
||||||
|
delete, and nothing matches F13's absent-volume message. A paused VM gets
|
||||||
|
the stop too. The Checker's reasons include `CertificateInvalid` and
|
||||||
|
`CredentialsUnavailable`, and pveproxy's 595 and 596 are `Unreachable`.
|
||||||
|
|
||||||
|
Open questions decided (spec entries 11 and 16 updated):
|
||||||
|
|
||||||
|
- **Our hibernated VM (`lock: suspended`) is Permanent in Delete:** "resume
|
||||||
|
or destroy it in Proxmox", the same answer Create's ownership check
|
||||||
|
gives. Proxmox's destroy refuses a locked VM (the fake answers with F7's
|
||||||
|
`VM is locked (…)` text; the suspended variant was never captured), and
|
||||||
|
that lock never clears on its own. Transient would retry forever. A
|
||||||
|
Permanent from Delete in `reconcileManaged`'s Stopped branch goes through
|
||||||
|
`providerFailure` and latches the Proxy with the admin action in its
|
||||||
|
status. On the finalizer path `deletionFailure` keeps retrying, and the
|
||||||
|
retries can be seen. Nothing captured shows what a hibernated VM does on
|
||||||
|
a stop or start through the API, so there was no evidence for a
|
||||||
|
self-healing path (for example, start to resume, then stop and destroy).
|
||||||
|
A hibernated VM that is not ours is left alone. The resources-entry lock
|
||||||
|
check skips `suspended`, so ownership is judged from the config, as in
|
||||||
|
Get.
|
||||||
|
- **The Checker reads `p.cfg.TokenFile` itself, through the client's
|
||||||
|
`readToken`**, before its first request, and keeps only the part before
|
||||||
|
`=`. A seam method would break the rule of one method per endpoint, and
|
||||||
|
both `TestClient_methodPathAndParams` and
|
||||||
|
`TestObserve_everySeamMethodLogsOneLine` enumerate the interface. An
|
||||||
|
unusable file answers `CredentialsUnavailable` without a request.
|
||||||
|
`readToken`'s errors never carry the content. Every row of
|
||||||
|
`TestCheck_reasonsTable` runs at V(2) over the decorator with a sentinel
|
||||||
|
secret and asserts that the secret is in no message, detail or log line.
|
||||||
|
|
||||||
|
Other judgment calls:
|
||||||
|
|
||||||
|
- **The seed is deleted on the VM's node only when Delete destroyed a VM
|
||||||
|
of ours; otherwise on every node.** Entry 11 said "skip to step 7" for
|
||||||
|
a VM that is foreign, unmarked, or gone by the time the config is read,
|
||||||
|
without naming a node. In each of those cases our VM's node is unknown:
|
||||||
|
the node in the listing belongs to someone else's VM, or to one that
|
||||||
|
just vanished. A container at the VMID takes the same path.
|
||||||
|
- **Stop's `does not exist` means the VM is gone**, the same as Destroy's.
|
||||||
|
The seed is then deleted on its node, and Destroy is not called.
|
||||||
|
- **`errCAFile` joins `errCredentials` in `client.go`.** A missing CA file
|
||||||
|
or one without a certificate is `CertificateInvalid` ("fix `caFile`").
|
||||||
|
Without the sentinel it would be `APIError`. Its class is unchanged
|
||||||
|
(Transient or Permanent, as entry 14 says).
|
||||||
|
- **Any 500 from the storage status call is `StorageNotFound`**, carrying
|
||||||
|
Proxmox's text. The plan matched a 500 "does not exist", but no response
|
||||||
|
for a missing storage was captured. Matching an invented string would go
|
||||||
|
against the fixture discipline, and every 500 there (missing, not
|
||||||
|
enabled on that node, offline) is fixed on the storage anyway. A 401,
|
||||||
|
403, 595 or 596 keeps its shared reason.
|
||||||
|
- **`checkReason` puts `*net.OpError` before the deadline check**, so a
|
||||||
|
dial that runs out of its own 10 s budget is `Unreachable`, not
|
||||||
|
`Timeout`. A request timing out on its own while the round still runs
|
||||||
|
is `Timeout`. The round's ctx ending is `Timeout` whatever the error
|
||||||
|
says, and that guard is in `checkNode`, as in kvm2.
|
||||||
|
- **The outcome line `proxmox vm deleted` carries `destroyed`**, which is
|
||||||
|
false when there was no VM of ours left to destroy. It has no `node` in
|
||||||
|
that case. Entry 20 lists the new V(1) no-op lines.
|
||||||
|
- `templates` counts catalogue entries, not unique VMIDs.
|
||||||
|
|
||||||
|
Test names. These plan names changed with the behaviour:
|
||||||
|
`TestDelete_absentIsNoOp` → `TestDelete_absentStillDeletesSeedOnEveryNode`,
|
||||||
|
`TestDelete_foreignNameIsNoOp` → `TestDelete_foreignNameLeavesVMAlone`,
|
||||||
|
`TestDelete_unmarkedIsNoOp` → `TestDelete_unmarkedLeavesVMAlone`, and
|
||||||
|
`TestDelete_toleratesMissingISO` merged into entry 21's
|
||||||
|
`TestDelete_seedDeleteIsTaskAndIdempotent`, which runs with the seed both
|
||||||
|
present and absent and ties the absent case to
|
||||||
|
`task_status_volume_missing.json`. Every other plan and entry 21 name
|
||||||
|
exists. `TestDelete_usesPurgeAndDestroyUnreferencedDisks` runs Delete over
|
||||||
|
the real `httpAPI` against the Phase 0 destroy captures
|
||||||
|
(`cluster_resources_during_destroy`, `config_during_destroy`,
|
||||||
|
`destroy_response`, `content_delete_response`,
|
||||||
|
`task_status_volume_missing`). `TestGet_duringDeleteStillReturnsInstance`
|
||||||
|
holds the destroy task in its first poll and first checks that the
|
||||||
|
mid-destroy captures show the VM listed, stopped and without a lock.
|
||||||
|
Tests beyond both lists: `TestDelete_containerAtVMIDIsLeftAlone`,
|
||||||
|
`TestDelete_hibernatedVMIsPermanentIfOurs`,
|
||||||
|
`TestDelete_seedDeleteFailureOnVMNodeFails`,
|
||||||
|
`TestDelete_seedDeleteNodeFailureSkippedWhenNodeUnknown`,
|
||||||
|
`TestDelete_goneMidwayIsSuccess`, `TestDelete_resourcesFailureIsTransient`,
|
||||||
|
`TestDelete_waitsOnTheNameLock` (a held name lock means no call at all,
|
||||||
|
which is what makes a Delete racing a Create wait),
|
||||||
|
`TestDelete_cancelledMidWaitReturnsPromptly`, `TestDelete_outcomeLine` and
|
||||||
|
`TestCheck_reasonsOverHTTPClient`, which takes the transport reasons from
|
||||||
|
real net/http and crypto/tls errors: unknown CA, hostname, missing CA file,
|
||||||
|
a replayed 401, 596, a closed server and a cut response. The fake gained
|
||||||
|
`destroyFailsWith`. New test helpers are only the small ones in
|
||||||
|
`delete_test.go` (`putSeed`, `seedPresent`, `delete`/`deleteFails`,
|
||||||
|
`seedDeleteNodes` read from the decorator's lines, `twoNodeFixture`) and
|
||||||
|
`check_test.go` (`newCheckFixture`, `assertNoSecret`, `setStorage`).
|
||||||
|
`TestCheck_nodesCheckedConcurrently` uses a barrier, not a timer: every
|
||||||
|
node's `Version` waits until all have arrived, so checking the nodes one
|
||||||
|
at a time fails the verdicts deterministically and does not measure
|
||||||
|
elapsed time. That avoids the kind of flake kvm2's 200 ms test has.
|
||||||
|
|
||||||
|
The mutation check applied 34 mutations: 17 in `delete.go`, 16 in
|
||||||
|
`check.go` and 1 in `client.go`. Every one compiled and every one failed at
|
||||||
|
least one test. Three did not compile on the first try (an unused `upid`
|
||||||
|
or `tokenID`) and were redone. The first run of "never stop" did fail
|
||||||
|
tests, but it also **hung** `TestDelete_concurrentSameNameSafe`, whose
|
||||||
|
wait for the first `Stop` call could not end. The wait is now bounded by
|
||||||
|
the first Delete returning, and the rerun fails fast. One reasons row
|
||||||
|
("dial cut short by the round's deadline") was added before the run,
|
||||||
|
because no existing row would have caught dropping `checkNode`'s ctx
|
||||||
|
guard. Every mutation was reverted by a script that compared each file's
|
||||||
|
hash with the original.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go test -race -count=2 ./internal/provider/proxmox/ ./internal/providerhealth/ # ok
|
||||||
|
make manifests && git diff --stat config/ # crawl.example.com_providerhealths.yaml | 2 +-
|
||||||
|
grep -rn 'not implemented' internal/provider/proxmox/ # nothing
|
||||||
|
go build ./... && go vet ./... && go vet -tags=integration ./...
|
||||||
|
go test -short -count=1 ./... # all ok
|
||||||
|
go test -count=1 -v ./internal/provider/proxmox/ | grep -c '^=== RUN' # 519 before, 649 after
|
||||||
|
bin/golangci-lint run --disable staticcheck --max-same-issues 0 --max-issues-per-linter 0 ./internal/provider/proxmox/...
|
||||||
|
```
|
||||||
|
|
||||||
|
Worth noting:
|
||||||
|
|
||||||
|
- Lint defaults hide repeats: with `--max-same-issues 0` the logcheck hits
|
||||||
|
are 4 in `create.go`, 4 in the new `delete.go`, and 2, 1 and 1 in
|
||||||
|
`get.go`, `seed.go` and `task.go`, all the ctx-plus-logger shape.
|
||||||
|
Nothing else is new except goconst on test strings. A prealloc hit in
|
||||||
|
`delete_test.go` was fixed.
|
||||||
|
- `TestCreate_concurrentSameNameSerialised` has the same unbounded
|
||||||
|
`for callCount("Clone") == 0` wait that hung the Delete test under a
|
||||||
|
mutation. It is left for the Phase 8 sweep, because this phase does not
|
||||||
|
touch Create.
|
||||||
|
- Never seen on the host, for Phase 10: what the storage status call
|
||||||
|
answers for a missing or disabled storage, what destroy says for a
|
||||||
|
hibernated VM, and a stop on a paused VM. The try-every-node seed delete
|
||||||
|
and the multi-node Checker have only met the fake (entry 24).
|
||||||
|
- Nothing in this phase talked to the host.
|
||||||
|
|||||||
@@ -381,22 +381,35 @@ the seed ISO; the VM's disks are removed by the destroy call itself, with
|
|||||||
`purge=1&destroy-unreferenced-disks=1`. Under the per-name lock:
|
`purge=1&destroy-unreferenced-disks=1`. Under the per-name lock:
|
||||||
|
|
||||||
1. Parse the ID (`ErrPermanent` if malformed).
|
1. Parse the ID (`ErrPermanent` if malformed).
|
||||||
2. `/cluster/resources`. VMID absent: skip to step 7, trying every configured
|
2. `/cluster/resources`. VMID absent, or held by a container: skip to
|
||||||
node because the VM's node is no longer known.
|
step 7, trying every configured node because the VM's node is no longer
|
||||||
3. `lock` set or `status: unknown`: `ErrTransient`. A clone or another task
|
known.
|
||||||
still running finishes first; the reconciler calls Delete again after
|
3. `lock` set, other than `suspended`, or `status: unknown`: `ErrTransient`.
|
||||||
`DeletionPoll`.
|
A clone or another task still running finishes first; the reconciler
|
||||||
4. Read the config. `does not exist`: skip to step 7. A different name, or
|
calls Delete again after `DeletionPoll`.
|
||||||
no mark: the VM is not ours; log it and skip to step 7 without touching
|
4. Read the config. `does not exist`: skip to step 7 on every node. A
|
||||||
it.
|
different name, or no mark: the VM is not ours; log it at V(1) and skip
|
||||||
5. Status other than `stopped`: `POST …/status/stop`, wait (stop budget).
|
to step 7 on every node without touching it. Our VM with a `lock` other
|
||||||
Stop works on a paused VM too.
|
than `suspended` (taken since the listing): `ErrTransient`. Our VM with
|
||||||
|
`lock: suspended` (hibernated): `ErrPermanent` asking the admin to resume
|
||||||
|
or destroy it, the same answer Create's ownership check gives. The
|
||||||
|
destroy refuses a locked VM, and that lock never clears on its own, so
|
||||||
|
Transient would retry forever; Permanent puts the admin action in the
|
||||||
|
Proxy's status, where `reconcileManaged` latches it and the finalizer
|
||||||
|
path keeps retrying it visibly. A hibernated VM that is not ours is left
|
||||||
|
alone like any other.
|
||||||
|
5. Status (from the listing) other than `stopped`: `POST …/status/stop`,
|
||||||
|
wait (stop budget). Stop works on a paused VM too, which the listing
|
||||||
|
shows as `running`. `does not exist` means the VM is gone.
|
||||||
6. `DELETE /nodes/{node}/qemu/{vmid}?purge=1&destroy-unreferenced-disks=1`,
|
6. `DELETE /nodes/{node}/qemu/{vmid}?purge=1&destroy-unreferenced-disks=1`,
|
||||||
wait (destroy budget). `does not exist` is success.
|
wait (destroy budget). `does not exist` is success.
|
||||||
7. `DELETE /nodes/{node}/storage/{isoStorage}/content/<isoStorage>:iso/<name>-seed.iso`,
|
7. `DELETE /nodes/{node}/storage/{isoStorage}/content/<isoStorage>:iso/<name>-seed.iso`,
|
||||||
wait (seed-delete budget). A missing volume is still a successful task,
|
wait (seed-delete budget), on the VM's node when steps 5 and 6 ran on a
|
||||||
so no message is matched. On the try-every-node path a node whose
|
VM of ours, and on every configured node in config order otherwise. A
|
||||||
request fails is logged and skipped.
|
missing volume is still a successful task, so no message is matched. On
|
||||||
|
the every-node path a node whose request or task fails is logged at V(1)
|
||||||
|
and skipped, unless ctx ended, which fails the call; on the VM's node a
|
||||||
|
failure fails the call (a 403 for `Datastore.Allocate` is Permanent).
|
||||||
8. Return nil.
|
8. Return nil.
|
||||||
|
|
||||||
Everything is gone when Delete returns nil, so `Get` afterwards is
|
Everything is gone when Delete returns nil, so `Get` afterwards is
|
||||||
@@ -593,18 +606,31 @@ failed for a network reason, or pveproxy answered 595 or 596);
|
|||||||
`APIError` (any other failure). Provider-specific, each fixed differently by
|
`APIError` (any other failure). Provider-specific, each fixed differently by
|
||||||
an admin:
|
an admin:
|
||||||
|
|
||||||
- `CertificateInvalid`: TLS verification failed. Fix `url` or `caFile`, not
|
- `CertificateInvalid`: TLS verification failed, or `caFile` is missing or
|
||||||
the host.
|
holds no certificate. Fix `url` or `caFile`, not the host.
|
||||||
- `CredentialsUnavailable`: the token file is missing or malformed. Fix the
|
- `CredentialsUnavailable`: the token file is missing or malformed. Fix the
|
||||||
Secret, not Proxmox.
|
Secret, not Proxmox.
|
||||||
- `StorageNotFound`: `isoStorage` is missing, inactive, or has no `iso`
|
- `StorageNotFound`: `isoStorage` is missing, inactive, or has no `iso`
|
||||||
content. Fix the storage on the node.
|
content. Fix the storage on the node. Any 500 from the storage status
|
||||||
|
call counts as missing, whatever its wording (it does not exist, is not
|
||||||
|
enabled on that node, or is offline; no missing-storage response was
|
||||||
|
captured, so no text is matched), and the message carries Proxmox's
|
||||||
|
text. A 401, 403, 595 or 596 there keeps its shared reason.
|
||||||
- `TemplateNotFound`: a configured template VMID does not exist or is not a
|
- `TemplateNotFound`: a configured template VMID does not exist or is not a
|
||||||
template. Fix the template or the catalogue.
|
template. Fix the template or the catalogue.
|
||||||
|
|
||||||
Details: `pveVersion`, `kernel`, `tokenID` (the part before `=`, never the
|
Details: `pveVersion`, `kernel`, `tokenID` (the part before `=`, never the
|
||||||
secret), `isoStorage`, `isoStorageAvailGiB`, `templates`, filled as far as
|
secret), `isoStorage`, `isoStorageAvailGiB`, `templates`, filled as far as
|
||||||
the check got. Cost: four requests per node per round. The `status.type`
|
the check got; `isoStorage` is always there. `templates` counts catalogue
|
||||||
|
entries. The Checker reads `tokenFile` itself, before the first request,
|
||||||
|
with the client's `readToken`: the seam stays one method per endpoint, and
|
||||||
|
a missing or malformed file answers `CredentialsUnavailable` without a
|
||||||
|
request. Only the part before `=` is kept.
|
||||||
|
|
||||||
|
`Timeout` also covers a request that runs out of its own timeout while the
|
||||||
|
round is still running. A dial or a connection cut mid-response is
|
||||||
|
`Unreachable`, even when the dial ran out of its own 10 s budget, because
|
||||||
|
the node did not answer. Cost: four requests per node per round. The `status.type`
|
||||||
comment in `api/v1alpha1/providerhealth_types.go` gains `proxmox` and the
|
comment in `api/v1alpha1/providerhealth_types.go` gains `proxmox` and the
|
||||||
CRD is regenerated.
|
CRD is regenerated.
|
||||||
|
|
||||||
@@ -713,14 +739,16 @@ named `proxmox`, with `provider` on every line and `vmid`, `name` and
|
|||||||
`node` on every line about an instance.
|
`node` on every line about an instance.
|
||||||
|
|
||||||
- V(1): one line per operation outcome (`proxmox vm ensured running`,
|
- V(1): one line per operation outcome (`proxmox vm ensured running`,
|
||||||
`proxmox vm fetched` with state, ip and uid, `proxmox vm deleted`,
|
`proxmox vm fetched` with state, ip and uid, `proxmox vm deleted` with
|
||||||
|
`destroyed` (false when no VM of ours was left to destroy),
|
||||||
`proxmox vms listed` with counts, `proxmox node checked` with the reason),
|
`proxmox vms listed` with counts, `proxmox node checked` with the reason),
|
||||||
one `proxmox api call` line per seam call from the decorator (`call`,
|
one `proxmox api call` line per seam call from the decorator (`call`,
|
||||||
`node`, `vmid`, `storage`, `upid`, `duration`, plus `newid` and `name`
|
`node`, `vmid`, `storage`, `upid`, `duration`, plus `newid` and `name`
|
||||||
on a clone, `filename` on an upload and `volid` on a volume delete, and
|
on a clone, `filename` on an upload and `volid` on a volume delete, and
|
||||||
on failure `httpStatus` and `error`), a `proxmox task failed` line with the UPID and
|
on failure `httpStatus` and `error`), a `proxmox task failed` line with the UPID and
|
||||||
`exitstatus`, and the no-op lines of Delete and ListByTag (foreign VM
|
`exitstatus`, and the no-op lines of Delete and ListByTag (VM already
|
||||||
left alone, unreadable candidate skipped).
|
gone, foreign or unmarked VM left alone, seed delete skipped on a node,
|
||||||
|
unreadable candidate skipped).
|
||||||
- V(2): request parameters (without `description`'s content at V(1) and
|
- V(2): request parameters (without `description`'s content at V(1) and
|
||||||
never the upload body), config summaries, agent addresses, the resolved
|
never the upload body), config summaries, agent addresses, the resolved
|
||||||
Create parameters with `cloudInitBytes`.
|
Create parameters with `cloudInitBytes`.
|
||||||
|
|||||||
@@ -0,0 +1,218 @@
|
|||||||
|
package proxmox
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"maps"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"slices"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/internal/provider"
|
||||||
|
)
|
||||||
|
|
||||||
|
var _ provider.Checker = (*Provider)(nil)
|
||||||
|
|
||||||
|
// Reasons only this provider can tell apart (spec entry 16). Each names a
|
||||||
|
// failure an admin fixes somewhere other than where the shared reasons
|
||||||
|
// point.
|
||||||
|
const (
|
||||||
|
// reasonCertificateInvalid: TLS verification failed or the CA file is
|
||||||
|
// unusable; fix url or caFile, not the host.
|
||||||
|
reasonCertificateInvalid = "CertificateInvalid"
|
||||||
|
// reasonCredentialsUnavailable: the token file is missing or malformed;
|
||||||
|
// fix the Secret, not Proxmox.
|
||||||
|
reasonCredentialsUnavailable = "CredentialsUnavailable"
|
||||||
|
// reasonStorageNotFound: isoStorage is missing, inactive, or holds no
|
||||||
|
// iso content on the node; fix the storage on the node.
|
||||||
|
reasonStorageNotFound = "StorageNotFound"
|
||||||
|
// reasonTemplateNotFound: a configured template VMID does not exist or
|
||||||
|
// is not a template; fix the template or the catalogue.
|
||||||
|
reasonTemplateNotFound = "TemplateNotFound"
|
||||||
|
)
|
||||||
|
|
||||||
|
// pveproxy's own answers when it cannot reach the node behind it.
|
||||||
|
const (
|
||||||
|
statusProxyConnect = 595
|
||||||
|
statusProxyBroken = 596
|
||||||
|
)
|
||||||
|
|
||||||
|
// Check verifies every configured node concurrently and returns one verdict
|
||||||
|
// per node in config order. The requests share the provider's HTTP client,
|
||||||
|
// but each is its own request, so a round that runs out of time cancels
|
||||||
|
// only its own requests and never a reconcile's.
|
||||||
|
func (p *Provider) Check(ctx context.Context) []provider.TargetStatus {
|
||||||
|
out := make([]provider.TargetStatus, len(p.cfg.Nodes))
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for i, node := range p.cfg.Nodes {
|
||||||
|
wg.Go(func() { out[i] = p.checkNode(ctx, node) })
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Provider) checkNode(ctx context.Context, node string) provider.TargetStatus {
|
||||||
|
start := time.Now()
|
||||||
|
log := p.logger(ctx).WithValues("node", node)
|
||||||
|
ts := provider.TargetStatus{
|
||||||
|
Name: node,
|
||||||
|
Address: p.cfg.URL,
|
||||||
|
Details: map[string]string{"isoStorage": p.cfg.ISOStorage},
|
||||||
|
}
|
||||||
|
err := p.probeNode(ctx, node, ts.Details)
|
||||||
|
ts.Latency = time.Since(start)
|
||||||
|
switch {
|
||||||
|
case err == nil:
|
||||||
|
ts.Ready = true
|
||||||
|
ts.Reason = provider.ReasonReachable
|
||||||
|
case ctx.Err() != nil:
|
||||||
|
// The round ran out of time, whichever request was running.
|
||||||
|
ts.Reason = provider.ReasonTimeout
|
||||||
|
ts.Message = err.Error()
|
||||||
|
default:
|
||||||
|
ts.Reason = checkReason(err)
|
||||||
|
ts.Message = err.Error()
|
||||||
|
}
|
||||||
|
log.V(1).Info("proxmox node checked", "ready", ts.Ready, "reason", ts.Reason, "latency", ts.Latency)
|
||||||
|
return ts
|
||||||
|
}
|
||||||
|
|
||||||
|
// probeNode runs spec entry 16's four requests and fills details as facts
|
||||||
|
// arrive, so a failure late in the sequence still reports what came before.
|
||||||
|
func (p *Provider) probeNode(ctx context.Context, node string, details map[string]string) error {
|
||||||
|
// The token file is read here only for its id: the secret after "="
|
||||||
|
// is dropped at once and never reaches details, logs or errors.
|
||||||
|
token, err := readToken(p.cfg.TokenFile)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tokenID, _, _ := strings.Cut(token, "=")
|
||||||
|
details["tokenID"] = tokenID
|
||||||
|
|
||||||
|
v, err := p.api.Version(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("reading the API version: %w", err)
|
||||||
|
}
|
||||||
|
details["pveVersion"] = v.Version
|
||||||
|
ns, err := p.api.NodeStatus(ctx, node)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("reading the status of node %s: %w", node, err)
|
||||||
|
}
|
||||||
|
details["kernel"] = ns.CurrentKernel.Release
|
||||||
|
if err := p.checkStorage(ctx, node, details); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return p.checkTemplates(ctx, details)
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkStorage reads isoStorage on the node. A 500 means Proxmox cannot
|
||||||
|
// use that storage on this node (it does not exist, is not enabled here,
|
||||||
|
// or is offline), whatever its wording; the message carries Proxmox's
|
||||||
|
// text.
|
||||||
|
func (p *Provider) checkStorage(ctx context.Context, node string, details map[string]string) error {
|
||||||
|
storage := p.cfg.ISOStorage
|
||||||
|
st, err := p.api.StorageStatus(ctx, node, storage)
|
||||||
|
var ae *apiError
|
||||||
|
if errors.As(err, &ae) && ae.Status == http.StatusInternalServerError {
|
||||||
|
return &checkFailure{reason: reasonStorageNotFound, msg: fmt.Sprintf("storage %s on node %s: %s", storage, node, ae.Message)}
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("reading storage %s on node %s: %w", storage, node, err)
|
||||||
|
}
|
||||||
|
details["isoStorageAvailGiB"] = formatGiB(st.Avail)
|
||||||
|
if st.Active == 0 {
|
||||||
|
return &checkFailure{reason: reasonStorageNotFound, msg: fmt.Sprintf("storage %s is not active on node %s", storage, node)}
|
||||||
|
}
|
||||||
|
if !slices.Contains(strings.Split(st.Content, ","), "iso") {
|
||||||
|
return &checkFailure{reason: reasonStorageNotFound, msg: fmt.Sprintf("storage %s on node %s does not hold iso content (content: %s)", storage, node, st.Content)}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkTemplates looks every catalogue entry up in the cluster's resources:
|
||||||
|
// a template may sit on any node, since a clone can target another.
|
||||||
|
func (p *Provider) checkTemplates(ctx context.Context, details map[string]string) error {
|
||||||
|
rs, err := p.api.ClusterResources(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("listing cluster resources: %w", err)
|
||||||
|
}
|
||||||
|
isTemplate := make(map[int]bool)
|
||||||
|
for _, r := range rs {
|
||||||
|
if r.Type == "qemu" {
|
||||||
|
isTemplate[r.VMID] = r.Template == 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var problems []string
|
||||||
|
for _, key := range slices.Sorted(maps.Keys(p.cfg.Templates)) {
|
||||||
|
vmid := p.cfg.Templates[key].VMID
|
||||||
|
switch tmpl, found := isTemplate[vmid]; {
|
||||||
|
case !found:
|
||||||
|
problems = append(problems, fmt.Sprintf("template %q: VM %d does not exist", key, vmid))
|
||||||
|
case !tmpl:
|
||||||
|
problems = append(problems, fmt.Sprintf("template %q: VM %d is not a template", key, vmid))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
details["templates"] = fmt.Sprintf("%d/%d", len(p.cfg.Templates)-len(problems), len(p.cfg.Templates))
|
||||||
|
if len(problems) > 0 {
|
||||||
|
return &checkFailure{reason: reasonTemplateNotFound, msg: strings.Join(problems, "; ")}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkFailure is a verdict the backend answered but the check refuses: the
|
||||||
|
// storage or a template is not usable.
|
||||||
|
type checkFailure struct {
|
||||||
|
reason string
|
||||||
|
msg string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *checkFailure) Error() string { return e.msg }
|
||||||
|
|
||||||
|
// checkReason maps a failed probe to a reason once checkNode has ruled out
|
||||||
|
// the round's ctx ending. A failed connect or a cut connection is
|
||||||
|
// Unreachable; a request timeout with the round still running is Timeout.
|
||||||
|
func checkReason(err error) string {
|
||||||
|
var (
|
||||||
|
cf *checkFailure
|
||||||
|
ae *apiError
|
||||||
|
op *net.OpError
|
||||||
|
ne net.Error
|
||||||
|
)
|
||||||
|
switch {
|
||||||
|
case errors.As(err, &cf):
|
||||||
|
return cf.reason
|
||||||
|
case errors.Is(err, errCredentials):
|
||||||
|
return reasonCredentialsUnavailable
|
||||||
|
case errors.Is(err, errCAFile), isTLSVerification(err):
|
||||||
|
return reasonCertificateInvalid
|
||||||
|
case errors.As(err, &ae):
|
||||||
|
switch ae.Status {
|
||||||
|
case http.StatusUnauthorized:
|
||||||
|
return provider.ReasonUnauthorized
|
||||||
|
case http.StatusForbidden:
|
||||||
|
return provider.ReasonForbidden
|
||||||
|
case statusProxyConnect, statusProxyBroken:
|
||||||
|
return provider.ReasonUnreachable
|
||||||
|
}
|
||||||
|
return provider.ReasonAPIError
|
||||||
|
case errors.As(err, &op):
|
||||||
|
// Checked before the deadline: a dial that runs out of its own
|
||||||
|
// budget is a node that did not answer.
|
||||||
|
return provider.ReasonUnreachable
|
||||||
|
case errors.Is(err, context.DeadlineExceeded), errors.Is(err, context.Canceled):
|
||||||
|
return provider.ReasonTimeout
|
||||||
|
case errors.As(err, &ne), errors.Is(err, io.EOF), errors.Is(err, io.ErrUnexpectedEOF):
|
||||||
|
return provider.ReasonUnreachable
|
||||||
|
}
|
||||||
|
return provider.ReasonAPIError
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatGiB(bytes int64) string {
|
||||||
|
return strconv.FormatFloat(float64(bytes)/(1<<30), 'f', 1, 64)
|
||||||
|
}
|
||||||
@@ -0,0 +1,577 @@
|
|||||||
|
package proxmox
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/x509"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"maps"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"path/filepath"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"syscall"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/internal/provider"
|
||||||
|
)
|
||||||
|
|
||||||
|
// checkSecret is the secret half of the Checker's token file; it must never
|
||||||
|
// reach a verdict or a log line.
|
||||||
|
const checkSecret = "SENTINEL-token-secret-4c1d"
|
||||||
|
|
||||||
|
// newCheckFixture is an observed create fixture at V(2) with a token file
|
||||||
|
// whose secret is checkSecret.
|
||||||
|
func newCheckFixture(t *testing.T) *fixture {
|
||||||
|
t.Helper()
|
||||||
|
fx := newCreateFixture(t, 2)
|
||||||
|
fx.p.api = observe(fx.fake, testProvider)
|
||||||
|
fx.p.cfg.TokenFile = filepath.Join(t.TempDir(), "token")
|
||||||
|
writeFile(t, fx.p.cfg.TokenFile, "proxy-operator@pve!operator="+checkSecret+"\n")
|
||||||
|
return fx
|
||||||
|
}
|
||||||
|
|
||||||
|
// assertNoSecret checks every verdict and every log line for checkSecret.
|
||||||
|
func assertNoSecret(t *testing.T, fx *fixture, got []provider.TargetStatus) {
|
||||||
|
t.Helper()
|
||||||
|
for _, ts := range got {
|
||||||
|
if strings.Contains(ts.Message, checkSecret) || slices.ContainsFunc(slices.Collect(maps.Values(ts.Details)), func(v string) bool { return strings.Contains(v, checkSecret) }) {
|
||||||
|
t.Errorf("verdict carries the token secret: %+v", ts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, l := range fx.logs.all() {
|
||||||
|
if strings.Contains(l, checkSecret) {
|
||||||
|
t.Errorf("log line carries the token secret: %s", l)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheck_oneEntryPerNodeInConfigOrder(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
fx := newCheckFixture(t)
|
||||||
|
fx.p.cfg.Nodes = []string{"pv1-3", fakeNode, "pv1-2"}
|
||||||
|
fx.fake.nodes["pv1-3"] = fx.fake.nodes[fakeNode]
|
||||||
|
got := fx.p.Check(fx.ctx)
|
||||||
|
if len(got) != len(fx.p.cfg.Nodes) {
|
||||||
|
t.Fatalf("Check() returned %d targets, want %d", len(got), len(fx.p.cfg.Nodes))
|
||||||
|
}
|
||||||
|
for i, node := range fx.p.cfg.Nodes {
|
||||||
|
ts := got[i]
|
||||||
|
if ts.Name != node || ts.Address != fx.p.cfg.URL {
|
||||||
|
t.Errorf("target %d = %s (%s), want %s (%s)", i, ts.Name, ts.Address, node, fx.p.cfg.URL)
|
||||||
|
}
|
||||||
|
if wantReady := node != "pv1-2"; ts.Ready != wantReady {
|
||||||
|
t.Errorf("target %s ready = %v, want %v (%s: %s)", node, ts.Ready, wantReady, ts.Reason, ts.Message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got[2].Reason != provider.ReasonAPIError || !strings.Contains(got[2].Message, "pv1-2") {
|
||||||
|
t.Errorf("pv1-2 verdict = %s: %s, want APIError naming the node", got[2].Reason, got[2].Message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCheck_nodesCheckedConcurrently holds every node's first request
|
||||||
|
// until all of them have arrived; checked one at a time, the first would
|
||||||
|
// give up and fail its verdict.
|
||||||
|
func TestCheck_nodesCheckedConcurrently(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
fx := newCheckFixture(t)
|
||||||
|
fx.p.cfg.Nodes = []string{fakeNode, "pv1-2", "pv1-3"}
|
||||||
|
for _, n := range fx.p.cfg.Nodes[1:] {
|
||||||
|
fx.fake.nodes[n] = fx.fake.nodes[fakeNode]
|
||||||
|
}
|
||||||
|
var (
|
||||||
|
mu sync.Mutex
|
||||||
|
arrived int
|
||||||
|
)
|
||||||
|
all := make(chan struct{})
|
||||||
|
fx.fake.hookOn("Version", func() error {
|
||||||
|
mu.Lock()
|
||||||
|
if arrived++; arrived == len(fx.p.cfg.Nodes) {
|
||||||
|
close(all)
|
||||||
|
}
|
||||||
|
mu.Unlock()
|
||||||
|
select {
|
||||||
|
case <-all:
|
||||||
|
return nil
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
return errors.New("barrier: the nodes were not checked concurrently")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
for _, ts := range fx.p.Check(fx.ctx) {
|
||||||
|
if !ts.Ready {
|
||||||
|
t.Errorf("target %s = %s: %s, want Ready", ts.Name, ts.Reason, ts.Message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheck_expiredContextReturnsPromptly(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
fx := newCheckFixture(t)
|
||||||
|
fx.p.cfg.Nodes = []string{fakeNode, "pv1-2"}
|
||||||
|
fx.fake.blockOn("Version")
|
||||||
|
ctx, cancel := context.WithTimeout(fx.ctx, 0)
|
||||||
|
defer cancel()
|
||||||
|
start := time.Now()
|
||||||
|
got := fx.p.Check(ctx)
|
||||||
|
if elapsed := time.Since(start); elapsed > time.Second {
|
||||||
|
t.Errorf("Check() took %v with an expired ctx, want under 1s", elapsed)
|
||||||
|
}
|
||||||
|
if len(got) != 2 {
|
||||||
|
t.Fatalf("Check() returned %d targets, want 2", len(got))
|
||||||
|
}
|
||||||
|
for _, ts := range got {
|
||||||
|
if ts.Ready || ts.Reason != provider.ReasonTimeout || !strings.Contains(ts.Message, context.DeadlineExceeded.Error()) {
|
||||||
|
t.Errorf("target %s = ready %v reason %s (%s), want Timeout naming the deadline", ts.Name, ts.Ready, ts.Reason, ts.Message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheck_reasonsTable(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
urlErr := func(err error) error {
|
||||||
|
return &url.Error{Op: "Get", URL: "https://192.0.2.8:8006/api2/json/version", Err: err}
|
||||||
|
}
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
prep func(fx *fixture)
|
||||||
|
timeout time.Duration
|
||||||
|
cancel bool
|
||||||
|
|
||||||
|
wantReady bool
|
||||||
|
wantReason string
|
||||||
|
wantMessage string
|
||||||
|
}{
|
||||||
|
{name: "ok", wantReady: true, wantReason: provider.ReasonReachable},
|
||||||
|
{
|
||||||
|
name: "token file missing",
|
||||||
|
prep: func(fx *fixture) { fx.p.cfg.TokenFile = filepath.Join(fx.t.TempDir(), "absent") },
|
||||||
|
wantReason: reasonCredentialsUnavailable,
|
||||||
|
wantMessage: "does not exist yet",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "token file malformed",
|
||||||
|
prep: func(fx *fixture) { writeFile(fx.t, fx.p.cfg.TokenFile, checkSecret+"\n") },
|
||||||
|
wantReason: reasonCredentialsUnavailable,
|
||||||
|
wantMessage: "is not <user>@<realm>!<tokenid>=<secret>",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "token file gone by the time the client reads it",
|
||||||
|
prep: func(fx *fixture) {
|
||||||
|
fx.fake.failWith("NodeStatus", fmt.Errorf("%w: %w: /etc/proxy-operator/proxmox/token does not exist yet", provider.ErrTransient, errCredentials))
|
||||||
|
},
|
||||||
|
wantReason: reasonCredentialsUnavailable,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unknown certificate authority",
|
||||||
|
prep: func(fx *fixture) { fx.fake.failWith("Version", urlErr(x509.UnknownAuthorityError{})) },
|
||||||
|
wantReason: reasonCertificateInvalid,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "host not in the certificate",
|
||||||
|
prep: func(fx *fixture) { fx.fake.failWith("Version", urlErr(x509.HostnameError{Host: "192.168.2.8"})) },
|
||||||
|
wantReason: reasonCertificateInvalid,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "CA file unusable",
|
||||||
|
prep: func(fx *fixture) {
|
||||||
|
fx.fake.failWith("Version", fmt.Errorf("%w: %w: reading CA file: %w", provider.ErrTransient, errCAFile, syscall.ENOENT))
|
||||||
|
},
|
||||||
|
wantReason: reasonCertificateInvalid,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "wrong secret",
|
||||||
|
prep: func(fx *fixture) { fx.fake.failWith("Version", loadAPIError(fx.t, "err_unauthorized")) },
|
||||||
|
wantReason: provider.ReasonUnauthorized,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no credentials sent",
|
||||||
|
prep: func(fx *fixture) { fx.fake.failWith("Version", loadAPIError(fx.t, "err_no_credentials")) },
|
||||||
|
wantReason: provider.ReasonUnauthorized,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "privilege missing",
|
||||||
|
prep: func(fx *fixture) { fx.fake.failWith("NodeStatus", loadAPIError(fx.t, "err_forbidden")) },
|
||||||
|
wantReason: provider.ReasonForbidden,
|
||||||
|
wantMessage: "Sys.Syslog",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "pveproxy cannot reach the node",
|
||||||
|
prep: func(fx *fixture) { fx.fake.failWith("NodeStatus", fakeErr(595, "Connection refused")) },
|
||||||
|
wantReason: provider.ReasonUnreachable,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "pveproxy broken pipe",
|
||||||
|
prep: func(fx *fixture) { fx.fake.failWith("NodeStatus", fakeErr(596, "Broken pipe")) },
|
||||||
|
wantReason: provider.ReasonUnreachable,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "connection refused",
|
||||||
|
prep: func(fx *fixture) {
|
||||||
|
fx.fake.failWith("Version", urlErr(&net.OpError{Op: "dial", Net: "tcp", Err: syscall.ECONNREFUSED}))
|
||||||
|
},
|
||||||
|
wantReason: provider.ReasonUnreachable,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "node status 500",
|
||||||
|
prep: func(fx *fixture) { fx.fake.failWith("NodeStatus", fakeErr(500, "fake: pve-cluster not running")) },
|
||||||
|
wantReason: provider.ReasonAPIError,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "iso storage missing",
|
||||||
|
prep: func(fx *fixture) {
|
||||||
|
fx.fake.mu.Lock()
|
||||||
|
delete(fx.fake.storages, fakeISOStorage)
|
||||||
|
fx.fake.mu.Unlock()
|
||||||
|
},
|
||||||
|
wantReason: reasonStorageNotFound,
|
||||||
|
wantMessage: "storage proxy-seeds on node pv1-1: fake: storage 'proxy-seeds' does not exist",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "iso storage inactive",
|
||||||
|
prep: func(fx *fixture) { setStorage(fx, func(s *storageStatus) { s.Active = 0 }) },
|
||||||
|
wantReason: reasonStorageNotFound,
|
||||||
|
wantMessage: "not active",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "iso storage without iso content",
|
||||||
|
prep: func(fx *fixture) { setStorage(fx, func(s *storageStatus) { s.Content = "images,rootdir,isoish" }) },
|
||||||
|
wantReason: reasonStorageNotFound,
|
||||||
|
wantMessage: "does not hold iso content (content: images,rootdir,isoish)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "iso storage forbidden",
|
||||||
|
prep: func(fx *fixture) { fx.fake.failWith("StorageStatus", loadAPIError(fx.t, "err_forbidden")) },
|
||||||
|
wantReason: provider.ReasonForbidden,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "template missing",
|
||||||
|
prep: func(fx *fixture) {
|
||||||
|
fx.fake.mu.Lock()
|
||||||
|
delete(fx.fake.vms, testTemplateVMID)
|
||||||
|
fx.fake.mu.Unlock()
|
||||||
|
},
|
||||||
|
wantReason: reasonTemplateNotFound,
|
||||||
|
wantMessage: `template "debian-13": VM 9001 does not exist`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "template VMID holds a plain VM",
|
||||||
|
prep: func(fx *fixture) { fx.fake.vms[testTemplateVMID].template = false },
|
||||||
|
wantReason: reasonTemplateNotFound,
|
||||||
|
wantMessage: `template "debian-13": VM 9001 is not a template`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "template VMID holds a container",
|
||||||
|
prep: func(fx *fixture) { fx.fake.vms[testTemplateVMID].kind = "lxc" },
|
||||||
|
wantReason: reasonTemplateNotFound,
|
||||||
|
wantMessage: "does not exist",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "resources listing fails",
|
||||||
|
prep: func(fx *fixture) {
|
||||||
|
fx.fake.failWith("ClusterResources", fakeErr(500, "fake: cluster filesystem not ready"))
|
||||||
|
},
|
||||||
|
wantReason: provider.ReasonAPIError,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "request outlives the deadline",
|
||||||
|
prep: func(fx *fixture) { fx.fake.blockOn("StorageStatus") },
|
||||||
|
timeout: 50 * time.Millisecond,
|
||||||
|
wantReason: provider.ReasonTimeout,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// The real client's dial sees the round's ctx end and fails as
|
||||||
|
// a dial error, which checkReason alone would call Unreachable.
|
||||||
|
name: "dial cut short by the round's deadline",
|
||||||
|
prep: func(fx *fixture) {
|
||||||
|
fx.fake.hookOn("Version", func() error {
|
||||||
|
time.Sleep(200 * time.Millisecond)
|
||||||
|
return urlErr(&net.OpError{Op: "dial", Net: "tcp", Err: context.DeadlineExceeded})
|
||||||
|
})
|
||||||
|
},
|
||||||
|
timeout: 20 * time.Millisecond,
|
||||||
|
wantReason: provider.ReasonTimeout,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "ctx cancelled mid check",
|
||||||
|
prep: func(fx *fixture) { fx.fake.blockOn("ClusterResources") },
|
||||||
|
cancel: true,
|
||||||
|
wantReason: provider.ReasonTimeout,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
fx := newCheckFixture(t)
|
||||||
|
if tc.prep != nil {
|
||||||
|
tc.prep(fx)
|
||||||
|
}
|
||||||
|
ctx := fx.ctx
|
||||||
|
switch {
|
||||||
|
case tc.timeout > 0:
|
||||||
|
var cancel context.CancelFunc
|
||||||
|
ctx, cancel = context.WithTimeout(ctx, tc.timeout)
|
||||||
|
defer cancel()
|
||||||
|
case tc.cancel:
|
||||||
|
var cancel context.CancelFunc
|
||||||
|
ctx, cancel = context.WithCancel(ctx)
|
||||||
|
time.AfterFunc(50*time.Millisecond, cancel)
|
||||||
|
}
|
||||||
|
got := fx.p.Check(ctx)
|
||||||
|
if len(got) != 1 {
|
||||||
|
t.Fatalf("Check() returned %d targets, want 1", len(got))
|
||||||
|
}
|
||||||
|
ts := got[0]
|
||||||
|
if ts.Ready != tc.wantReady || ts.Reason != tc.wantReason {
|
||||||
|
t.Errorf("verdict = ready %v reason %s (%s), want ready %v reason %s", ts.Ready, ts.Reason, ts.Message, tc.wantReady, tc.wantReason)
|
||||||
|
}
|
||||||
|
if !tc.wantReady && ts.Message == "" {
|
||||||
|
t.Error("failed check has no message")
|
||||||
|
}
|
||||||
|
if tc.wantReady && ts.Message != "" {
|
||||||
|
t.Errorf("successful check has a message: %q", ts.Message)
|
||||||
|
}
|
||||||
|
if tc.wantMessage != "" && !strings.Contains(ts.Message, tc.wantMessage) {
|
||||||
|
t.Errorf("message = %q, want it to contain %q", ts.Message, tc.wantMessage)
|
||||||
|
}
|
||||||
|
if ts.Details["isoStorage"] != fakeISOStorage {
|
||||||
|
t.Errorf("details = %v, want isoStorage whatever failed", ts.Details)
|
||||||
|
}
|
||||||
|
assertNoSecret(t, fx, got)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func setStorage(fx *fixture, fn func(s *storageStatus)) {
|
||||||
|
fx.fake.mu.Lock()
|
||||||
|
defer fx.fake.mu.Unlock()
|
||||||
|
s := fx.fake.storages[fakeISOStorage]
|
||||||
|
fn(&s)
|
||||||
|
fx.fake.storages[fakeISOStorage] = s
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCheck_reasonsOverHTTPClient takes the transport-level reasons from
|
||||||
|
// the real client, so the errors checkReason sees are the ones net/http and
|
||||||
|
// crypto/tls produce, and the Ready row replays the Phase 0 captures.
|
||||||
|
func TestCheck_reasonsOverHTTPClient(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
captures := func(t *testing.T) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
path := strings.TrimPrefix(r.URL.Path, "/api2/json/")
|
||||||
|
name := map[string]string{
|
||||||
|
"version": "version",
|
||||||
|
"nodes/pv1-1/status": "node_status",
|
||||||
|
"nodes/pv1-1/storage/local/status": "storage_status",
|
||||||
|
"cluster/resources": "cluster_resources",
|
||||||
|
}[path]
|
||||||
|
if name == "" {
|
||||||
|
t.Errorf("unexpected request %s", path)
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
serveJSON(w, readCapture(t, name).Body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
handler func(t *testing.T) http.HandlerFunc
|
||||||
|
mutate func(t *testing.T, cfx *clientFixture)
|
||||||
|
wantReason string
|
||||||
|
}{
|
||||||
|
{name: "captures", handler: captures, wantReason: provider.ReasonReachable},
|
||||||
|
{name: "unknown authority", handler: captures, mutate: func(_ *testing.T, cfx *clientFixture) { cfx.cfg.CAFile = "" }, wantReason: reasonCertificateInvalid},
|
||||||
|
{
|
||||||
|
name: "host not in the certificate",
|
||||||
|
handler: captures,
|
||||||
|
mutate: func(_ *testing.T, cfx *clientFixture) {
|
||||||
|
cfx.cfg.URL = strings.Replace(cfx.srv.URL, "127.0.0.1", "localhost", 1)
|
||||||
|
},
|
||||||
|
wantReason: reasonCertificateInvalid,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "CA file missing",
|
||||||
|
handler: captures,
|
||||||
|
mutate: func(t *testing.T, cfx *clientFixture) { cfx.cfg.CAFile = filepath.Join(t.TempDir(), "ca.pem") },
|
||||||
|
wantReason: reasonCertificateInvalid,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "401 from pveproxy",
|
||||||
|
handler: func(t *testing.T) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, _ *http.Request) { serveCapture(t, w, readCapture(t, "err_unauthorized")) }
|
||||||
|
},
|
||||||
|
wantReason: provider.ReasonUnauthorized,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "596 from pveproxy",
|
||||||
|
handler: func(t *testing.T) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
serveCapture(t, w, capture{Status: 596, Reason: "Broken pipe"})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
wantReason: provider.ReasonUnreachable,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "server gone",
|
||||||
|
handler: captures,
|
||||||
|
mutate: func(_ *testing.T, cfx *clientFixture) { cfx.srv.Close() },
|
||||||
|
wantReason: provider.ReasonUnreachable,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "connection cut mid response",
|
||||||
|
handler: func(t *testing.T) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
conn, _, err := w.(http.Hijacker).Hijack()
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("hijack: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = io.WriteString(conn, "HTTP/1.1 200 OK\r\nContent-Length: 100\r\n\r\n{\"data\":")
|
||||||
|
_ = conn.Close()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
wantReason: provider.ReasonUnreachable,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
cfx := newClientFixture(t, tc.handler(t))
|
||||||
|
if tc.mutate != nil {
|
||||||
|
tc.mutate(t, cfx)
|
||||||
|
}
|
||||||
|
cfg := testConfig()
|
||||||
|
cfg.ISOStorage = "local"
|
||||||
|
cfg.URL, cfg.TokenFile, cfg.CAFile = cfx.cfg.URL, cfx.cfg.TokenFile, cfx.cfg.CAFile
|
||||||
|
p := newProvider(testProvider, cfg, cfx.api(t))
|
||||||
|
got := p.Check(context.Background())
|
||||||
|
if len(got) != 1 {
|
||||||
|
t.Fatalf("Check() returned %d targets, want 1", len(got))
|
||||||
|
}
|
||||||
|
if ts := got[0]; ts.Reason != tc.wantReason || ts.Ready != (tc.wantReason == provider.ReasonReachable) {
|
||||||
|
t.Errorf("verdict = ready %v reason %s (%s), want %s", ts.Ready, ts.Reason, ts.Message, tc.wantReason)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheck_detailsKeys(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
full := map[string]string{
|
||||||
|
"tokenID": "proxy-operator@pve!operator", "isoStorage": fakeISOStorage, "pveVersion": "9.2.20",
|
||||||
|
"kernel": "7.0.14-17-pve", "isoStorageAvailGiB": "90.0", "templates": "1/1",
|
||||||
|
}
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
prep func(fx *fixture)
|
||||||
|
want []string
|
||||||
|
}{
|
||||||
|
{name: "success", want: slices.Sorted(maps.Keys(full))},
|
||||||
|
{name: "token unusable", prep: func(fx *fixture) { writeFile(fx.t, fx.p.cfg.TokenFile, "junk") }, want: []string{"isoStorage"}},
|
||||||
|
{name: "node status fails", prep: func(fx *fixture) { fx.fake.failWith("NodeStatus", fakeErr(596, "Broken pipe")) }, want: []string{"isoStorage", "pveVersion", "tokenID"}},
|
||||||
|
{name: "storage inactive", prep: func(fx *fixture) { setStorage(fx, func(s *storageStatus) { s.Active = 0 }) }, want: []string{"isoStorage", "isoStorageAvailGiB", "kernel", "pveVersion", "tokenID"}},
|
||||||
|
{name: "template missing", prep: func(fx *fixture) { fx.fake.vms[testTemplateVMID].template = false }, want: slices.Sorted(maps.Keys(full))},
|
||||||
|
}
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
fx := newCheckFixture(t)
|
||||||
|
if tc.prep != nil {
|
||||||
|
tc.prep(fx)
|
||||||
|
}
|
||||||
|
got := fx.p.Check(fx.ctx)[0]
|
||||||
|
if keys := slices.Sorted(maps.Keys(got.Details)); !slices.Equal(keys, tc.want) {
|
||||||
|
t.Errorf("details keys = %v, want %v", keys, tc.want)
|
||||||
|
}
|
||||||
|
for k, v := range got.Details {
|
||||||
|
if want, ok := full[k]; ok && tc.name == "success" && v != want {
|
||||||
|
t.Errorf("details[%s] = %q, want %q", k, v, want)
|
||||||
|
}
|
||||||
|
if v == "" {
|
||||||
|
t.Errorf("details[%s] is empty", k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
t.Run("templates counts found over configured", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
fx := newCheckFixture(t)
|
||||||
|
fx.p.cfg.Templates["ubuntu"] = provider.ProxmoxTemplate{VMID: 9002}
|
||||||
|
fx.p.cfg.Templates["debian-again"] = provider.ProxmoxTemplate{VMID: testTemplateVMID}
|
||||||
|
got := fx.p.Check(fx.ctx)[0]
|
||||||
|
if got.Details["templates"] != "2/3" || got.Reason != reasonTemplateNotFound || !strings.Contains(got.Message, `template "ubuntu": VM 9002`) {
|
||||||
|
t.Errorf("verdict = %s %q, details %v; want TemplateNotFound naming ubuntu, templates 2/3", got.Reason, got.Message, got.Details)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheck_logsVerdictPerNode(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
fx := newCheckFixture(t)
|
||||||
|
fx.p.cfg.Nodes = []string{fakeNode, "pv1-2"}
|
||||||
|
fx.p.Check(fx.ctx)
|
||||||
|
checked := fx.logs.matching(`"msg"="proxmox node checked"`)
|
||||||
|
if len(checked) != 2 {
|
||||||
|
t.Fatalf("got %d verdict lines, want 2: %v", len(checked), checked)
|
||||||
|
}
|
||||||
|
for _, l := range checked {
|
||||||
|
switch {
|
||||||
|
case strings.Contains(l, `"node"="pv1-1"`):
|
||||||
|
if !strings.Contains(l, `"ready"=true`) || !strings.Contains(l, `"reason"="Reachable"`) || !strings.Contains(l, `"latency"=`) {
|
||||||
|
t.Errorf("pv1-1 verdict line = %s", l)
|
||||||
|
}
|
||||||
|
case strings.Contains(l, `"node"="pv1-2"`):
|
||||||
|
if !strings.Contains(l, `"ready"=false`) || !strings.Contains(l, `"reason"="APIError"`) {
|
||||||
|
t.Errorf("pv1-2 verdict line = %s", l)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
t.Errorf("verdict line without a node: %s", l)
|
||||||
|
}
|
||||||
|
if !strings.Contains(l, `"provider"="`+testProvider+`"`) || !strings.Contains(l, `"level"=1`) {
|
||||||
|
t.Errorf("verdict line lacks the provider or is not V(1): %s", l)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckReason_table(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
dial := &url.Error{Op: "Get", URL: "https://192.0.2.8:8006/api2/json/version", Err: &net.OpError{Op: "dial", Net: "tcp", Err: context.DeadlineExceeded}}
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
err error
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{name: "storage verdict", err: &checkFailure{reason: reasonStorageNotFound, msg: "x"}, want: reasonStorageNotFound},
|
||||||
|
{name: "template verdict wrapped", err: fmt.Errorf("check: %w", &checkFailure{reason: reasonTemplateNotFound, msg: "x"}), want: reasonTemplateNotFound},
|
||||||
|
{name: "token missing", err: fmt.Errorf("%w: %w: /t does not exist yet", provider.ErrTransient, errCredentials), want: reasonCredentialsUnavailable},
|
||||||
|
{name: "token malformed", err: fmt.Errorf("%w: %w: /t is not a token", provider.ErrPermanent, errCredentials), want: reasonCredentialsUnavailable},
|
||||||
|
{name: "CA file", err: fmt.Errorf("%w: %w: /ca holds no PEM certificate", provider.ErrPermanent, errCAFile), want: reasonCertificateInvalid},
|
||||||
|
{name: "unknown authority", err: &url.Error{Err: x509.UnknownAuthorityError{}}, want: reasonCertificateInvalid},
|
||||||
|
{name: "certificate invalid", err: &url.Error{Err: x509.CertificateInvalidError{Reason: x509.Expired}}, want: reasonCertificateInvalid},
|
||||||
|
{name: "401", err: loadAPIError(t, "err_unauthorized"), want: provider.ReasonUnauthorized},
|
||||||
|
{name: "403 wrapped", err: fmt.Errorf("reading: %w", loadAPIError(t, "err_forbidden_volume_delete")), want: provider.ReasonForbidden},
|
||||||
|
{name: "595", err: fakeErr(595, "Connection refused"), want: provider.ReasonUnreachable},
|
||||||
|
{name: "596", err: fakeErr(596, "Broken pipe"), want: provider.ReasonUnreachable},
|
||||||
|
{name: "400", err: loadAPIError(t, "err_bad_param"), want: provider.ReasonAPIError},
|
||||||
|
{name: "500", err: loadAPIError(t, "err_vm_missing"), want: provider.ReasonAPIError},
|
||||||
|
{name: "dial ran out of its own budget", err: dial, want: provider.ReasonUnreachable},
|
||||||
|
{name: "connection reset", err: &url.Error{Err: &net.OpError{Op: "read", Net: "tcp", Err: syscall.ECONNRESET}}, want: provider.ReasonUnreachable},
|
||||||
|
{name: "request timeout", err: fmt.Errorf("reading: %w", &url.Error{Err: context.DeadlineExceeded}), want: provider.ReasonTimeout},
|
||||||
|
{name: "cancelled", err: context.Canceled, want: provider.ReasonTimeout},
|
||||||
|
{name: "server closed mid response", err: &url.Error{Err: io.ErrUnexpectedEOF}, want: provider.ReasonUnreachable},
|
||||||
|
{name: "eof", err: io.EOF, want: provider.ReasonUnreachable},
|
||||||
|
{name: "decode failure", err: errors.New("decoding GET version: invalid character '<'"), want: provider.ReasonAPIError},
|
||||||
|
{name: "nil", err: nil, want: provider.ReasonAPIError},
|
||||||
|
}
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
if got := checkReason(tc.err); got != tc.want {
|
||||||
|
t.Errorf("checkReason(%v) = %s, want %s", tc.err, got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -39,6 +39,10 @@ const (
|
|||||||
// Checker can report CredentialsUnavailable instead of an API failure.
|
// Checker can report CredentialsUnavailable instead of an API failure.
|
||||||
var errCredentials = errors.New("proxmox token file unusable")
|
var errCredentials = errors.New("proxmox token file unusable")
|
||||||
|
|
||||||
|
// errCAFile marks a CA file that is missing or holds no certificate, so the
|
||||||
|
// Checker can report CertificateInvalid: the admin fixes caFile.
|
||||||
|
var errCAFile = errors.New("proxmox CA file unusable")
|
||||||
|
|
||||||
// tokenPattern is <user>@<realm>!<tokenid>=<secret> (spec entry 14).
|
// tokenPattern is <user>@<realm>!<tokenid>=<secret> (spec entry 14).
|
||||||
var tokenPattern = regexp.MustCompile(`^[^@\s]+@[^!\s]+![^=\s]+=\S+$`)
|
var tokenPattern = regexp.MustCompile(`^[^@\s]+@[^!\s]+![^=\s]+=\S+$`)
|
||||||
|
|
||||||
@@ -91,11 +95,11 @@ func (a *httpAPI) httpClient() (*http.Client, error) {
|
|||||||
if a.caFile != "" && !a.insecure {
|
if a.caFile != "" && !a.insecure {
|
||||||
pem, err := os.ReadFile(a.caFile)
|
pem, err := os.ReadFile(a.caFile)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("%w: reading CA file: %w", provider.ErrTransient, err)
|
return nil, fmt.Errorf("%w: %w: reading CA file: %w", provider.ErrTransient, errCAFile, err)
|
||||||
}
|
}
|
||||||
pool := x509.NewCertPool()
|
pool := x509.NewCertPool()
|
||||||
if !pool.AppendCertsFromPEM(pem) {
|
if !pool.AppendCertsFromPEM(pem) {
|
||||||
return nil, permanentf("CA file %s holds no PEM certificate", a.caFile)
|
return nil, fmt.Errorf("%w: %w: %s holds no PEM certificate", provider.ErrPermanent, errCAFile, a.caFile)
|
||||||
}
|
}
|
||||||
tlsCfg.RootCAs = pool
|
tlsCfg.RootCAs = pool
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
package proxmox
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-logr/logr"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Delete is spec entry 11: under the per-name lock, stop and destroy the VM
|
||||||
|
// when it is ours, then delete its seed. Everything already gone is
|
||||||
|
// success, and nothing of ours is left when nil is returned, so Get
|
||||||
|
// afterwards is NotFound.
|
||||||
|
func (p *Provider) Delete(ctx context.Context, id string) error {
|
||||||
|
vmid, name, err := parseProviderID(id)
|
||||||
|
if err != nil {
|
||||||
|
return p.wrapErr("delete", id, err)
|
||||||
|
}
|
||||||
|
log := p.logger(ctx).WithValues("vmid", vmid, "name", name)
|
||||||
|
unlock, err := p.lockName(ctx, name)
|
||||||
|
if err != nil {
|
||||||
|
return p.wrapErr("delete", id, err)
|
||||||
|
}
|
||||||
|
defer unlock()
|
||||||
|
|
||||||
|
node, err := p.destroy(ctx, log, vmid, name)
|
||||||
|
if err != nil {
|
||||||
|
return p.wrapErr("delete", id, err)
|
||||||
|
}
|
||||||
|
if node == "" {
|
||||||
|
err = p.deleteSeedEverywhere(ctx, log, name)
|
||||||
|
} else {
|
||||||
|
log = log.WithValues("node", node)
|
||||||
|
err = p.deleteSeed(ctx, log, node, name)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return p.wrapErr("delete", id, err)
|
||||||
|
}
|
||||||
|
log.V(1).Info("proxmox vm deleted", "destroyed", node != "")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// destroy is steps 2 to 6. It returns the node of the VM it destroyed, or
|
||||||
|
// "" when there was no VM of ours to destroy: then the seed's node is not
|
||||||
|
// known, and the seed is looked for on every node.
|
||||||
|
func (p *Provider) destroy(ctx context.Context, log logr.Logger, vmid int, name string) (string, error) {
|
||||||
|
r, ok, err := p.resolve(ctx, vmid)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
log.V(1).Info("proxmox vm already gone")
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
log = log.WithValues("node", r.Node)
|
||||||
|
if r.Lock != "" && r.Lock != lockSuspended {
|
||||||
|
return "", transientf("VM %d is locked (%s)", vmid, r.Lock)
|
||||||
|
}
|
||||||
|
if r.Status == "unknown" {
|
||||||
|
return "", transientf("VM %d has status unknown: its node %s is not answering", vmid, r.Node)
|
||||||
|
}
|
||||||
|
cfg, err := p.api.VMConfig(ctx, r.Node, vmid)
|
||||||
|
if isNotExist(err) {
|
||||||
|
log.V(1).Info("proxmox vm already gone")
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("reading config of VM %d: %w", vmid, err)
|
||||||
|
}
|
||||||
|
if cfg["name"] != name {
|
||||||
|
log.V(1).Info("proxmox vm at this vmid is not ours, left alone", "vmName", cfg["name"])
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
if !parseDescription(cfg["description"]).Managed {
|
||||||
|
log.V(1).Info("proxmox vm carries no proxy-operator mark, left alone")
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
switch lock := cfg["lock"]; lock {
|
||||||
|
case "":
|
||||||
|
case lockSuspended:
|
||||||
|
// Destroy refuses a locked VM and the lock never clears on its own,
|
||||||
|
// so only an admin can move this on, as in Create's ownership check.
|
||||||
|
return "", permanentf("VM %d named %s is hibernated (lock suspended); resume or destroy it in Proxmox", vmid, name)
|
||||||
|
default:
|
||||||
|
return "", transientf("VM %d is locked (%s)", vmid, lock)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The listing shows a paused VM as running, and Stop ends it too.
|
||||||
|
if r.Status != "stopped" {
|
||||||
|
gone, err := p.runTask(ctx, log, "stopping", vmid, p.budgets.stop, func() (string, error) { return p.api.Stop(ctx, r.Node, vmid) })
|
||||||
|
if err != nil || gone {
|
||||||
|
return r.Node, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := p.runTask(ctx, log, "destroying", vmid, p.budgets.destroy, func() (string, error) { return p.api.Destroy(ctx, r.Node, vmid) }); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return r.Node, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// runTask submits a task on a VM and waits for it. A VM that is gone by the
|
||||||
|
// time the task is submitted reports gone: it is what the task was for.
|
||||||
|
func (p *Provider) runTask(ctx context.Context, log logr.Logger, what string, vmid int, budget time.Duration, submit func() (string, error)) (bool, error) {
|
||||||
|
upid, err := submit()
|
||||||
|
if isNotExist(err) {
|
||||||
|
log.V(1).Info("proxmox vm already gone")
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("%s VM %d: %w", what, vmid, err)
|
||||||
|
}
|
||||||
|
if err := p.waitTask(ctx, log, upid, budget); err != nil {
|
||||||
|
return false, fmt.Errorf("%s VM %d: %w", what, vmid, err)
|
||||||
|
}
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// deleteSeed is step 7 on one node. A missing volume still ends in a
|
||||||
|
// successful task (F13), so nothing is matched.
|
||||||
|
func (p *Provider) deleteSeed(ctx context.Context, log logr.Logger, node, name string) error {
|
||||||
|
volid := seedVolID(p.cfg.ISOStorage, name)
|
||||||
|
upid, err := p.api.DeleteVolume(ctx, node, p.cfg.ISOStorage, volid)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("deleting seed %s on %s: %w", volid, node, err)
|
||||||
|
}
|
||||||
|
if err := p.waitTask(ctx, log, upid, p.budgets.seedDelete); err != nil {
|
||||||
|
return fmt.Errorf("deleting seed %s on %s: %w", volid, node, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// deleteSeedEverywhere is step 7 when the VM's node is not known: every
|
||||||
|
// configured node is tried, and one that fails is logged and skipped, so a
|
||||||
|
// node that is down or lacks the storage cannot hold the Delete up. An
|
||||||
|
// ended ctx fails the call instead.
|
||||||
|
func (p *Provider) deleteSeedEverywhere(ctx context.Context, log logr.Logger, name string) error {
|
||||||
|
for _, node := range p.cfg.Nodes {
|
||||||
|
err := p.deleteSeed(ctx, log, node, name)
|
||||||
|
if err == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
log.V(1).Info("proxmox seed delete failed on a node, skipped", "node", node, "reason", err.Error())
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,717 @@
|
|||||||
|
package proxmox
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"slices"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/internal/provider"
|
||||||
|
)
|
||||||
|
|
||||||
|
// putSeed uploads testName's seed to the fake's ISO storage, as Create
|
||||||
|
// leaves it.
|
||||||
|
func (fx *fixture) putSeed() {
|
||||||
|
fx.t.Helper()
|
||||||
|
upid, err := fx.fake.UploadISO(context.Background(), fakeNode, fakeISOStorage, seedISOName(testName), strings.NewReader("seed"), 4)
|
||||||
|
if err != nil {
|
||||||
|
fx.t.Fatalf("UploadISO error = %v", err)
|
||||||
|
}
|
||||||
|
pollTask(fx.t, fx.fake, upid)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fx *fixture) seedPresent() bool {
|
||||||
|
_, ok := fx.fake.iso(fakeISOStorage, seedISOName(testName))
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// deleteFails runs Delete on fx.id(), checks the error's class and returns
|
||||||
|
// its text.
|
||||||
|
func (fx *fixture) deleteFails(class error) string {
|
||||||
|
fx.t.Helper()
|
||||||
|
err := fx.p.Delete(fx.ctx, fx.id())
|
||||||
|
assertProviderError(fx.t, err, "delete", class)
|
||||||
|
return err.Error()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fx *fixture) delete() {
|
||||||
|
fx.t.Helper()
|
||||||
|
if err := fx.p.Delete(fx.ctx, fx.id()); err != nil {
|
||||||
|
fx.t.Fatalf("Delete error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// seedDeleteNodes returns the node of every DeleteVolume call, read from
|
||||||
|
// the decorator's lines; the fixture's api must be observed.
|
||||||
|
func (fx *fixture) seedDeleteNodes() []string {
|
||||||
|
lines := fx.logs.matching(`"call"="DeleteVolume"`)
|
||||||
|
nodes := make([]string, 0, len(lines))
|
||||||
|
for _, l := range lines {
|
||||||
|
_, rest, _ := strings.Cut(l, `"node"="`)
|
||||||
|
node, _, _ := strings.Cut(rest, `"`)
|
||||||
|
nodes = append(nodes, node)
|
||||||
|
}
|
||||||
|
return nodes
|
||||||
|
}
|
||||||
|
|
||||||
|
// twoNodeFixture is an observed fixture at V(1) with nodes pv1-1 and pv1-2.
|
||||||
|
func twoNodeFixture(t *testing.T) *fixture {
|
||||||
|
t.Helper()
|
||||||
|
fx := newFixture(t, 1)
|
||||||
|
fx.p.api = observe(fx.fake, testProvider)
|
||||||
|
fx.p.cfg.Nodes = []string{fakeNode, "pv1-2"}
|
||||||
|
return fx
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDelete_malformedIDIsPermanent(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
for _, id := range []string{"", "vms/150042", "vms/0150042/" + testName, "vm/150042/" + testName, "vms/abc/" + testName, "vms/150042/a/b", "vms/99/" + testName} {
|
||||||
|
t.Run(id, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
fx := newFixture(t, 1)
|
||||||
|
assertProviderError(t, fx.p.Delete(fx.ctx, id), "delete", provider.ErrPermanent)
|
||||||
|
if calls := fx.fake.callLog(); len(calls) != 0 {
|
||||||
|
t.Errorf("calls = %v, want none", calls)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDelete_twiceSucceeds(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
fx := newFixture(t, 1)
|
||||||
|
fx.addOurVM(testVMID, testName, "running")
|
||||||
|
fx.putSeed()
|
||||||
|
fx.delete()
|
||||||
|
fx.delete()
|
||||||
|
if _, ok := fx.fake.vmSnapshot(testVMID); ok {
|
||||||
|
t.Error("VM still exists")
|
||||||
|
}
|
||||||
|
if fx.seedPresent() {
|
||||||
|
t.Error("seed still exists")
|
||||||
|
}
|
||||||
|
for method, want := range map[string]int{"Stop": 1, "Destroy": 1, "DeleteVolume": 2} {
|
||||||
|
if got := fx.fake.callCount(method); got != want {
|
||||||
|
t.Errorf("%s calls = %d, want %d", method, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDelete_absentStillDeletesSeedOnEveryNode replaces the plan's
|
||||||
|
// TestDelete_absentIsNoOp: spec entry 11 deletes the seed even when the VM
|
||||||
|
// is gone, on every node, because the VM's node is no longer known.
|
||||||
|
func TestDelete_absentStillDeletesSeedOnEveryNode(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
fx := twoNodeFixture(t)
|
||||||
|
fx.putSeed()
|
||||||
|
fx.delete()
|
||||||
|
if got := fx.seedDeleteNodes(); !slices.Equal(got, []string{fakeNode, "pv1-2"}) {
|
||||||
|
t.Errorf("seed deleted on %v, want every node in config order", got)
|
||||||
|
}
|
||||||
|
if fx.seedPresent() {
|
||||||
|
t.Error("seed still exists")
|
||||||
|
}
|
||||||
|
for _, method := range []string{"VMConfig", "Stop", "Destroy"} {
|
||||||
|
if n := fx.fake.callCount(method); n != 0 {
|
||||||
|
t.Errorf("%s calls = %d, want 0 for an absent VM", method, n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if lines := fx.logs.matching(`"msg"="proxmox vm already gone"`); len(lines) != 1 {
|
||||||
|
t.Errorf("already-gone lines = %v, want one", lines)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDelete_containerAtVMIDIsLeftAlone(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
fx := twoNodeFixture(t)
|
||||||
|
fx.addOurVM(testVMID, testName, "running").kind = "lxc"
|
||||||
|
fx.delete()
|
||||||
|
if vm, ok := fx.fake.vmSnapshot(testVMID); !ok || vm.status != "running" {
|
||||||
|
t.Errorf("container = %+v, %v; want it untouched", vm, ok)
|
||||||
|
}
|
||||||
|
if got := fx.seedDeleteNodes(); len(got) != 2 {
|
||||||
|
t.Errorf("seed deleted on %v, want every node", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDelete_foreignNameLeavesVMAlone replaces the plan's
|
||||||
|
// TestDelete_foreignNameIsNoOp: the VM is not touched, but our seed is
|
||||||
|
// still deleted, on every node since ours is gone.
|
||||||
|
func TestDelete_foreignNameLeavesVMAlone(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
fx := twoNodeFixture(t)
|
||||||
|
fx.addOurVM(testVMID, "proxy-someoneelse00000", "running")
|
||||||
|
fx.putSeed()
|
||||||
|
fx.delete()
|
||||||
|
if vm, ok := fx.fake.vmSnapshot(testVMID); !ok || vm.status != "running" {
|
||||||
|
t.Errorf("foreign VM = %+v, %v; want it running and untouched", vm, ok)
|
||||||
|
}
|
||||||
|
for _, method := range []string{"Stop", "Destroy", "SetVMConfig"} {
|
||||||
|
if n := fx.fake.callCount(method); n != 0 {
|
||||||
|
t.Errorf("%s calls = %d, want 0", method, n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got := fx.seedDeleteNodes(); !slices.Equal(got, []string{fakeNode, "pv1-2"}) {
|
||||||
|
t.Errorf("seed deleted on %v, want every node", got)
|
||||||
|
}
|
||||||
|
lines := fx.logs.matching(`"msg"="proxmox vm at this vmid is not ours, left alone"`)
|
||||||
|
if len(lines) != 1 || !strings.Contains(lines[0], `"vmName"="proxy-someoneelse00000"`) {
|
||||||
|
t.Errorf("no-op lines = %v, want one naming the other VM", lines)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDelete_unmarkedLeavesVMAlone replaces the plan's
|
||||||
|
// TestDelete_unmarkedIsNoOp, for the same reason.
|
||||||
|
func TestDelete_unmarkedLeavesVMAlone(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
for name, desc := range map[string]string{
|
||||||
|
"no description": "",
|
||||||
|
"managed false": "proxy-operator-managed=false\nproxy-operator-uid=" + testUID,
|
||||||
|
} {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
fx := twoNodeFixture(t)
|
||||||
|
vm := fx.fake.addVM(testVMID, testName, "stopped")
|
||||||
|
if desc != "" {
|
||||||
|
vm.config["description"] = desc
|
||||||
|
}
|
||||||
|
fx.delete()
|
||||||
|
if _, ok := fx.fake.vmSnapshot(testVMID); !ok {
|
||||||
|
t.Error("unmarked VM was destroyed")
|
||||||
|
}
|
||||||
|
if n := fx.fake.callCount("Destroy"); n != 0 {
|
||||||
|
t.Errorf("Destroy calls = %d, want 0", n)
|
||||||
|
}
|
||||||
|
if got := fx.seedDeleteNodes(); len(got) != 2 {
|
||||||
|
t.Errorf("seed deleted on %v, want every node", got)
|
||||||
|
}
|
||||||
|
if lines := fx.logs.matching(`"msg"="proxmox vm carries no proxy-operator mark, left alone"`); len(lines) != 1 {
|
||||||
|
t.Errorf("no-op lines = %v, want one", lines)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDelete_runningVMStoppedFirst covers a paused VM too: the listing
|
||||||
|
// shows it as running (F14), and Stop ends it. The fake refuses to destroy
|
||||||
|
// a running VM, and each task takes two polls, so a Destroy that does not
|
||||||
|
// wait for the stop fails.
|
||||||
|
func TestDelete_runningVMStoppedFirst(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
for _, qmp := range []string{"running", "paused", "prelaunch"} {
|
||||||
|
t.Run(qmp, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
fx := newFixture(t, 1)
|
||||||
|
fx.fake.taskPolls = 2
|
||||||
|
fx.addOurVM(testVMID, testName, "running").qmpstatus = qmp
|
||||||
|
fx.delete()
|
||||||
|
log := fx.fake.callLog()
|
||||||
|
stop, destroy := slices.Index(log, "Stop"), slices.Index(log, "Destroy")
|
||||||
|
if stop < 0 || destroy < stop {
|
||||||
|
t.Fatalf("calls = %v, want Stop before Destroy", log)
|
||||||
|
}
|
||||||
|
if n := callsBefore(log[stop:], "TaskStatus", "Destroy"); n != fx.fake.taskPolls+1 {
|
||||||
|
t.Errorf("stop task polled %d times before Destroy, want %d", n, fx.fake.taskPolls+1)
|
||||||
|
}
|
||||||
|
if _, ok := fx.fake.vmSnapshot(testVMID); ok {
|
||||||
|
t.Error("VM still exists")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDelete_stoppedVMSkipsStop(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
fx := newFixture(t, 1)
|
||||||
|
fx.addOurVM(testVMID, testName, "stopped")
|
||||||
|
fx.delete()
|
||||||
|
want := []string{"ClusterResources", "VMConfig", "Destroy", "TaskStatus", "DeleteVolume", "TaskStatus"}
|
||||||
|
if got := fx.fake.callLog(); !slices.Equal(got, want) {
|
||||||
|
t.Errorf("calls = %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDelete_lockedIsTransient(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
for _, lock := range []string{"backup", "migrate", "snapshot", "rollback", "create"} {
|
||||||
|
t.Run("resources lock "+lock, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
fx := newFixture(t, 1)
|
||||||
|
fx.addOurVM(testVMID, testName, "running").lock = lock
|
||||||
|
fx.putSeed()
|
||||||
|
if msg := fx.deleteFails(provider.ErrTransient); !strings.Contains(msg, "locked ("+lock+")") {
|
||||||
|
t.Errorf("error = %s, want it to name the lock", msg)
|
||||||
|
}
|
||||||
|
if got := fx.fake.callLog(); !slices.Equal(got, []string{"UploadISO", "TaskStatus", "ClusterResources"}) {
|
||||||
|
t.Errorf("calls = %v, want only the listing after the seed setup", got)
|
||||||
|
}
|
||||||
|
if !fx.seedPresent() {
|
||||||
|
t.Error("seed deleted while the VM is locked")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
t.Run("mid-clone nameless entry", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
fx := newFixture(t, 1)
|
||||||
|
fx.fake.addTemplate(9001, "debian-13-template")
|
||||||
|
if _, err := fx.fake.Clone(fx.ctx, fakeNode, 9001, cloneParams{NewID: testVMID, Name: testName, Description: formatDescription(testUID, testCreatedAt)}); err != nil {
|
||||||
|
t.Fatalf("Clone error = %v", err)
|
||||||
|
}
|
||||||
|
fx.deleteFails(provider.ErrTransient)
|
||||||
|
if _, ok := fx.fake.vmSnapshot(testVMID); !ok {
|
||||||
|
t.Error("mid-clone VM is gone")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("lock taken between the resources and config reads", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
fx := newFixture(t, 1)
|
||||||
|
vm := fx.addOurVM(testVMID, testName, "running")
|
||||||
|
mutateOn(fx.fake, "VMConfig", func() { vm.lock = "backup" })
|
||||||
|
fx.deleteFails(provider.ErrTransient)
|
||||||
|
if n := fx.fake.callCount("Stop"); n != 0 {
|
||||||
|
t.Errorf("Stop calls = %d, want 0", n)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDelete_unknownStatusIsTransient(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
fx := newFixture(t, 1)
|
||||||
|
fx.addOurVM(testVMID, testName, "running").status = "unknown"
|
||||||
|
if msg := fx.deleteFails(provider.ErrTransient); !strings.Contains(msg, fakeNode) {
|
||||||
|
t.Errorf("error = %s, want it to name the node", msg)
|
||||||
|
}
|
||||||
|
if n := fx.fake.callCount("VMConfig"); n != 0 {
|
||||||
|
t.Errorf("VMConfig calls = %d, want 0 while the node is unknown", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDelete_hibernatedVMIsPermanentIfOurs matches Create's ownership
|
||||||
|
// check: Destroy refuses the suspended lock, which never clears on its own,
|
||||||
|
// so an admin must act. A hibernated VM that is not ours is left alone.
|
||||||
|
func TestDelete_hibernatedVMIsPermanentIfOurs(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
t.Run("ours", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
fx := newFixture(t, 1)
|
||||||
|
fx.addOurVM(testVMID, testName, "stopped").lock = lockSuspended
|
||||||
|
fx.putSeed()
|
||||||
|
if msg := fx.deleteFails(provider.ErrPermanent); !strings.Contains(msg, "resume or destroy it in Proxmox") {
|
||||||
|
t.Errorf("error = %s, want the admin action", msg)
|
||||||
|
}
|
||||||
|
for _, method := range []string{"Stop", "Destroy", "DeleteVolume"} {
|
||||||
|
if n := fx.fake.callCount(method); n != 0 {
|
||||||
|
t.Errorf("%s calls = %d, want 0", method, n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := fx.fake.Destroy(fx.ctx, fakeNode, testVMID); !isLocked(err) {
|
||||||
|
t.Errorf("fake Destroy on the hibernated VM error = %v, want the lock answer", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("someone else's", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
fx := newFixture(t, 1)
|
||||||
|
fx.addOurVM(testVMID, "proxy-someoneelse00000", "stopped").lock = lockSuspended
|
||||||
|
fx.delete()
|
||||||
|
if vm, ok := fx.fake.vmSnapshot(testVMID); !ok || vm.lock != lockSuspended {
|
||||||
|
t.Errorf("foreign hibernated VM = %+v, %v; want it untouched", vm, ok)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDelete_removesSeedISO(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
fx := twoNodeFixture(t)
|
||||||
|
fx.addOurVM(testVMID, testName, "stopped").node = "pv1-2"
|
||||||
|
fx.putSeed()
|
||||||
|
fx.delete()
|
||||||
|
if fx.seedPresent() {
|
||||||
|
t.Error("seed still exists")
|
||||||
|
}
|
||||||
|
if got := fx.seedDeleteNodes(); !slices.Equal(got, []string{"pv1-2"}) {
|
||||||
|
t.Errorf("seed deleted on %v, want only the VM's node pv1-2", got)
|
||||||
|
}
|
||||||
|
lines := fx.logs.matching(`"call"="DeleteVolume"`)
|
||||||
|
if len(lines) != 1 || !strings.Contains(lines[0], `"volid"="`+seedVolID(fakeISOStorage, testName)+`"`) || !strings.Contains(lines[0], `"storage"="`+fakeISOStorage+`"`) {
|
||||||
|
t.Errorf("DeleteVolume lines = %v, want the seed's volid on the ISO storage", lines)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDelete_seedDeleteIsTaskAndIdempotent replaces the plan's
|
||||||
|
// TestDelete_toleratesMissingISO: Proxmox ends a delete of a missing volume
|
||||||
|
// with a successful imgdel task (F13), so nothing is matched, but the task
|
||||||
|
// is still waited on.
|
||||||
|
func TestDelete_seedDeleteIsTaskAndIdempotent(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
missing := decodeData[taskStatus](t, "task_status_volume_missing")
|
||||||
|
if missing.Type != "imgdel" || missing.ExitStatus != "OK" {
|
||||||
|
t.Fatalf("capture task_status_volume_missing = %+v, want an imgdel task ending OK", missing)
|
||||||
|
}
|
||||||
|
for _, withSeed := range []bool{true, false} {
|
||||||
|
t.Run("seed present "+strconv.FormatBool(withSeed), func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
fx := newFixture(t, 1)
|
||||||
|
fx.addOurVM(testVMID, testName, "stopped")
|
||||||
|
if withSeed {
|
||||||
|
fx.putSeed()
|
||||||
|
}
|
||||||
|
fx.fake.taskPolls = 2
|
||||||
|
fx.delete()
|
||||||
|
log := fx.fake.callLog()
|
||||||
|
last := len(log) - 1
|
||||||
|
for log[last] != "DeleteVolume" {
|
||||||
|
last--
|
||||||
|
}
|
||||||
|
if n := callsBefore(log[last:], "TaskStatus", ""); n != fx.fake.taskPolls+1 {
|
||||||
|
t.Errorf("seed delete task polled %d times, want %d: %v", n, fx.fake.taskPolls+1, log)
|
||||||
|
}
|
||||||
|
if fx.seedPresent() {
|
||||||
|
t.Error("seed still exists")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDelete_seedDeleteFailureOnVMNodeFails(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
setup func(fx *fixture)
|
||||||
|
class error
|
||||||
|
}{
|
||||||
|
{name: "token lacks Datastore.Allocate", setup: func(fx *fixture) {
|
||||||
|
fx.fake.failWith("DeleteVolume", loadAPIError(t, "err_forbidden_volume_delete"))
|
||||||
|
}, class: provider.ErrPermanent},
|
||||||
|
{name: "pveproxy 596", setup: func(fx *fixture) { fx.fake.failWith("DeleteVolume", fakeErr(596, "Broken pipe")) }, class: provider.ErrTransient},
|
||||||
|
{name: "task failed", setup: func(fx *fixture) {
|
||||||
|
fx.fake.hookOn("TaskStatus", func() error {
|
||||||
|
if fx.fake.callCount("DeleteVolume") > 0 {
|
||||||
|
return fakeErr(500, "fake: task log unreadable")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}, class: provider.ErrTransient},
|
||||||
|
}
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
fx := newFixture(t, 1)
|
||||||
|
fx.addOurVM(testVMID, testName, "stopped")
|
||||||
|
tc.setup(fx)
|
||||||
|
fx.deleteFails(tc.class)
|
||||||
|
if _, ok := fx.fake.vmSnapshot(testVMID); ok {
|
||||||
|
t.Error("VM still exists: the destroy should have run before the seed delete")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDelete_seedDeleteNodeFailureSkippedWhenNodeUnknown(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
t.Run("one node refuses", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
fx := twoNodeFixture(t)
|
||||||
|
fx.putSeed()
|
||||||
|
var calls int
|
||||||
|
fx.fake.hookOn("DeleteVolume", func() error {
|
||||||
|
if calls++; calls == 1 {
|
||||||
|
return fakeErr(500, "fake: storage 'proxy-seeds' is not available on node 'pv1-1'")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
fx.delete()
|
||||||
|
if got := fx.seedDeleteNodes(); !slices.Equal(got, []string{fakeNode, "pv1-2"}) {
|
||||||
|
t.Errorf("seed deleted on %v, want both nodes tried", got)
|
||||||
|
}
|
||||||
|
lines := fx.logs.matching(`"msg"="proxmox seed delete failed on a node, skipped"`)
|
||||||
|
if len(lines) != 1 || !strings.Contains(lines[0], `"node"="pv1-1"`) || !strings.Contains(lines[0], "not available") {
|
||||||
|
t.Errorf("skip lines = %v, want one naming pv1-1 and the reason", lines)
|
||||||
|
}
|
||||||
|
if fx.seedPresent() {
|
||||||
|
t.Error("seed still exists after pv1-2's delete")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("ended ctx fails the call", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
fx := twoNodeFixture(t)
|
||||||
|
ctx, cancel := context.WithCancel(fx.ctx)
|
||||||
|
defer cancel()
|
||||||
|
fx.fake.hookOn("DeleteVolume", func() error {
|
||||||
|
cancel()
|
||||||
|
return context.Canceled
|
||||||
|
})
|
||||||
|
err := fx.p.Delete(ctx, fx.id())
|
||||||
|
assertProviderError(t, err, "delete", provider.ErrTransient)
|
||||||
|
if n := fx.fake.callCount("DeleteVolume"); n != 1 {
|
||||||
|
t.Errorf("DeleteVolume calls = %d, want 1: the ended ctx stops the loop", n)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDelete_destroyTaskFailureIsTransient(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
fx := newFixture(t, 1)
|
||||||
|
fx.addOurVM(testVMID, testName, "stopped")
|
||||||
|
fx.putSeed()
|
||||||
|
fx.fake.destroyFailsWith = "fake: unable to remove disk"
|
||||||
|
if msg := fx.deleteFails(provider.ErrTransient); !strings.Contains(msg, "unable to remove disk") {
|
||||||
|
t.Errorf("error = %s, want the task's exitstatus", msg)
|
||||||
|
}
|
||||||
|
if _, ok := fx.fake.vmSnapshot(testVMID); !ok {
|
||||||
|
t.Error("VM gone after a failed destroy task")
|
||||||
|
}
|
||||||
|
if !fx.seedPresent() {
|
||||||
|
t.Error("seed deleted although the VM is still there")
|
||||||
|
}
|
||||||
|
if lines := fx.logs.matching(`"msg"="proxmox task failed"`); len(lines) != 1 {
|
||||||
|
t.Errorf("task failed lines = %v, want one", lines)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDelete_goneMidwayIsSuccess: the VM vanishing at any step counts as
|
||||||
|
// done, and F2's does-not-exist answer is what each step then sees.
|
||||||
|
func TestDelete_goneMidwayIsSuccess(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
status string
|
||||||
|
method string
|
||||||
|
wantDestroy int
|
||||||
|
wantNodes []string
|
||||||
|
}{
|
||||||
|
{name: "before the config read", status: "stopped", method: "VMConfig", wantNodes: []string{fakeNode, "pv1-2"}},
|
||||||
|
{name: "before the stop", status: "running", method: "Stop", wantNodes: []string{fakeNode}},
|
||||||
|
{name: "before the destroy", status: "stopped", method: "Destroy", wantDestroy: 1, wantNodes: []string{fakeNode}},
|
||||||
|
}
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
fx := twoNodeFixture(t)
|
||||||
|
fx.addOurVM(testVMID, testName, tc.status)
|
||||||
|
mutateOn(fx.fake, tc.method, func() { delete(fx.fake.vms, testVMID) })
|
||||||
|
fx.delete()
|
||||||
|
if n := fx.fake.callCount("Destroy"); n != tc.wantDestroy {
|
||||||
|
t.Errorf("Destroy calls = %d, want %d", n, tc.wantDestroy)
|
||||||
|
}
|
||||||
|
if got := fx.seedDeleteNodes(); !slices.Equal(got, tc.wantNodes) {
|
||||||
|
t.Errorf("seed deleted on %v, want %v", got, tc.wantNodes)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDelete_resourcesFailureIsTransient(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
fx := newFixture(t, 1)
|
||||||
|
fx.addOurVM(testVMID, testName, "running")
|
||||||
|
fx.fake.failWith("ClusterResources", fakeErr(595, "Connection refused"))
|
||||||
|
fx.deleteFails(provider.ErrTransient)
|
||||||
|
if n := fx.fake.callCount("DeleteVolume"); n != 0 {
|
||||||
|
t.Errorf("DeleteVolume calls = %d, want 0 when the VM's state is unknown", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDelete_usesPurgeAndDestroyUnreferencedDisks runs Delete over the real
|
||||||
|
// client against Phase 0's destroy captures: the DELETE carries both flags
|
||||||
|
// and the seed delete sends the volid as one escaped segment.
|
||||||
|
func TestDelete_usesPurgeAndDestroyUnreferencedDisks(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
const (
|
||||||
|
vmid = 199999
|
||||||
|
name = "proxy-reconaaaaaaaaaaa"
|
||||||
|
)
|
||||||
|
var (
|
||||||
|
mu sync.Mutex
|
||||||
|
seen []string
|
||||||
|
)
|
||||||
|
cfx := newClientFixture(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
path := strings.TrimPrefix(r.URL.EscapedPath(), "/api2/json/")
|
||||||
|
mu.Lock()
|
||||||
|
seen = append(seen, r.Method+" "+path+"?"+r.URL.RawQuery)
|
||||||
|
mu.Unlock()
|
||||||
|
capture := ""
|
||||||
|
switch {
|
||||||
|
case r.Method == http.MethodGet && path == "cluster/resources":
|
||||||
|
capture = "cluster_resources_during_destroy"
|
||||||
|
case r.Method == http.MethodGet && path == "nodes/pv1-1/qemu/199999/config":
|
||||||
|
capture = "config_during_destroy"
|
||||||
|
case r.Method == http.MethodDelete && path == "nodes/pv1-1/qemu/199999":
|
||||||
|
capture = "destroy_response"
|
||||||
|
case r.Method == http.MethodDelete && path == "nodes/pv1-1/storage/proxy-seeds/content/proxy-seeds:iso%2F"+name+"-seed.iso":
|
||||||
|
capture = "content_delete_response"
|
||||||
|
case r.Method == http.MethodGet && strings.Contains(path, ":imgdel:"):
|
||||||
|
capture = "task_status_volume_missing"
|
||||||
|
case r.Method == http.MethodGet && strings.Contains(path, ":qmdestroy:"):
|
||||||
|
capture = "task_status_stopped_ok"
|
||||||
|
default:
|
||||||
|
t.Errorf("unexpected request %s %s", r.Method, path)
|
||||||
|
http.Error(w, "unexpected", http.StatusNotImplemented)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
serveJSON(w, readCapture(t, capture).Body)
|
||||||
|
})
|
||||||
|
cfg := testConfig()
|
||||||
|
cfg.URL, cfg.TokenFile, cfg.CAFile = cfx.cfg.URL, cfx.cfg.TokenFile, cfx.cfg.CAFile
|
||||||
|
p := newProvider(testProvider, cfg, cfx.api(t))
|
||||||
|
p.budgets = testBudgets()
|
||||||
|
if err := p.Delete(context.Background(), formatProviderID(vmid, name)); err != nil {
|
||||||
|
t.Fatalf("Delete error = %v", err)
|
||||||
|
}
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
if !slices.Contains(seen, "DELETE nodes/pv1-1/qemu/199999?destroy-unreferenced-disks=1&purge=1") {
|
||||||
|
t.Errorf("requests = %v, want the destroy with purge=1 and destroy-unreferenced-disks=1", seen)
|
||||||
|
}
|
||||||
|
if !slices.ContainsFunc(seen, func(s string) bool { return strings.HasPrefix(s, "DELETE nodes/pv1-1/storage/proxy-seeds/content/") }) {
|
||||||
|
t.Errorf("requests = %v, want the seed delete", seen)
|
||||||
|
}
|
||||||
|
if slices.ContainsFunc(seen, func(s string) bool { return strings.Contains(s, "status/stop") }) {
|
||||||
|
t.Errorf("requests = %v, want no stop for the captured stopped VM", seen)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDelete_concurrentSameNameSafe(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
fx := newFixture(t, 1)
|
||||||
|
fx.addOurVM(testVMID, testName, "running")
|
||||||
|
release := fx.fake.blockOn("Stop")
|
||||||
|
errs := make([]error, 2)
|
||||||
|
first := make(chan struct{})
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
wg.Go(func() {
|
||||||
|
defer close(first)
|
||||||
|
errs[0] = fx.p.Delete(fx.ctx, fx.id())
|
||||||
|
})
|
||||||
|
for fx.fake.callCount("Stop") == 0 {
|
||||||
|
select {
|
||||||
|
case <-first:
|
||||||
|
t.Fatalf("first Delete returned %v without stopping the VM", errs[0])
|
||||||
|
case <-time.After(time.Millisecond):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
wg.Go(func() { errs[1] = fx.p.Delete(fx.ctx, fx.id()) })
|
||||||
|
close(release)
|
||||||
|
wg.Wait()
|
||||||
|
for i, err := range errs {
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Delete %d error = %v", i, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for method, want := range map[string]int{"Stop": 1, "Destroy": 1} {
|
||||||
|
if got := fx.fake.callCount(method); got != want {
|
||||||
|
t.Errorf("%s calls = %d, want %d", method, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDelete_waitsOnTheNameLock: Delete takes the per-name lock before any
|
||||||
|
// call, so it cannot interleave with a Create or another Delete of that
|
||||||
|
// name (spec entry 19).
|
||||||
|
func TestDelete_waitsOnTheNameLock(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
fx := newFixture(t, 1)
|
||||||
|
fx.addOurVM(testVMID, testName, "running")
|
||||||
|
release, err := fx.p.lockName(fx.ctx, testName)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("lockName error = %v", err)
|
||||||
|
}
|
||||||
|
defer release()
|
||||||
|
ctx, cancel := context.WithTimeout(fx.ctx, 20*time.Millisecond)
|
||||||
|
defer cancel()
|
||||||
|
err = fx.p.Delete(ctx, fx.id())
|
||||||
|
assertProviderError(t, err, "delete", provider.ErrTransient)
|
||||||
|
if !errors.Is(err, context.DeadlineExceeded) {
|
||||||
|
t.Errorf("error = %v, want it to wrap DeadlineExceeded", err)
|
||||||
|
}
|
||||||
|
if calls := fx.fake.callLog(); len(calls) != 0 {
|
||||||
|
t.Errorf("calls = %v, want none while the name is locked", calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDelete_cancelledMidWaitReturnsPromptly(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
fx := newFixture(t, 1)
|
||||||
|
fx.p.budgets = defaultBudgets()
|
||||||
|
fx.p.budgets.poll = time.Millisecond
|
||||||
|
fx.fake.taskPolls = 1 << 30
|
||||||
|
fx.addOurVM(testVMID, testName, "running")
|
||||||
|
ctx, cancel := context.WithCancel(fx.ctx)
|
||||||
|
defer cancel()
|
||||||
|
fx.fake.hookOn("TaskStatus", func() error {
|
||||||
|
cancel()
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
start := time.Now()
|
||||||
|
err := fx.p.Delete(ctx, fx.id())
|
||||||
|
if elapsed := time.Since(start); elapsed > 5*time.Second {
|
||||||
|
t.Errorf("Delete took %v after cancellation", elapsed)
|
||||||
|
}
|
||||||
|
assertProviderError(t, err, "delete", provider.ErrTransient)
|
||||||
|
if !errors.Is(err, context.Canceled) {
|
||||||
|
t.Errorf("error = %v, want it to wrap context.Canceled", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDelete_outcomeLine(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
setup func(fx *fixture)
|
||||||
|
want []string
|
||||||
|
never string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "destroyed",
|
||||||
|
setup: func(fx *fixture) { fx.addOurVM(testVMID, testName, "stopped") },
|
||||||
|
want: []string{`"node"="` + fakeNode + `"`, `"destroyed"=true`},
|
||||||
|
},
|
||||||
|
{name: "already gone", setup: func(*fixture) {}, want: []string{`"destroyed"=false`}, never: `"node"=`},
|
||||||
|
}
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
fx := newFixture(t, 1)
|
||||||
|
tc.setup(fx)
|
||||||
|
fx.delete()
|
||||||
|
lines := fx.logs.matching(`"msg"="proxmox vm deleted"`)
|
||||||
|
if len(lines) != 1 {
|
||||||
|
t.Fatalf("outcome lines = %d, want 1:\n%s", len(lines), strings.Join(fx.logs.all(), "\n"))
|
||||||
|
}
|
||||||
|
for _, want := range append(tc.want, `"vmid"=150042`, `"name"="`+testName+`"`, `"provider"="`+testProvider+`"`) {
|
||||||
|
if !strings.Contains(lines[0], want) {
|
||||||
|
t.Errorf("outcome line lacks %s: %s", want, lines[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if tc.never != "" && strings.Contains(lines[0], tc.never) {
|
||||||
|
t.Errorf("outcome line has %s: %s", tc.never, lines[0])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDelete_noOpLinesAreSilentAtV0(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
tests := map[string]func(fx *fixture){
|
||||||
|
"absent": func(*fixture) {},
|
||||||
|
"foreign": func(fx *fixture) { fx.addOurVM(testVMID, "proxy-someoneelse00000", "running") },
|
||||||
|
"unmarked": func(fx *fixture) { fx.fake.addVM(testVMID, testName, "running") },
|
||||||
|
"ours": func(fx *fixture) { fx.addOurVM(testVMID, testName, "running") },
|
||||||
|
}
|
||||||
|
for name, setup := range tests {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
fx := newFixture(t, 0)
|
||||||
|
fx.p.api = observe(fx.fake, testProvider)
|
||||||
|
setup(fx)
|
||||||
|
fx.delete()
|
||||||
|
if lines := fx.logs.all(); len(lines) != 0 {
|
||||||
|
t.Errorf("V(0) lines = %v, want none", lines)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -39,9 +39,10 @@ type fakeAPI struct {
|
|||||||
// taskPolls is how many TaskStatus polls answer "running" before a
|
// taskPolls is how many TaskStatus polls answer "running" before a
|
||||||
// task stops.
|
// task stops.
|
||||||
taskPolls int
|
taskPolls int
|
||||||
// cloneFailsWith and startFailsWith, when set, are the exitstatus of
|
// cloneFailsWith, startFailsWith and destroyFailsWith, when set, are
|
||||||
// every clone or start task. A failed clone removes its VM.
|
// the exitstatus of every clone, start or destroy task. A failed clone
|
||||||
cloneFailsWith, startFailsWith string
|
// removes its VM; a failed destroy keeps it.
|
||||||
|
cloneFailsWith, startFailsWith, destroyFailsWith string
|
||||||
// agentNotRunning makes the agent of every running VM with agent=1
|
// agentNotRunning makes the agent of every running VM with agent=1
|
||||||
// answer "QEMU guest agent is not running".
|
// answer "QEMU guest agent is not running".
|
||||||
agentNotRunning bool
|
agentNotRunning bool
|
||||||
@@ -510,8 +511,12 @@ func (f *fakeAPI) Destroy(ctx context.Context, node string, vmid int) (string, e
|
|||||||
if vm.status == "running" {
|
if vm.status == "running" {
|
||||||
return "", fakeErr(500, fmt.Sprintf("fake: VM %d is running - destroy failed", vmid))
|
return "", fakeErr(500, fmt.Sprintf("fake: VM %d is running - destroy failed", vmid))
|
||||||
}
|
}
|
||||||
return f.newTask(node, "qmdestroy", strconv.Itoa(vmid), "OK", func() {
|
exit := "OK"
|
||||||
if f.vms[vmid] == vm {
|
if f.destroyFailsWith != "" {
|
||||||
|
exit = f.destroyFailsWith
|
||||||
|
}
|
||||||
|
return f.newTask(node, "qmdestroy", strconv.Itoa(vmid), exit, func() {
|
||||||
|
if exit == "OK" && f.vms[vmid] == vm {
|
||||||
delete(f.vms, vmid)
|
delete(f.vms, vmid)
|
||||||
}
|
}
|
||||||
}), nil
|
}), nil
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -474,3 +475,60 @@ func TestGet_resourcesFailureIsTransient(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGet_afterDeleteReturnsNotFound(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
fx := newFixture(t, 1)
|
||||||
|
fx.addOurVM(testVMID, testName, "running").ifaces = capturedIfaces(t)
|
||||||
|
if inst := fx.get(fx.id()); inst.State != provider.StateRunning {
|
||||||
|
t.Fatalf("Get before Delete = %+v, want Running", inst)
|
||||||
|
}
|
||||||
|
if err := fx.p.Delete(fx.ctx, fx.id()); err != nil {
|
||||||
|
t.Fatalf("Delete error = %v", err)
|
||||||
|
}
|
||||||
|
assertGetFails(t, fx, provider.ErrNotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGet_duringDeleteStillReturnsInstance holds the destroy task running:
|
||||||
|
// destroying sets no lock and keeps the VM listed until the task ends, as
|
||||||
|
// Phase 0's mid-destroy captures show, so Get sees it Stopped, not gone.
|
||||||
|
func TestGet_duringDeleteStillReturnsInstance(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
if _, locked := capturedConfig(t, "config_during_destroy")["lock"]; locked {
|
||||||
|
t.Fatal("capture config_during_destroy has a lock; the fake models none")
|
||||||
|
}
|
||||||
|
listed := decodeData[[]vmResource](t, "cluster_resources_during_destroy")
|
||||||
|
if !slices.ContainsFunc(listed, func(r vmResource) bool { return r.VMID == 199999 && r.Status == "stopped" && r.Lock == "" }) {
|
||||||
|
t.Fatal("capture cluster_resources_during_destroy does not list VM 199999 stopped and unlocked")
|
||||||
|
}
|
||||||
|
|
||||||
|
fx := newFixture(t, 1)
|
||||||
|
fx.addOurVM(testVMID, testName, "running")
|
||||||
|
inTask, release := make(chan struct{}), make(chan struct{})
|
||||||
|
var once sync.Once
|
||||||
|
fx.fake.hookOn("TaskStatus", func() error {
|
||||||
|
if fx.fake.callCount("Destroy") > 0 {
|
||||||
|
once.Do(func() {
|
||||||
|
close(inTask)
|
||||||
|
<-release
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
done := make(chan error, 1)
|
||||||
|
go func() { done <- fx.p.Delete(fx.ctx, fx.id()) }()
|
||||||
|
select {
|
||||||
|
case <-inTask:
|
||||||
|
case err := <-done:
|
||||||
|
t.Fatalf("Delete returned %v before its destroy task was polled", err)
|
||||||
|
}
|
||||||
|
inst := fx.get(fx.id())
|
||||||
|
if inst.State != provider.StateStopped || inst.UID != testUID || inst.ID != fx.id() {
|
||||||
|
t.Errorf("Get during the destroy = %+v, want the instance Stopped with its uid", inst)
|
||||||
|
}
|
||||||
|
close(release)
|
||||||
|
if err := <-done; err != nil {
|
||||||
|
t.Fatalf("Delete error = %v", err)
|
||||||
|
}
|
||||||
|
assertGetFails(t, fx, provider.ErrNotFound)
|
||||||
|
}
|
||||||
|
|||||||
@@ -172,9 +172,3 @@ func (p *Provider) lockName(ctx context.Context, name string) (func(), error) {
|
|||||||
}
|
}
|
||||||
return release, nil
|
return release, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete is a stub until Phase 7. It answers Transient, so a Proxy on this
|
|
||||||
// provider retries rather than latching.
|
|
||||||
func (p *Provider) Delete(_ context.Context, id string) error {
|
|
||||||
return p.wrapErr("delete", id, transientf("proxmox Delete is not implemented yet"))
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -215,16 +215,6 @@ func TestWrapErr_carriesProviderNameOpAndID(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestStubs_answerTransient keeps a Proxy on this provider retrying, not
|
|
||||||
// latched, until Delete is implemented in Phase 7.
|
|
||||||
func TestStubs_answerTransient(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
p := newProvider("proxmox-home", testConfig(), nil)
|
|
||||||
if err := p.Delete(context.Background(), "vms/150042/proxy-x"); !errors.Is(err, provider.ErrTransient) {
|
|
||||||
t.Errorf("delete error = %v, want ErrTransient", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestLockName_givesUpWithContextAsTransient(t *testing.T) {
|
func TestLockName_givesUpWithContextAsTransient(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
p := newProvider("proxmox-home", testConfig(), nil)
|
p := newProvider("proxmox-home", testConfig(), nil)
|
||||||
|
|||||||
Reference in New Issue
Block a user