proxmox: a Proxmox VE provider built from the provider contract #9

Merged
kacerr merged 13 commits from feat/proxmox-provider into main 2026-09-24 13:12:45 +02:00
13 changed files with 1936 additions and 46 deletions
Showing only changes of commit 2f89c1fffb - Show all commits
+1 -1
View File
@@ -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.
+48 -20
View File
@@ -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`.
+218
View File
@@ -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)
}
+577
View File
@@ -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)
}
})
}
}
+6 -2
View File
@@ -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
} }
+149
View File
@@ -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
}
+717
View File
@@ -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)
}
})
}
}
+10 -5
View File
@@ -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
+58
View File
@@ -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)
}
-6
View File
@@ -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"))
}
-10
View File
@@ -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)