Author SHA1 Message Date
kacerrandClaude Opus 5 e6ed46fd7c Record the provider health validation and add the changelog entry
The user ran the real-hypervisor checks against beelink and confirmed they
passed, which completes Step 7 of the provider health plan.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 08:45:58 +00:00
kacerrandClaude Opus 5 80b2e9c85e Plan the provider blueprint document
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 08:41:42 +00:00
kacerrandClaude Opus 5 4e23347760 Point the KVM integration tests at a dedicated known_hosts
Leaving KVM_TEST_KNOWN_HOSTS unset silently disables host-key
verification for the run, so document it as part of the normal setup
rather than as an optional extra, and suggest a kvm_known_hosts holding
only the hypervisors.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-12 05:59:22 +00:00
kacerrandClaude Opus 5 4e5b5d62df Probe hosts for health instead of risking the shared connection
The health check shares each host's libvirt connection with the reconciles,
so it must not be able to take that connection away from them.

- A timed-out check no longer drops the link. The check goes through the new
  hostPool.probe, which abandons its RPCs when the context ends and leaves
  the connection alone, so an image upload or a Create running on the same
  link is not cut off. A link that really died is closed by the keepalive.
  One abandoned probe per host may stay waiting; until it returns, the next
  check reports Timeout at once (errProbeBusy).
- Waiting for a host's connection slot now ends with the caller's context.
  hostEntry used a mutex, so a check could wait out a reconcile redialing a
  dead host — minutes — past its own timeout. The slot is a one-slot channel
  and acquire bounds only the wait, never the uncontended take.
- --provider-check-timeout defaults to 0 and derives min(15s, interval).
  The flat 15s default contradicted the validation that the timeout may not
  exceed the interval, so --provider-check-interval=12s alone failed to
  start.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-12 05:04:45 +00:00
kacerrandClaude Opus 5 0e456affdd Record the automated validation of the provider health checks
make test (envtest, -race) and the kind e2e suite pass; the e2e
ProviderHealth for the kubernetes provider turned Ready within a second
of the manager starting and its gauge appeared in the scrape. make lint
is blocked by the local go1.27 toolchain crashing staticcheck on the
standard library, unrelated to this branch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-11 20:31:15 +00:00
kacerrandClaude Opus 5 19b4a82ae9 Document the provider health checks
Architecture gets a provider-health section, component rows and four
decisions; the KVM provider doc gets a health-check section with the
reason table and the rotated-Secret gap; the testing guide lists the new
suites. CLAUDE.md's Architecture TODO and stale "only go.mod exists"
gotcha now point at the real docs, plus the RBAC-marker gotcha found
while wiring the monitor.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-11 20:28:09 +00:00
kacerrandClaude Opus 5 9a3ee963a0 Wire the provider health monitor into the manager, with gauges
--provider-check-interval (1m; 0 = off, else at least 10s) and
--provider-check-timeout (15s) are validated before the providers config
loads. Checkers are collected from the unwrapped providers in config
order, so checks never count as provider requests. Three scrape-time
gauges per {provider,target} read the monitor's snapshot: up, check
duration and check timestamp. The e2e suite waits for the kubernetes
ProviderHealth to turn Ready and asserts its gauge in the scrape.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-11 20:23:55 +00:00
kacerrandClaude Opus 5 88a15ecdb5 Add the provider health Monitor
A leader-only runnable checks every configured provider concurrently
under one timeout per round (first round at start, then every interval),
keeps the latest results for the metrics collector, logs targets going
down, changing reason or coming back, and publishes one ProviderHealth
object per provider: created with a managed-by label when missing, then
a status merge patch. Per-target lastTransitionTime survives rounds in
which ready does not flip. Objects of providers that are no longer
configured are deleted once at start; with checks turned off, all are.
Reads go through the API reader, so no cluster-wide informer is needed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-11 20:20:52 +00:00
kacerrandClaude Opus 5 04dd2c0284 Add the KVM health check: reachability and storage pool per host
Each configured host is checked concurrently over the pooled SSH+libvirt
connection: libvirt version and hostname prove the daemon answers, then
the configured pool must exist and be running, with its free space
reported. Dial failures are now tagged with their stage (SSH material,
TCP, SSH handshake, libvirt connect) by a dialError that keeps the
message and unwraps to the cause, so classification is unchanged and the
check can say why a host is unreachable. A pool verdict is decided after
the call returns so it never drops a working connection.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-11 20:17:35 +00:00
kacerrandClaude Opus 5 1962365f44 Add the provider Checker contract and the kubernetes and GCP checks
Checker is an optional interface next to Provider: Check returns one
verdict per target and never fails as a whole. The kubernetes provider
lists at most one managed Pod cluster-wide (the verb and scope GC relies
on); the GCP provider fetches a single page of at most one managed
instance through a new Probe seam method, since the SDK treats
MaxResults as a page size and the existing AggregatedList drains every
page.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-11 20:08:21 +00:00
kacerrandClaude Opus 5 1c773c24ad Add the cluster-scoped ProviderHealth CRD
One operator-owned object per configured provider will carry the
periodic check result: a Ready condition, per-target verdicts with their
own transition times, and a ready/total count for kubectl get. There is
no spec; only a viewer role is shipped since the operator is the sole
writer. Written by hand (kubebuilder is not installed), registered in
the existing AddKnownTypes call rather than a second init().

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-11 18:30:31 +00:00
kacerrandClaude Opus 5 18a3378507 Add plan: provider health checks
Periodic, leader-only checks of every configured provider (KVM per host
over the pooled SSH+libvirt connection including the storage pool,
kubernetes apiserver/RBAC, GCP credentials/API), surfaced through a new
cluster-scoped ProviderHealth CRD, Prometheus gauges and transition logs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-11 18:08:59 +00:00
kacerr eb8f075f7a Merge pull request 'KVM/libvirt provider: proxies as VMs on your own hypervisors' (#5) from feat/kvm-provider into main
Reviewed-on: #5
2026-09-11 17:02:33 +02:00
kacerrandClaude Opus 5 43d429aef7 Point the manager config and sample at the beelink KVM host
The default manager config gains a kvm-home provider reaching beelink
over Tailscale, the provider sample names the same host, and the
manager kustomization uses the locally built kvm-dev image.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-11 06:26:44 +00:00
kacerrandClaude Opus 5 82eeeef9da Simplify the KVM provider after the /simplify review
Collapse duplicated logic the review found in PR #5 without changing
behaviour: one releaseFinalizer helper for the four finalizer exits in
reconcileDelete, classify reusing isClassified and provider.Class, the
image URL check and format/firmware values shared from the provider
package, imageSource and the metadata parser embedding their config
structs instead of copying fields, and the SSH loader as a small struct
that keeps only the SSH fields and is seeded by New instead of loading
the key twice. Tracing builds its V(2)-only lists only at V(2).

Tests: drop a duplicate New test, fold near-identical tests into tables
(connection drops, metadata parsing, cached images), add mustSSHAuth,
and use wantClass where classes were checked by hand.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-11 06:24:43 +00:00
kacerrandClaude Opus 5 2b4b61cdc4 Record the review-fix validation: integration, in-cluster and the missing-Secret check
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 22:59:01 +00:00
kacerrandClaude Opus 5 c23def0051 Install the guest agent from a vendor-data script instead of editing the user's cloud-config
Merging the bootstrap into each Proxy's cloud-config was the wrong
mechanism twice over. Detection only matched a literal #cloud-config
first line, so a jinja template or a MIME archive still fell back to
vendor-data, where cloud-init's default mergers drop it — the exact
failure the merge existed to prevent. And the YAML round-trip re-typed
unquoted scalars: version: 1.10 reached the guest as 1.1, comments were
dropped and keys reordered.

Neither is worth patching, because vendor-data delivered as a shell
script bypasses the merger entirely: cloud-init hands vendor-data to its
own handler chain, writes scripts to a separate directory and runs them
from cc_scripts_vendor, after the user's packages and before the user's
runcmd. The merge could not have been rescued anyway — merge_how in
user-data governs only the first source, and merge_how in vendor-data is
discarded before the document is stored.

The Proxy's cloud-init now reaches the seed byte for byte in any format.
installGuestAgent (default true) turns the script off; a configured
vendorData travels beside it as a MIME multipart, declared text/plain so
cloud-init re-sniffs it and this code never classifies user content.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 22:42:55 +00:00
kacerrandClaude Opus 5 10ca71331e Load the KVM ssh material on first use instead of at startup
The kvm-ssh Secret is mounted optional, but kvm.New read the key and
known_hosts eagerly and registry.Build aborts the whole provider set on a
constructor error, so a missing or misnamed Secret exited the manager
before any controller started and took the kubernetes and gcp providers
down with it.

lazyAuth loads the material on the first connection and caches it once it
works, so a Secret created after the manager starts heals the affected
proxies on the next reconcile with no restart. A missing file classifies
transient for that reason; a key that does not parse is permanent.

New still checks both files and logs a WARNING: line when they are
unreadable, keeping the startup diagnostic the eager load was there for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 22:34:28 +00:00
kacerrandClaude Opus 5 bbe9b751a9 Reject KVM host names and image keys that would break the identifiers they land in
A host name is pasted into the providerID hosts/<name>/domains/<vm>, which
parseProviderID splits on "/", so a name containing a slash produced an ID
that never parsed again: Get, Delete and GC all failed permanently and the
VM leaked with the Proxy's finalizer stuck.

The image map key has the same defect and lands in a file name rather than
an ID: base-<key>.<format> in the host's storage pool. Both now have to
match a plain identifier pattern. Machine-type keys are left alone; they
are only ever compared, never embedded.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 22:31:34 +00:00
kacerrandClaude Opus 5 ba24d7e3d9 Let a Proxy finish deleting when its instance can no longer be reached
reconcileDelete asked Get first and dropped the finalizer only on
not-found, so a permanent Get retried forever: a KVM host removed from
the config, or a providerID the provider cannot resolve, left the Proxy
in Terminating with no way out. It now tries Delete anyway, which is
idempotent and can still finish where Get cannot converge, and releases
the object when that succeeds.

crawl.example.com/skip-cleanup abandons the instance for the cases that
stay stuck, such as revoked credentials. It is checked before the
provider lookup, so it also frees a Proxy whose provider is gone from
the config.

In the KVM provider, Delete over an unconfigured host is now a no-op
success, mirroring removeVolumes over a missing pool. Get keeps failing
permanently there on purpose: the live path reads not-found as "rebuild
it", so a typo in hosts must not look like a missing VM.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 22:30:15 +00:00
kacerrandClaude Opus 5 ce365e2602 Add plan: fix the five code-review findings on the KVM branch
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 22:26:47 +00:00
kacerrandClaude Fable 5.1 0daf56bbad Close out the KVM provider plan: Step 7 done, MR #5 opened
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-10 21:41:38 +00:00
kacerrandClaude Fable 5.1 366736f53f Merge the qemu-guest-agent bootstrap into each Proxy's cloud-config instead of vendor-data
In the kind cluster two KVM Proxies with real cloud-configs booted fine
but never left Provisioning: the guest agent was not installed, so the
provider could not learn the IP. cloud-init merges vendor-data under
user-data with its default mergers, which keep the user-data's value
for any key both define and never append lists, so a cloud-config with
its own `packages:` silently dropped the vendor-data's packages and
runcmd. The integration test passed only because its user-data was
empty.

withGuestAgent now appends qemu-guest-agent to `packages` and the
enable command to `runcmd` of the Proxy's own cloud-config (skipping
entries already present) and re-renders it. Vendor-data is no longer
defaulted; `vendorData` is plain extra data. User-data that is not a
cloud-config document (script, #include, MIME) still gets the bootstrap
as vendor-data. A cloud-config that is not a YAML mapping, or whose
packages/runcmd is not a list, fails the Proxy as ErrPermanent.

Validated in-cluster: both Proxies Ready and healthy in two minutes, 204
through squid from the LAN. Docs, sample comment, CHANGELOG and the
plan execution log (Run 7) updated.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-10 21:06:54 +00:00
kacerrandClaude Fable 5.1 220967660d Give the manager a writable /tmp for seed ISOs; keep the libvirt connection on classified errors
The manager runs with a read-only root filesystem and the seed ISO
writer stages files in a temp directory, so the first in-cluster Create
failed with "creating iso writer: read-only file system". Mount a 64 MiB
emptyDir on /tmp.

That error is classified ErrPermanent by writeSeed, yet hostPool.call
treated every non-libvirt error as a transport failure and dropped the
cached SSH connection, redialling on each retry. Errors that already
carry a provider sentinel are local verdicts, not evidence about the
link; leave the connection alone for those.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-10 21:06:34 +00:00
63 changed files with 5628 additions and 413 deletions
+71
View File
@@ -1,5 +1,76 @@
# Changelog
## 2026-09-16 08:45 UTC — Provider health checks: ProviderHealth CRD, per-provider checks, gauges
- **Each configured provider is now checked on a schedule** and reported in a
cluster-scoped `ProviderHealth` object (`kubectl get ph`), one per provider, with a
`Ready` condition, a reason, and per-target verdicts.
- **Checks:** kubernetes lists one object from the API server; GCP fetches a single
instance page; KVM checks SSH/libvirt reachability and the storage pool on every
host, in parallel. Failures map to fixed reasons (`Unreachable`,
`SSHHandshakeFailed`, `PoolNotFound`, `Timeout`, …).
- **The KVM check can't hurt reconciles:** it uses `hostPool.probe`, which gives up on
a slow RPC without dropping the shared connection, and waiting for a host slot can
be interrupted.
- **Metrics:** `proxy_operator_provider_target_up`, `_check_duration_seconds` and
`_check_timestamp_seconds`. The flags are `--provider-check-interval` and
`--provider-check-timeout` (defaults to `min(15s, interval)`).
- Validated with `make test`, `make test-e2e` and the real-hypervisor KVM run against
beelink. `make lint` is blocked by a Go 1.27 toolchain/staticcheck panic that has
nothing to do with this change.
- Key files: `api/v1alpha1/providerhealth_types.go`, `internal/provider/check.go`,
`internal/provider/kvm/`, `internal/providerhealth/`, `docs/architecture.md`.
## 2026-09-10 22:58 UTC — Code-review fixes on the KVM branch: deletion, validation, lazy SSH, guest agent
Five findings from a review of `feat/kvm-provider`, all fixed on the branch.
- **A Proxy can always finish deleting.** The delete path used to drop the finalizer
only when `Get` answered not-found, so a permanent error there retried forever —
a KVM host removed from the config was enough. It now calls `Delete` anyway, since
that is idempotent and can finish where `Get` cannot, and
`crawl.example.com/skip-cleanup: "true"` abandons the instance for whatever stays
stuck. In the KVM provider, deleting over an unconfigured host is a no-op success;
`Get` still fails permanently there so a typo cannot look like a missing VM.
- **The guest agent ships as a vendor-data script**, superseding the cloud-config
merge in the entry below. Merging could never be made safe: cloud-init drops
vendor-data keys the user-data defines, and the strategy cannot be overridden from
either document. The Proxy's cloud-init now reaches the seed byte for byte in any
format — Jinja, MIME, gzip, scripts — where the round-trip previously re-typed
unquoted scalars (`version: 1.10` arrived as `1.1`), dropped comments and reordered
keys. New `installGuestAgent` setting, default on.
- **A missing `kvm-ssh` Secret no longer crash-loops the manager.** The SSH material
is read on first use and cached, so only KVM Proxies fail and they recover when the
Secret appears. A missing file is transient, an unparseable key permanent, and
startup still logs a warning.
- **Host names and image keys are validated.** Either can end up in a providerID or a
volume file name, where a slash made the VM unaddressable for good.
- Verified end to end: unit suite, the integration test against a real hypervisor
(22 s, agent reporting the IP), both in-cluster Proxies Ready in 2 min with 204
through squid, the guest showing `qemu-guest-agent` active with its cloud-config
comments intact, and the manager staying up with the Secret pointed away.
- Key files: `internal/provider/kvm/{seed.go,conn.go,kvm.go}`,
`internal/controller/proxy_controller.go`, `internal/provider/config_kvm.go`,
`api/v1alpha1/proxy_types.go`.
## 2026-09-10 21:04 UTC — KVM provider passes in-cluster; guest agent merged into cloud-config
- Two KVM Proxies with real cloud-configs reach `Ready` from inside the kind cluster
(2 min from apply, IP via the guest agent, 204 through squid from the LAN).
- The `qemu-guest-agent` bootstrap is now merged into each Proxy's cloud-config
(`packages` + `runcmd`) instead of shipped as vendor-data: cloud-init's default
merger drops vendor-data keys the user-data also defines, so any Proxy with its own
`packages:` never got the agent and stayed `Provisioning` forever. `vendorData` is
now plain extra vendor-data with no default; non-cloud-config user-data (scripts,
`#include`) still gets the bootstrap as vendor-data. A cloud-config that is not a
YAML mapping fails the Proxy permanently instead of booting without its config.
- The manager pod mounts a 64 MiB `emptyDir` on `/tmp`: the seed ISO writer stages
files on disk and the root filesystem is read-only.
- Errors already classified with a provider sentinel no longer drop the cached
libvirt SSH connection; only libvirt and transport errors do.
- Key files: `internal/provider/kvm/{seed.go,conn.go,errors.go}`,
`config/manager/manager.yaml`, `docs/kvm-provider.md`.
## 2026-09-10 10:07 UTC — KVM provider validated on a real hypervisor; UEFI default; libvirt call tracing
- `make test-integration-kvm` passes end to end against a libvirt 11.6 / QEMU 10.1
+11 -8
View File
@@ -25,10 +25,11 @@ entrypoint yet, so there's nothing to run locally.
## Architecture
Not yet built. TODO: once the controller-runtime manager and reconcile loop exist,
document the data-flow diagram and package/role table here, following the standard
reconcile-loop shape: fetch → resolve → list related → compute desired state → merge
→ patch only on diff.
Documented in [docs/architecture.md](docs/architecture.md). It holds the component
table, the reconcile flow, and one section per runnable: health engine, discovery and
lease API, orphan GC, metrics, tracing, and provider health checks. Provider-specific
detail is in `docs/kvm-provider.md` and the GCP docs; the test layout is in
`docs/testing.md`.
## Conventions
@@ -214,7 +215,9 @@ A lightweight `check` job (`go vet` / `go build` / `go test -short`) gates the b
## Gotchas
- This repo currently contains only `go.mod` (module
`gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator`) — no controller code,
`main.go`, Dockerfile, k8s manifests, Makefile, or CI config exist yet. Nothing in
this file describes working software; it's the convention scaffold to build against.
- The "What this is" and "Commands" sections above predate the code and are stale.
The Makefile targets (`make test`, `make manifests generate`, `make test-e2e`,
`make test-integration-kvm`) and `docs/testing.md` are authoritative.
- `+kubebuilder:rbac` markers are package-level. Keep them in their own comment block,
never inside a type's or function's doc comment: controller-gen silently ignores
them there. After `make manifests`, check `git diff config/rbac/role.yaml`.
+7
View File
@@ -18,4 +18,11 @@ resources:
kind: Proxy
path: gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/api/v1alpha1
version: v1alpha1
- api:
crdVersion: v1
domain: example.com
group: crawl
kind: ProviderHealth
path: gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/api/v1alpha1
version: v1alpha1
version: "3"
+6
View File
@@ -164,6 +164,12 @@ Cloud-init from a Secret: the Secret **must** carry the label
labelled Secrets, so an unlabelled one is invisible (the Proxy reports
`CloudInitError`). Rotating the Secret's content triggers VM replacement.
Stuck deletion: a Proxy stays `Terminating` while the operator keeps failing
to clean its instance up — credentials revoked, a provider or a KVM host
dropped from the config. Annotate it with
`crawl.example.com/skip-cleanup: "true"` to abandon the instance and release
the object. Whatever is still running then has to be removed by hand.
## Providers
`Managed` proxies are provisioned by a provider — a small compute backend
+127
View File
@@ -0,0 +1,127 @@
/*
Copyright 2026.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package v1alpha1
import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
// ConditionReady summarizes a ProviderHealth's targets: True when every
// target passed its last check.
const ConditionReady = "Ready"
// ProviderTarget is the latest check verdict for one thing a provider
// depends on: a KVM hypervisor, the Kubernetes API server, a GCP project.
type ProviderTarget struct {
// name identifies the target within its provider: the KVM host name,
// "apiserver", or the GCP project.
// +kubebuilder:validation:MinLength=1
// +required
Name string `json:"name"`
// address is where the check connected to.
// +optional
Address string `json:"address,omitempty"`
// ready is true when the last check succeeded.
// +required
Ready bool `json:"ready"`
// reason is a CamelCase verdict such as Reachable, Unreachable,
// Forbidden, SSHHandshakeFailed or PoolNotActive.
// +optional
Reason string `json:"reason,omitempty"`
// message is human-readable detail, usually the error of a failed check.
// +kubebuilder:validation:MaxLength=1024
// +optional
Message string `json:"message,omitempty"`
// latencyMillis is how long the last check took.
// +optional
LatencyMillis int64 `json:"latencyMillis,omitempty"`
// lastTransitionTime is when ready last changed.
// +required
LastTransitionTime metav1.Time `json:"lastTransitionTime"`
// details are provider-specific facts from the last check, e.g. the
// libvirt version and free storage-pool space of a KVM host.
// +optional
Details map[string]string `json:"details,omitempty"`
}
// ProviderHealthStatus is the result of the latest check of one provider.
type ProviderHealthStatus struct {
// type is the provider's type from the providers config: kubernetes,
// gcp or kvm.
// +optional
Type string `json:"type,omitempty"`
// conditions hold Ready, which summarizes the targets.
// +listType=map
// +listMapKey=type
// +optional
Conditions []metav1.Condition `json:"conditions,omitempty"`
// targets hold one verdict per checked target.
// +listType=map
// +listMapKey=name
// +optional
Targets []ProviderTarget `json:"targets,omitempty"`
// targetsReady counts the ready targets as "ready/total".
// +optional
TargetsReady string `json:"targetsReady,omitempty"`
// lastCheckTime is when the latest check finished.
// +optional
LastCheckTime *metav1.Time `json:"lastCheckTime,omitempty"`
}
// +kubebuilder:object:root=true
// +kubebuilder:subresource:status
// +kubebuilder:resource:scope=Cluster,shortName=ph
// +kubebuilder:printcolumn:name="Type",type=string,JSONPath=`.status.type`
// +kubebuilder:printcolumn:name="Ready",type=string,JSONPath=`.status.conditions[?(@.type=="Ready")].status`
// +kubebuilder:printcolumn:name="Targets",type=string,JSONPath=`.status.targetsReady`
// +kubebuilder:printcolumn:name="Last Check",type=date,JSONPath=`.status.lastCheckTime`
// +kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp`
// ProviderHealth reports the periodic health check of one configured
// provider. It is named after the provider, and the operator creates,
// updates and deletes it itself; there is no spec to edit.
type ProviderHealth struct {
metav1.TypeMeta `json:",inline"`
// metadata is a standard object metadata
// +optional
metav1.ObjectMeta `json:"metadata,omitzero"`
// status is the result of the latest check
// +optional
Status ProviderHealthStatus `json:"status,omitzero"`
}
// +kubebuilder:object:root=true
// ProviderHealthList contains a list of ProviderHealth
type ProviderHealthList struct {
metav1.TypeMeta `json:",inline"`
metav1.ListMeta `json:"metadata,omitzero"`
Items []ProviderHealth `json:"items"`
}
+10 -1
View File
@@ -62,6 +62,15 @@ const (
// must be replaced.
AnnotationSpecHash = "crawl.example.com/spec-hash"
// AnnotationSkipCleanup abandons the provider-side instance: set it to
// "true" and deletion removes the finalizer without asking the provider
// to clean anything up. It is the escape hatch for a cleanup that cannot
// succeed — a provider dropped from the config, credentials revoked, a
// backend that answers permanently — where the object would otherwise
// stay in Terminating forever. The instance is left running and becomes
// the administrator's to remove.
AnnotationSkipCleanup = "crawl.example.com/skip-cleanup"
// LabelCloudInit must be set (to "true") on every Secret referenced by
// spec.cloudInit.secretRef: the manager's cache only holds Secrets
// carrying this label, so an unlabelled Secret is invisible to the
@@ -352,7 +361,7 @@ type ProxyList struct {
func init() {
SchemeBuilder.Register(func(s *runtime.Scheme) error {
s.AddKnownTypes(SchemeGroupVersion, &Proxy{}, &ProxyList{})
s.AddKnownTypes(SchemeGroupVersion, &Proxy{}, &ProxyList{}, &ProviderHealth{}, &ProviderHealthList{})
return nil
})
}
+114
View File
@@ -95,6 +95,120 @@ func (in *PlacementSpec) DeepCopy() *PlacementSpec {
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *ProviderHealth) DeepCopyInto(out *ProviderHealth) {
*out = *in
out.TypeMeta = in.TypeMeta
in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
in.Status.DeepCopyInto(&out.Status)
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ProviderHealth.
func (in *ProviderHealth) DeepCopy() *ProviderHealth {
if in == nil {
return nil
}
out := new(ProviderHealth)
in.DeepCopyInto(out)
return out
}
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
func (in *ProviderHealth) DeepCopyObject() runtime.Object {
if c := in.DeepCopy(); c != nil {
return c
}
return nil
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *ProviderHealthList) DeepCopyInto(out *ProviderHealthList) {
*out = *in
out.TypeMeta = in.TypeMeta
in.ListMeta.DeepCopyInto(&out.ListMeta)
if in.Items != nil {
in, out := &in.Items, &out.Items
*out = make([]ProviderHealth, len(*in))
for i := range *in {
(*in)[i].DeepCopyInto(&(*out)[i])
}
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ProviderHealthList.
func (in *ProviderHealthList) DeepCopy() *ProviderHealthList {
if in == nil {
return nil
}
out := new(ProviderHealthList)
in.DeepCopyInto(out)
return out
}
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
func (in *ProviderHealthList) DeepCopyObject() runtime.Object {
if c := in.DeepCopy(); c != nil {
return c
}
return nil
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *ProviderHealthStatus) DeepCopyInto(out *ProviderHealthStatus) {
*out = *in
if in.Conditions != nil {
in, out := &in.Conditions, &out.Conditions
*out = make([]v1.Condition, len(*in))
for i := range *in {
(*in)[i].DeepCopyInto(&(*out)[i])
}
}
if in.Targets != nil {
in, out := &in.Targets, &out.Targets
*out = make([]ProviderTarget, len(*in))
for i := range *in {
(*in)[i].DeepCopyInto(&(*out)[i])
}
}
if in.LastCheckTime != nil {
in, out := &in.LastCheckTime, &out.LastCheckTime
*out = (*in).DeepCopy()
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ProviderHealthStatus.
func (in *ProviderHealthStatus) DeepCopy() *ProviderHealthStatus {
if in == nil {
return nil
}
out := new(ProviderHealthStatus)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *ProviderTarget) DeepCopyInto(out *ProviderTarget) {
*out = *in
in.LastTransitionTime.DeepCopyInto(&out.LastTransitionTime)
if in.Details != nil {
in, out := &in.Details, &out.Details
*out = make(map[string]string, len(*in))
for key, val := range *in {
(*out)[key] = val
}
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ProviderTarget.
func (in *ProviderTarget) DeepCopy() *ProviderTarget {
if in == nil {
return nil
}
out := new(ProviderTarget)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *Proxy) DeepCopyInto(out *Proxy) {
*out = *in
+59 -2
View File
@@ -16,8 +16,8 @@ limitations under the License.
// Package main is the composition root: it loads the provider config (fail
// fast), assembles the provider registry, and wires the reconciler, health
// engine, lease store, discovery API, orphan GC, and metrics onto one
// controller-runtime manager.
// engine, lease store, discovery API, orphan GC, provider health checks and
// metrics onto one controller-runtime manager.
package main
import (
@@ -60,6 +60,7 @@ import (
"gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/internal/provider/kubernetes"
"gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/internal/provider/kvm"
"gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/internal/provider/registry"
"gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/internal/providerhealth"
"gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/internal/tracing"
"gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/internal/version"
// +kubebuilder:scaffold:imports
@@ -97,6 +98,7 @@ func main() {
var showVersion bool
var gcpWireFullPayloads bool
var traceHealthProbes bool
var providerCheckInterval, providerCheckTimeout time.Duration
flag.StringVar(&metricsAddr, "metrics-bind-address", "0", "The address the metrics endpoint binds to. "+
"Use :8443 for HTTPS or :8080 for HTTP, or leave as 0 to disable the metrics service.")
@@ -140,6 +142,13 @@ func main() {
flag.BoolVar(&traceHealthProbes, "trace-health-probes", false,
"Emit one trace span per health probe. Off by default: probes run about "+
"once per second per proxy and would dominate trace volume.")
flag.DurationVar(&providerCheckInterval, "provider-check-interval", time.Minute,
"Interval between provider health checks (can the cluster reach each hypervisor, API "+
"and account), published as ProviderHealth objects and metrics. 0 turns them off; "+
"otherwise at least 10s.")
flag.DurationVar(&providerCheckTimeout, "provider-check-timeout", 0,
"Time limit for one round of provider health checks: 15s, or the interval when that is "+
"shorter. If set, at most the interval.")
opts := zap.Options{
Development: true,
@@ -174,6 +183,10 @@ func main() {
setupLog.Error(nil, "--providers-config is required")
os.Exit(1)
}
if err := providerhealth.ValidateSchedule(providerCheckInterval, providerCheckTimeout); err != nil {
setupLog.Error(err, "Invalid provider check flags")
os.Exit(1)
}
cfg, err := provider.LoadConfigFile(providersConfig)
if err != nil {
setupLog.Error(err, "Failed to load providers config", "path", providersConfig)
@@ -195,6 +208,14 @@ func main() {
os.Exit(1)
}
m := metrics.New()
// Checkers come from the unwrapped providers, in config order: the
// metrics and tracing wrappers implement only Provider, and a check is
// not a provider request to be counted anyway.
checkTargets := make([]providerhealth.Target, 0, len(cfg.Providers))
for _, pc := range cfg.Providers {
checker, _ := providers[pc.Name].(provider.Checker)
checkTargets = append(checkTargets, providerhealth.Target{Name: pc.Name, Type: pc.Type, Checker: checker})
}
for name, p := range providers {
// Tracing outermost: the span covers the metrics recording too.
providers[name] = provider.WithTracing(name, provider.WithMetrics(name, p, m))
@@ -314,6 +335,18 @@ func main() {
os.Exit(1)
}
monitor := &providerhealth.Monitor{
Client: mgr.GetClient(),
Reader: mgr.GetAPIReader(),
Targets: checkTargets,
Interval: providerCheckInterval,
Timeout: providerCheckTimeout,
}
if err := mgr.Add(monitor); err != nil {
setupLog.Error(err, "Failed to add provider health monitor")
os.Exit(1)
}
if err := m.Register(ctrlmetrics.Registry,
proxyPhaseCounts(mgr.GetClient()),
func() int {
@@ -327,6 +360,10 @@ func main() {
setupLog.Error(err, "Failed to register metrics")
os.Exit(1)
}
if err := metrics.RegisterProviderTargets(ctrlmetrics.Registry, providerTargetSamples(monitor)); err != nil {
setupLog.Error(err, "Failed to register provider check metrics")
os.Exit(1)
}
if err := (&controller.ProxyReconciler{
Client: mgr.GetClient(),
@@ -389,3 +426,23 @@ func proxyPhaseCounts(c client.Reader) func() map[string]int {
return counts
}
}
// providerTargetSamples flattens the monitor's latest results for the
// scrape-time provider-check gauges.
func providerTargetSamples(mon *providerhealth.Monitor) func() []metrics.ProviderTarget {
return func() []metrics.ProviderTarget {
var out []metrics.ProviderTarget
for _, r := range mon.Snapshot() {
for _, ts := range r.Targets {
out = append(out, metrics.ProviderTarget{
Provider: r.Provider,
Target: ts.Name,
Up: ts.Ready,
Duration: ts.Latency,
CheckedAt: r.CheckedAt,
})
}
}
return out
}
}
@@ -0,0 +1,193 @@
---
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
annotations:
controller-gen.kubebuilder.io/version: v0.21.0
name: providerhealths.crawl.example.com
spec:
group: crawl.example.com
names:
kind: ProviderHealth
listKind: ProviderHealthList
plural: providerhealths
shortNames:
- ph
singular: providerhealth
scope: Cluster
versions:
- additionalPrinterColumns:
- jsonPath: .status.type
name: Type
type: string
- jsonPath: .status.conditions[?(@.type=="Ready")].status
name: Ready
type: string
- jsonPath: .status.targetsReady
name: Targets
type: string
- jsonPath: .status.lastCheckTime
name: Last Check
type: date
- jsonPath: .metadata.creationTimestamp
name: Age
type: date
name: v1alpha1
schema:
openAPIV3Schema:
description: |-
ProviderHealth reports the periodic health check of one configured
provider. It is named after the provider, and the operator creates,
updates and deletes it itself; there is no spec to edit.
properties:
apiVersion:
description: |-
APIVersion defines the versioned schema of this representation of an object.
Servers should convert recognized schemas to the latest internal value, and
may reject unrecognized values.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources
type: string
kind:
description: |-
Kind is a string value representing the REST resource this object represents.
Servers may infer this from the endpoint the client submits requests to.
Cannot be updated.
In CamelCase.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds
type: string
metadata:
type: object
status:
description: status is the result of the latest check
properties:
conditions:
description: conditions hold Ready, which summarizes the targets.
items:
description: Condition contains details for one aspect of the current
state of this API Resource.
properties:
lastTransitionTime:
description: |-
lastTransitionTime is the last time the condition transitioned from one status to another.
This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable.
format: date-time
type: string
message:
description: |-
message is a human readable message indicating details about the transition.
This may be an empty string.
maxLength: 32768
type: string
observedGeneration:
description: |-
observedGeneration represents the .metadata.generation that the condition was set based upon.
For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date
with respect to the current state of the instance.
format: int64
minimum: 0
type: integer
reason:
description: |-
reason contains a programmatic identifier indicating the reason for the condition's last transition.
Producers of specific condition types may define expected values and meanings for this field,
and whether the values are considered a guaranteed API.
The value should be a CamelCase string.
This field may not be empty.
maxLength: 1024
minLength: 1
pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$
type: string
status:
description: status of the condition, one of True, False, Unknown.
enum:
- "True"
- "False"
- Unknown
type: string
type:
description: type of condition in CamelCase or in foo.example.com/CamelCase.
maxLength: 316
pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$
type: string
required:
- lastTransitionTime
- message
- reason
- status
- type
type: object
type: array
x-kubernetes-list-map-keys:
- type
x-kubernetes-list-type: map
lastCheckTime:
description: lastCheckTime is when the latest check finished.
format: date-time
type: string
targets:
description: targets hold one verdict per checked target.
items:
description: |-
ProviderTarget is the latest check verdict for one thing a provider
depends on: a KVM hypervisor, the Kubernetes API server, a GCP project.
properties:
address:
description: address is where the check connected to.
type: string
details:
additionalProperties:
type: string
description: |-
details are provider-specific facts from the last check, e.g. the
libvirt version and free storage-pool space of a KVM host.
type: object
lastTransitionTime:
description: lastTransitionTime is when ready last changed.
format: date-time
type: string
latencyMillis:
description: latencyMillis is how long the last check took.
format: int64
type: integer
message:
description: message is human-readable detail, usually the error
of a failed check.
maxLength: 1024
type: string
name:
description: |-
name identifies the target within its provider: the KVM host name,
"apiserver", or the GCP project.
minLength: 1
type: string
ready:
description: ready is true when the last check succeeded.
type: boolean
reason:
description: |-
reason is a CamelCase verdict such as Reachable, Unreachable,
Forbidden, SSHHandshakeFailed or PoolNotActive.
type: string
required:
- lastTransitionTime
- name
- ready
type: object
type: array
x-kubernetes-list-map-keys:
- name
x-kubernetes-list-type: map
targetsReady:
description: targetsReady counts the ready targets as "ready/total".
type: string
type:
description: |-
type is the provider's type from the providers config: kubernetes,
gcp or kvm.
type: string
type: object
type: object
served: true
storage: true
subresources:
status: {}
+1
View File
@@ -3,4 +3,5 @@
# It should be run by config/default
resources:
- bases/crawl.example.com_proxies.yaml
- bases/crawl.example.com_providerhealths.yaml
# +kubebuilder:scaffold:crdkustomizeresource
+2 -2
View File
@@ -5,5 +5,5 @@ apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
images:
- name: controller
newName: example.com/egress-proxies-operator
newTag: v0.0.1
newName: egress-proxies-operator
newTag: kvm-dev
+12
View File
@@ -66,6 +66,10 @@ spec:
- --providers-config=/etc/proxy-operator/providers.yaml
# Add --trace-health-probes to emit one span per health probe
# (high volume: ~1/s per proxy).
# Provider health checks (kubectl get providerhealth) run every
# minute; tune with --provider-check-interval (0 turns them off,
# otherwise at least 10s) and --provider-check-timeout (15s, or
# the interval if that is shorter).
env:
# Bearer token for the discovery API. Optional: without the
# Secret the API serves unauthenticated (with a loud warning).
@@ -154,6 +158,11 @@ spec:
- name: kvm-ssh
mountPath: /etc/proxy-operator/kvm
readOnly: true
# Scratch space: the root filesystem is read-only, and the kvm
# provider builds each VM's cloud-init seed ISO through a temp
# directory (a few hundred KB, deleted right after upload).
- name: tmp
mountPath: /tmp
volumes:
- name: providers-config
configMap:
@@ -162,5 +171,8 @@ spec:
secret:
secretName: kvm-ssh
optional: true
- name: tmp
emptyDir:
sizeLimit: 64Mi
serviceAccountName: controller-manager
terminationGracePeriodSeconds: 10
+36
View File
@@ -16,3 +16,39 @@ data:
providers:
- name: kubernetes
type: kubernetes
- name: kvm-home
type: kvm
kvm:
# SSH to each host with the libvirt unix socket forwarded through the
# connection; nothing to open on the hypervisors beyond sshd. Both
# files come from the optional kvm-ssh Secret mounted by
# config/manager/manager.yaml — see docs/kvm-provider.md.
sshKeyFile: /etc/proxy-operator/kvm/id_ed25519
knownHostsFile: /etc/proxy-operator/kvm/known_hosts
# insecureIgnoreHostKey: true # skip host-key checks (not recommended)
# diskSizeGb: 10 # per-VM overlay; cloud-init grows the root fs into it
# vendorData: | # extra vendor-data; the qemu-guest-agent bootstrap is merged
# #cloud-config # into each Proxy's cloud-config, not configured here
# ...
images:
# placement.image names a key here (or is an https:// URL directly).
# Fetched into each host's pool on first use and cached there as
# base-<key>.qcow2; do not change an entry while Proxies use it.
debian-13:
url: https://cloud.debian.org/images/cloud/trixie/latest/debian-13-generic-amd64.qcow2
# sha256: "<64 hex>" # optional; verified after download
# firmware: uefi # default; "bios" only for images with no EFI partition
# # (Debian 13 hangs in GRUB under BIOS headless: Debian bug #1111240)
ubuntu-24.04:
url: https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-amd64.img
machineTypes: # placement.machineType names a key here
small: {vcpus: 1, memoryMiB: 1024}
medium: {vcpus: 2, memoryMiB: 2048}
hosts: # placement.zone names a host; optional with one host
- name: beelink
address: beelink.tail8c69c.ts.net:22
user: novakj # must be in the libvirt group
bridge: br0 # to a LAN with DHCP; the VM's IP comes from there
# socket: /var/run/libvirt/libvirt-sock
# pool: default
+3
View File
@@ -25,4 +25,7 @@ resources:
- proxy_admin_role.yaml
- proxy_editor_role.yaml
- proxy_viewer_role.yaml
# ProviderHealth objects are written only by the operator, so they get a
# viewer role and nothing else.
- providerhealth_viewer_role.yaml
@@ -0,0 +1,29 @@
# This rule is not used by the project egress-proxies-operator itself.
# It is provided to allow the cluster admin to help manage permissions for users.
#
# Grants read-only access to ProviderHealth objects, the results of the
# operator's periodic provider checks. The operator creates, updates and
# deletes them itself, so no admin or editor role is provided.
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
labels:
app.kubernetes.io/name: egress-proxies-operator
app.kubernetes.io/managed-by: kustomize
name: providerhealth-viewer-role
rules:
- apiGroups:
- crawl.example.com
resources:
- providerhealths
verbs:
- get
- list
- watch
- apiGroups:
- crawl.example.com
resources:
- providerhealths/status
verbs:
- get
+16
View File
@@ -22,6 +22,22 @@ rules:
- get
- list
- watch
- apiGroups:
- crawl.example.com
resources:
- providerhealths
verbs:
- create
- delete
- get
- list
- apiGroups:
- crawl.example.com
resources:
- providerhealths/status
verbs:
- get
- patch
- apiGroups:
- crawl.example.com
resources:
+6 -5
View File
@@ -30,8 +30,9 @@ providers:
knownHostsFile: /etc/proxy-operator/kvm/known_hosts
# insecureIgnoreHostKey: true # skip host-key checks (not recommended)
# diskSizeGb: 10 # per-VM overlay; cloud-init grows the root fs into it
# vendorData: | # default installs qemu-guest-agent for IP discovery
# #cloud-config
# installGuestAgent: false # skip the vendor-data script that installs qemu-guest-agent
# vendorData: | # extra vendor-data, carried beside the agent script as a
# #cloud-config # MIME multipart. The Proxy's own cloud-init is never rewritten.
# ...
images:
# placement.image names a key here (or is an https:// URL directly).
@@ -48,9 +49,9 @@ providers:
small: {vcpus: 1, memoryMiB: 1024}
medium: {vcpus: 2, memoryMiB: 2048}
hosts: # placement.zone names a host; optional with one host
- name: nas1
address: nas1.home.lan:22
user: virt # must be in the libvirt group
- name: beelink
address: beelink:22
user: novakj # must be in the libvirt group
bridge: br0 # to a LAN with DHCP; the VM's IP comes from there
# socket: /var/run/libvirt/libvirt-sock
# pool: default
+97 -3
View File
@@ -4,9 +4,9 @@
| Component | Package | Runs as | Leader-elected | Role |
|---|---|---|---|---|
| Proxy CRD + helpers | `api/v1alpha1` | types | — | `Proxy` spec/status, CEL validation, defaulting, pure helpers |
| Proxy + ProviderHealth CRDs | `api/v1alpha1` | types | — | `Proxy` spec/status, CEL validation, defaulting, pure helpers; the status-only, cluster-scoped `ProviderHealth` |
| Reconciler | `internal/controller` | controller | yes (with the manager) | the state machine: provision, replace, delete, represent health |
| Provider contract | `internal/provider` | library | — | `Provider` interface, error taxonomy, deterministic naming, config, metrics decorator |
| Provider contract | `internal/provider` | library | — | `Provider` interface, optional `Checker`, error taxonomy, deterministic naming, config, metrics decorator |
| kubernetes provider | `internal/provider/kubernetes` | library | — | real Squid pods in this cluster (local dev/CI) |
| gcp provider | `internal/provider/gcp` | library | — | Compute Engine VMs, four API calls, fire-and-forget ops |
| kvm provider | `internal/provider/kvm` | library | — | VMs on libvirt hosts over SSH-forwarded sockets; image cache, overlay disks, NoCloud seeds |
@@ -14,6 +14,7 @@
| Lease store | `internal/lease` | Runnable (expiry sweep) | no | in-memory leases + cooldowns, single mutex |
| Discovery API | `internal/discovery` | Runnable | no | HTTP list/lease/release/report on `:8090` |
| Orphan GC | `internal/gc` | Runnable | yes | deletes tagged instances whose CR is gone |
| Provider health monitor | `internal/providerhealth` | Runnable | yes | periodic provider checks → one `ProviderHealth` per provider, target gauges, transition logs |
| Metrics | `internal/metrics` | library | — | explicit registration, scrape-time collectors |
| Composition root | `cmd/main.go` | binary | — | flags, provider registry, wires everything onto one manager |
@@ -306,6 +307,8 @@ registries). Two kinds:
- **Scrape-time collectors** — `proxy_operator_proxies{phase}` and
`proxy_operator_leases_active` read the cache / lease store at every
scrape; reconcile-incremented gauges inevitably drift and leak series.
The provider-check gauges (§11) read the health monitor's snapshot the
same way.
- **Fed vectors** — `healthcheck_duration_seconds{proxy}` and
`healthcheck_failures_total{proxy}` observe EVERY probe (status writes
are transition-only; metrics carry the high-frequency signal), and the
@@ -348,7 +351,8 @@ incoming trace context):
- Discovery API: one server span per request (named from the route
pattern), incoming W3C `traceparent` honored so clients' traces continue
into the operator; `/healthz` excluded.
- `gc.sweep` per GC pass; `health.probe` per probe **only** with
- `gc.sweep` per GC pass; `providerhealth.check` per round of provider
checks; `health.probe` per probe **only** with
`--trace-health-probes` (default off — probes run ~1/s per proxy).
Log correlation: logr sinks never see a context, so trace IDs ride on the
@@ -359,6 +363,63 @@ Re-deriving from the base rather than layering keeps zap from emitting
duplicate keys on nested spans. GCP V(5) wire logs get the same keys from
the slog handler's ctx.
### 11. Provider health checks (`internal/providerhealth/`)
Answers "can this cluster reach each provider's backend right now?"
without waiting for a Proxy to fail. A provider opts in by implementing
`provider.Checker`: `Check(ctx) []TargetStatus` returns one verdict per
target and never fails as a whole. All three providers do:
| Provider | Target | What one check does |
|---|---|---|
| kvm | each `kvm.hosts` entry | over the pooled SSH+libvirt connection: libvirt version and hostname, then the configured pool must exist and be running; reports pool capacity and free space ([details](kvm-provider.md#health-check)) |
| kubernetes | `apiserver` | lists at most one managed Pod cluster-wide: the verb and scope `ListByTag` and GC rely on |
| gcp | the project | one page of an aggregated instance list with `maxResults=1`: credentials, Compute API enabled, `compute.instances.list` |
```text
Monitor.Start(ctx) ── leader-only; --provider-check-interval (1m; 0 = off,
│ otherwise ≥ 10s), --provider-check-timeout (15s, or
│ the interval if that is shorter)
├─ once: delete managed-by-labelled ProviderHealth objects whose provider
│ is not configured (all of them when checks are off) — the providers
│ config is read once per process
└─ at start, then every Interval: checkOnce
│ every provider's Check concurrently, under one context.WithTimeout
│ snapshot → the scrape-time gauges
│ log transitions: down or reason changed → "WARNING: provider target
│ not ready"; back up → "Provider target ready"; steady → silent
└ per provider: APIReader.Get → Create if missing → status merge patch
```
- **One status write per provider per round**, since `lastCheckTime`
moves every round. At the 10s minimum interval that is still far below
leader-election renewals. Proxy status avoids writes like this only
because its probes run once a second per proxy.
- **Reads go through the API reader**, not the cache. The objects are
cluster-scoped; a cached Get would start a cluster-wide informer (and
need `watch`) even with `--proxy-namespace`.
- **Checks bypass the provider decorators.** `cmd/main.go` collects the
Checkers before wrapping the providers in `WithTracing(WithMetrics(...))`,
so a check never counts as a provider request. KVM RPCs still appear in
the V(1) `libvirt call` trace, and each round is one
`providerhealth.check` span.
- **Not wired to `/readyz`.** A dead hypervisor must not make the manager
unready while the other providers keep working.
- **Gauges** `proxy_operator_provider_target_up`,
`proxy_operator_provider_check_duration_seconds` and
`proxy_operator_provider_check_timestamp_seconds`, each labelled
`{provider,target}`. Only the leader exports them.
Illustrative output:
```sh
$ kubectl get providerhealth # short name: ph
NAME TYPE READY TARGETS LAST CHECK AGE
kubernetes kubernetes True 1/1 12s 3d
kvm-home kvm False 0/1 12s 3d
$ kubectl get ph kvm-home -o yaml # per target: reason, message, details
```
## Decisions
Judgment calls the spec left open, and deliberate deviations — recorded so
@@ -489,6 +550,15 @@ they read as choices, not accidents. Chronological by build step.
cluster-wide Secret read RBAC — an unrestricted cache would hold every
Secret in scope). Unlabelled referenced Secrets are invisible by
construction, surfacing as `CloudInitError`.
- **Deletion never gives up on its own, but can be released by hand:** a
permanent cleanup failure keeps retrying with the finalizer on, because
dropping it automatically would leak a running instance on any provider
that answers "permission denied". Where `Get` fails permanently the
reconciler still calls `Delete`, since it is idempotent and may finish
where `Get` cannot converge. `crawl.example.com/skip-cleanup: "true"`
abandons the instance and releases the object; it is checked before the
provider lookup, so it also frees a Proxy whose provider is gone from the
config.
- **Events RBAC from the plan is omitted:** nothing wires an
EventRecorder in the prototype, and granting verbs nothing uses would
be RBAC lint noise. Add the marker together with the recorder if events
@@ -519,3 +589,27 @@ they read as choices, not accidents. Chronological by build step.
external sites. The kube-apiserver ignores incoming `traceparent` by
design (public endpoint), so k8s client spans are leaves — in-process
traces, not cross-process ones.
- **Provider health is a status-only, cluster-scoped CRD** (user
decision). `kubectl get providerhealth` works without Prometheus, which
the deployment does not ship (its ServiceMonitor is commented out). There
is no spec: the providers config file stays the one place providers are
configured, and the object only reports on it. The gauges carry the same
verdicts for alerting.
- **KVM checks reuse the pooled connection instead of dialing fresh.** A
fresh dial would not pick up a rotated Secret anyway, since the SSH
material is cached. It would also open an sshd session per host per
minute. And a working cached connection is exactly what reconciles use.
Sharing must not cost the reconciles anything, so the check calls
through `hostPool.probe`. A check that runs out of time abandons its
RPCs instead of closing the connection, which would kill an upload or a
Create running on it. At most one abandoned check per host is left
waiting, and until it returns, the next check reports `Timeout` at once.
Waiting for a host's connection slot also ends with the caller's
deadline, so a reconcile redialing a dead host cannot stall the round.
- **Provider transitions are logs, not Events**, in line with the
EventRecorder decision above. They log at Info with a `WARNING:` prefix,
and each target's `lastTransitionTime` in the object records when it
flipped.
- **A provider name that is not a valid object name** gets metrics but no
`ProviderHealth` object, with a warning at start. Tightening config
validation instead would crash-loop existing deployments on upgrade.
+114 -16
View File
@@ -27,11 +27,12 @@ Per host, once:
`edk2-ovmf` on Fedora/RHEL. VMs boot UEFI by default (libvirt picks
the OVMF binary itself); a domain start fails with "no firmware
found" without it. Only `firmware: bios` images skip this.
6. Optionally, **outbound internet from the VMs**: the default
vendor-data installs `qemu-guest-agent` from the distro mirrors on
first boot. Without it the VM still works — the operator falls back to
the host's ARP table to find the IP, which fills in once the guest
sends traffic.
6. Optionally, **outbound internet from the VMs**: a vendor-data script
installs `qemu-guest-agent` from the distro mirrors on first boot.
Without it the VM still works — the operator falls back to the host's
ARP table to find the IP, which fills in once the guest sends traffic —
but the ARP table only sees traffic to the host itself, so on a plain
bridge the agent is in practice required.
Check from the operator's point of view before wiring it in:
@@ -76,9 +77,10 @@ ssh virt@nas1.home.lan ip link show br0
`/etc/proxy-operator/kvm` as two files, `id_ed25519` and
`known_hosts`, which is what `sshKeyFile` and `knownHostsFile` in the
provider config point at. The deployment starts fine without the
Secret as long as no `kvm` provider is configured. Rotating the key:
re-run the command (or the make target) and restart the manager; it
reads both files once at startup.
Secret, even with a `kvm` provider configured: only that provider's
proxies fail, and they recover once the Secret exists. Rotating the
key: re-run the command (or the make target) and restart the manager;
both files are read once, on the first connection, and cached.
2. Add a provider entry to the providers config
(`config/manager/providers_config.yaml` in-cluster; full sample in
@@ -102,9 +104,13 @@ ssh virt@nas1.home.lan ip link show br0
bridge: br0
```
The operator reads the key and known_hosts at startup and refuses to
start if either is missing — a bad path fails fast, not on the first
Proxy.
The operator checks both files at startup and logs a `WARNING:` line
when either is unreadable, but it does not refuse to start: the
provider set is built all-or-nothing, so exiting here would crash-loop
the manager and take the `kubernetes` and `gcp` providers with it.
The files are read for real on the first connection. A missing file is
a transient failure, so proxies heal by themselves once the Secret
lands; a key that does not parse is permanent and needs a fix.
3. Apply a Proxy (`config/samples/proxy_kvm.yaml`) and watch it:
@@ -129,13 +135,15 @@ ssh virt@nas1.home.lan ip link show br0
| `knownHostsFile` | unless `insecureIgnoreHostKey` | | OpenSSH `known_hosts` to verify host keys against |
| `insecureIgnoreHostKey` | | `false` | skip host-key verification entirely |
| `diskSizeGb` | | `10` | capacity of each VM's overlay disk; cloud-init's growpart expands the root fs into it |
| `vendorData` | | installs `qemu-guest-agent` | cloud-init vendor-data placed on every seed next to the Proxy's user-data |
| `vendorData` | | | extra cloud-init vendor-data placed on every seed next to the Proxy's user-data. Beware that cloud-init drops any top-level key of a vendor-data **cloud-config** that the user-data also defines. Shipped as one part of a MIME multipart alongside the agent script when `installGuestAgent` is on |
| `installGuestAgent` | | `true` | add the vendor-data script that installs and starts `qemu-guest-agent`. Turn off for images that ship it already or cannot reach a package mirror; IP discovery then depends on the host's ARP table |
| `images.<key>` | | | the key names the image in `placement.image`. Letters, digits, dots, dashes and underscores only — it becomes the cached volume name `base-<key>.<format>` |
| `images.<key>.url` | yes per entry | | http(s) URL of a cloud image (qcow2 or raw) |
| `images.<key>.sha256` | | | expected digest; verified after download, a change triggers a re-download |
| `images.<key>.format` | | `qcow2` | `qcow2` or `raw` |
| `images.<key>.firmware` | | `uefi` | `uefi` (OVMF, secure boot off) or `bios` (SeaBIOS) for images without an EFI system partition; Debian 13 hangs in GRUB under BIOS on a headless VM, see *Known gaps* |
| `machineTypes.<key>` | ≥ 1 | | `{vcpus, memoryMiB}` size profile |
| `hosts[].name` | yes | | the `placement.zone` value that selects this host; unique |
| `hosts[].name` | yes | | the `placement.zone` value that selects this host; unique. Letters, digits, dots, dashes and underscores only — it becomes part of the providerID `hosts/<name>/domains/<vm>` |
| `hosts[].address` | yes | | SSH endpoint, `host:port` |
| `hosts[].user` | yes | | SSH user, member of the `libvirt` group |
| `hosts[].socket` | | `/var/run/libvirt/libvirt-sock` | libvirt socket path on the host |
@@ -174,6 +182,28 @@ console, the guest-agent channel, a virtio RNG and no graphics. Its
creation time) — that is how orphan GC recognises its own VMs, since
libvirt has no labels.
The seed's `user-data` is the Proxy's `cloudInit`, **byte for byte**. Any
format cloud-init accepts works, including a Jinja template, a MIME
archive, a shell script or a gzipped payload; the operator never parses or
re-renders it.
The guest agent rides along in `vendor-data`, as a **shell script** rather
than a cloud-config. That is the only shape cloud-init cannot drop:
vendor-data cloud-config is merged under the user's own document with
mergers that keep the user-data's value for every key both define and never
append lists, so a Proxy carrying its own `packages:` silently lost the
agent and sat in `Provisioning` forever. The strategy cannot be overridden
from either document — `merge_how` in user-data governs only the first
source, and `merge_how` in vendor-data is consumed and discarded before the
document is stored. A vendor-data script skips all of it: cloud-init gives
vendor-data its own handler chain, writes scripts to a separate directory
and runs them from `cc_scripts_vendor`, after the user's packages are
installed and before the user's `runcmd`. The script is idempotent, picks
whichever of `apt-get`, `dnf`, `yum`, `zypper` or `apk` the image has, and
logs into `/var/log/cloud-init-output.log`. Set `installGuestAgent: false`
to leave it out. When `vendorData` is also configured, the two travel as
separate parts of a MIME multipart vendor-data.
## The image cache
The base image is downloaded **by the operator, through libvirt**, into
@@ -235,18 +265,79 @@ The provider logs through controller-runtime's logger, named `kvm`, with
only `length` and `duration`. There is no raw wire tier like the GCP
provider's V(5); go-libvirt exposes no hook for it.
## Health check
The operator checks every configured host every `--provider-check-interval`
(default 1m). It publishes the result in two places: the `ProviderHealth`
object named after the provider (`kubectl get ph kvm-home -o yaml`), and the
`proxy_operator_provider_target_up{provider,target}` gauge.
All hosts are checked concurrently, each over the same pooled connection
that Create and Get use:
1. The SSH and libvirt handshakes. These only happen on the first check, or
after the link dropped.
2. `virConnectGetLibVersion` and `virConnectGetHostname`.
3. `virStoragePoolLookupByName` and `virStoragePoolGetInfo` on the host's
`pool`.
A passing target's `details` carry `hostname`, `libvirtVersion`, `pool`,
`poolState`, `poolCapacity` and `poolAvailable` (in GiB). The free space is
the closest thing this provider has to a capacity signal. Every target also
shows its SSH `user`. A failing target says where the check broke:
| Reason | Meaning | Look at |
|---|---|---|
| `SSHMaterialUnavailable` | the key or known_hosts file is missing or does not parse | the `kvm-ssh` Secret (see *Cluster-side setup*) |
| `Unreachable` | the TCP connect failed, or the link died mid-check | DNS, Tailscale or a firewall between the cluster and `address` |
| `SSHHandshakeFailed` | the host key is missing from known_hosts or has changed, or `user` rejected the key | the known_hosts entry; `user`'s `authorized_keys` |
| `LibvirtConnectFailed` | SSH worked, the libvirt socket did not | `user` in the `libvirt` group, `libvirtd` running, the `socket` path |
| `Timeout` | no answer within `--provider-check-timeout` (default 15s), or the previous check of the host is still waiting | a hung `libvirtd`, a saturated link, or a reconcile stuck redialing the host |
| `PoolNotFound` | there is no pool named `pool` | `virsh pool-list --all` |
| `PoolNotActive` | the pool exists but is not running | `virsh pool-start <pool>` and `virsh pool-autostart <pool>` |
| `LibvirtError` | any other libvirt error | the message carries libvirt's own text |
Sharing the connection has two consequences:
- The leader now keeps one SSH connection per host open from startup, with a
keepalive every 15s. Before, the first connection opened with the first KVM
reconcile.
- A check that runs out of time gives up on its RPCs but leaves the
connection alone, so an image upload or a Create running on it is not cut
off. At most one abandoned check per host keeps waiting; until it
returns, the next check reports `Timeout` straight away. A link that is
really dead is closed by the keepalive within about two keepalive
intervals.
- A check never waits longer than its timeout for the host's connection,
even while a reconcile holds it to redial a host that is down.
## Known gaps
- **No quota or capacity detection.** libvirt reports an out-of-space
pool or an over-committed host as an ordinary error; the reconciler
treats it as transient and retries with backoff rather than the slower
quota backoff.
quota backoff. The health check does report each pool's free space
(`poolAvailable`), so a pool that is filling up shows before Creates
start failing.
- **A rotated `kvm-ssh` Secret needs a manager restart.** The key and
known_hosts files are read on first use and cached for the life of the
process. Replacing them in the Secret therefore changes nothing for
later connections, health checks included. After a host key change, the
check keeps reporting `SSHHandshakeFailed` until the manager restarts.
- **Orphan volumes without a domain are invisible to GC.** A crash
between creating the volumes and defining the domain leaves an overlay
and seed that `ListByTag` cannot see (it lists domains). They are
cleaned when the same Proxy reconciles again (Delete removes volumes by
name even with no domain), but a Proxy CR that is gone for good leaves
them for `virsh vol-list` to find.
- **A host removed from the config takes its VMs with it.** `Delete` for a
providerID naming an unconfigured host is a no-op success, so the Proxy
finishes deleting instead of pinning its finalizer, but the domain and its
volumes stay on the hypervisor and orphan GC cannot see them either (it
only sweeps configured hosts). Delete the Proxies before retiring a host,
or clean up afterwards with `virsh undefine --nvram` plus `virsh vol-delete`.
`Get` still fails permanently for such a providerID on purpose: a typo in
`hosts` must not look like a missing VM and trigger a rebuild elsewhere.
- **The first image fetch is synchronous.** It holds a reconcile worker
for the download (LAN speed, a few hundred MB, once per image per
host). Other Proxies keep reconciling on the remaining workers.
@@ -254,7 +345,12 @@ The provider logs through controller-runtime's logger, named `kvm`, with
under *The image cache*.
- **IP discovery needs either guest traffic or the agent.** A VM on a
bridge with no DHCP never gets an address and stays `Provisioning`
forever; check `virsh console`.
forever; check `virsh console`. The same symptom with a booted, addressed
VM means the guest agent never came up: check `systemctl status
qemu-guest-agent`, `cloud-init status --long` and the vendor script's
output in `/var/log/cloud-init-output.log` (a VM without outbound
internet cannot install it). A user-data that disables vendor-data
(`vendor_data: {enabled: false}`) also removes the agent, by design.
- **Debian 13 cloud images hang in GRUB under BIOS on a headless VM**
([Debian bug #1111240](https://bugs.debian.org/1111240), open, in
`grub2`): trixie's image ships `terminal_output gfxterm serial` in its
@@ -272,4 +368,6 @@ Unit tests run over an in-memory fake of the libvirt API. The optional
integration test against a real host is described in
[testing.md](testing.md#the-kvm-integration-test-real-hypervisor); its
`-v` output includes the `libvirt call` trace above, at the verbosity
`KVM_TEST_LOG_LEVEL` selects (default 1).
`KVM_TEST_LOG_LEVEL` selects (default 1). `TestKVMCheck_realHost` runs the
health check against the same host and creates nothing:
`go test -tags=integration -count=1 -v -run TestKVMCheck ./internal/provider/kvm/`.
@@ -10,7 +10,7 @@ Plan: [2026-09-04-1404-kvm-provider.md](../plans/2026-09-04-1404-kvm-provider.md
- [x] Step 4 — Provider methods (`kvm.go`) over a fake API
- [x] Step 5 — Wiring, manifests, samples, docs, CHANGELOG
- [x] Step 6 — Integration test against a real hypervisor (written and compiled; first real run is part of Step 7)
- [ ] Step 7 — Manual validation + MR
- [x] Step 7 — Manual validation + MR
## Step 0 — Save plan + branch
@@ -557,3 +557,117 @@ on the host it needs `virsh undefine --nvram` plus its two volumes by
hand. The `ListByTag` sweep costs one `virDomainGetMetadata` per
domain on the host (21 here, ~0.4 ms each over the tunnel), which is
fine at this scale but is the call to watch on a busy hypervisor.
### Run 7: in-cluster, two defects the integration test could not see
The e2e pass ran the operator inside the kind cluster
(`egress-proxies-operator:kvm-dev`, built with `make docker-build`,
loaded with `kind load docker-image`, deployed via the kustomize
`config/manager` with the `kvm-home` provider pointing at beelink) and
two Proxies with real cloud-configs: one plain squid, one with a debug
login user. Both defects come from the difference between the test
harness and the real thing, not from libvirt.
**Read-only root filesystem.** The manager runs with
`readOnlyRootFilesystem: true`, and `iso9660.NewWriter` stages the seed
in a temp directory, so the first Create failed with `creating iso
writer: read-only file system`. Fix: a 64 MiB `emptyDir` on `/tmp` in
`config/manager/manager.yaml`. Side finding: the error is classified
`ErrPermanent` by `writeSeed`, yet `hostPool.call` treated any
non-libvirt error as a transport failure and dropped the SSH connection,
so every retry redialled. `call` now leaves the connection alone for
errors already carrying a provider sentinel (`isClassified`), with a
test.
**The guest agent never got installed.** Both VMs booted (confirmed on
the console by hand) but sat in `Provisioning` for 25 minutes: the
trace showed `virDomainInterfaceAddresses` from the agent source failing
with `ErrAgentUnresponsive` on every poll and the ARP source empty. The
integration test had passed with `CloudInit: "#cloud-config\n"`; the
real Proxies have `packages: [squid]` and `runcmd:`. cloud-init merges
vendor-data under user-data with its default mergers, which keep the
user-data's value for any key both define and never append lists, so the
vendor-data's `packages`/`runcmd` were dropped wholesale. Confirmed
locally with the dev box's cloud-init 26.1 before changing anything:
```sh
python3 - <<'EOF'
from cloudinit import mergers
user = {"packages": ["squid"], "runcmd": ["systemctl restart squid"]}
vendor = {"packages": ["qemu-guest-agent"], "runcmd": [["systemctl", "enable", "--now", "qemu-guest-agent"]]}
print(mergers.construct(mergers.default_mergers()).merge(dict(user), dict(vendor)))
EOF
# {'packages': ['squid'], 'runcmd': ['systemctl restart squid']}
```
Fix: `withGuestAgent` in `seed.go` merges the bootstrap into the Proxy's
cloud-config itself (append to `packages` and `runcmd`, skip if present,
re-render with `sigs.k8s.io/yaml`), so vendor-data is no longer defaulted
and `vendorData` is plain extra data. User-data that is not a cloud-config
document (script, `#include`, MIME) still gets the bootstrap as
vendor-data since there is nothing to collide with; a cloud-config that
does not parse as a mapping fails the Proxy as `ErrPermanent`. The
rendered document for the squid Proxy passed `cloud-init schema
--config-file`. The logging test's fixture (`#cloud-config` + a bare
sentinel line) is not a mapping and had to become `hostname: <sentinel>`.
Rebuilt, reloaded, restarted, deleted and recreated both Proxies: both
`Ready`/`HEALTHY True` in 2 min 1 s from `kubectl apply`, IP via the
agent 110 s after `virDomainCreate` (the guest installs squid and the
agent from the mirrors first), and `curl -x http://<ip>:3128
https://www.gstatic.com/generate_204` from the dev box returns 204
through both.
Worth noting: the `#cloud-config` round-trip drops comments and reorders
keys alphabetically; semantics are preserved (numbers, booleans and
block scalars survive, checked in tests) but a user diffing the seed
against their CR will see a different document. Vendor-data supplied via
`vendorData` is subject to the same cloud-init merge rule and is now
documented as such. The kind pod also restarted twice during the session
from "Pod sandbox changed" (the node briefly lost the API server), which
is unrelated to the operator. Still uncommitted on purpose: the
`kustomization.yaml` image override (`egress-proxies-operator:kvm-dev`)
and the `beelink` host entries in `config/manager/providers_config.yaml`
and `config/samples/providers-config.yaml`, which are this machine's dev
setup rather than the repo's defaults.
### Run 8: review fixes
A `/code-review high` pass over the branch found five defects, fixed in
[plan 2026-09-10-2222](../plans/2026-09-10-2222-kvm-review-fixes.md) with
its own execution log. Two of them land on Run 7's conclusions above and
supersede them:
- The guest-agent **merge described in Run 7 is gone**. It could not be made
safe for user-data that is not a plain cloud-config, and its YAML
round-trip re-typed unquoted scalars. The bootstrap now ships as a
vendor-data shell script, which cloud-init never merges, and the Proxy's
cloud-init reaches the seed byte for byte.
- The read-only-filesystem fix from Run 7 still stands, but the eager SSH
key load it sat next to does not: a missing `kvm-ssh` Secret aborted the
whole provider set and crash-looped the manager. The material is read on
first use now.
Re-validated the same way: integration test against beelink, both Proxies
recreated in kind, 204 through squid.
### MR opened
[PR #5](https://gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/pulls/5),
`feat/kvm-provider` → `main`, 19 commits. `tea` is not installed on the
dev box and the HTTPS remote has no credential helper, so the push used a
one-off helper reading a Gitea access token from `~/.config/gitea-token`,
and the MR was created with the API instead of `tea pr create`:
```sh
git -c credential.helper='!f() { echo username=kacerr; echo "password=$(tr -d "[:space:]" < ~/.config/gitea-token)"; }; f' \
push -u origin feat/kvm-provider
curl -s -H "Authorization: token $(tr -d '[:space:]' < ~/.config/gitea-token)" \
-H 'Content-Type: application/json' -d @pr.json \
https://gitea.home.hrajfrisbee.cz/api/v1/repos/kacerr/egress-proxies-operator/pulls
# pr.json: {"title": ..., "head": "feat/kvm-provider", "base": "main", "body": ...}
```
The push printed a libsecret "store failed" warning from the system
credential store; harmless, the token was not persisted anywhere but the
file.
@@ -0,0 +1,229 @@
# Execution: fix the five code-review findings on `feat/kvm-provider`
Plan: [docs/plans/2026-09-10-2222-kvm-review-fixes.md](../plans/2026-09-10-2222-kvm-review-fixes.md)
- [x] Step 0 — Save plan into the repo
- [x] Step 1 — Unwedge deletion (finding 1)
- [x] Step 2 — Validate names that become identifiers (finding 2)
- [x] Step 3 — Read the SSH material lazily (finding 3)
- [x] Step 4 — Guest agent via vendor-data script (findings 4 and 5)
- [x] Step 5 — Validate, record, push
## Step 0 — plan committed
Copied out of `~/.claude/plans/` and committed alone before touching code, as
the repo's plan rules require.
## Step 1 — Unwedge deletion
Three changes, in the order the bug unfolds.
`reconcileDelete` calls `Get` first and only drops the finalizer when it
answers not-found, so any permanent error there retried forever. It now
switches on the class: a permanent `Get` still calls `Delete`, which is
idempotent, and releases the object when that succeeds. Polling a `Get`
that cannot converge was the wedge; `Delete` is the only call that can
still make progress.
`crawl.example.com/skip-cleanup: "true"` abandons the instance and removes
the finalizer. It is checked **before** the provider lookup, which also
frees a Proxy whose provider was deleted from the config — a second wedge
sitting three lines above the reported one, with the same shape. No
`EventRecorder` exists on the reconciler and none was added, so the
abandonment is an Info log; `docs/architecture.md` already records that
decision for the whole controller.
In the KVM provider, `Delete` for a providerID naming an unconfigured host
now logs and returns success. The precedent is two functions further down:
`removeVolumes` treats a missing pool the same way, with a comment saying
outright that refusing "would pin the Proxy's finalizer".
**`Get` deliberately still fails permanently for that case.** Making it
answer not-found would have been a one-line fix for the wedge, but the live
reconcile path reads not-found as "the VM is gone" and rebuilds it
elsewhere. A typo in `hosts` would then quietly duplicate every Proxy on
that host, with the originals still running where GC cannot see them, since
`ListByTag` only sweeps configured hosts. The asymmetry is now pinned by a
test that asserts both halves at once.
## Step 2 — Validate names that become identifiers
One regexp, `^[A-Za-z0-9][A-Za-z0-9_.-]*$`, applied to `hosts[].name` and to
the `images` map keys. Dots stay legal because a host name like
`nas1.home.lan` is ordinary and harmless; the character that actually
breaks things is `/`.
The review only reported the host name. The image key has the same defect
and is arguably worse: it is not merely embedded in an ID but pasted into a
file name, `base-<key>.<format>`, so `../etc/x` would be a path traversal
into the storage pool rather than a parse failure. Both are validated in the
same pass. Machine-type keys are left alone deliberately — they are only
ever compared against `placement.machineType`, never embedded anywhere.
Worth noting: this is validation of the operator's own config file, which
only a cluster admin can write, so the traversal was never a privilege
boundary. It is still cheaper to reject at load than to explain a stuck
Proxy later. Both new table cases assert the message text, so the wording
that names the consequence is now pinned by tests.
## Step 3 — Read the SSH material lazily
`kvm.New` no longer loads the key; `lazyAuth` does, on the first connection,
caching the result once it works. `registry.Build` is all-or-nothing by
design, so the old constructor error did not merely fail the KVM provider,
it exited the manager before any controller started.
The plan said "lazy dialer", and the first attempt was exactly that — one
closure wrapping both the loading and the dial. It could not be tested: the
fixture's SSH server is real, but the socket behind it is an echo server, so
every successful dial dies in the libvirt handshake and the assertion could
not tell "the key worked" from "the key did not". Splitting the seam in two,
`lazyAuth` for the material and `lazyDialer` for the wiring, made both
testable: the healing path against `lazyAuth`, and a separate test that the
dialer really authenticates, asserted by the error being past SSH and by the
server having seen the forward request.
Error classes carry the intent. A missing file stays unclassified, so
`provider.Class` defaults it to transient and a Proxy heals on its own when
the Secret appears — the common case, since the Secret is usually created
after the manager. A key that does not parse wraps `ErrPermanent`, because
retrying cannot fix it. `newSSHAuth` distinguishes the two with
`errors.Is(err, fs.ErrNotExist)`.
Worth noting: the startup check is kept, just non-fatally, logged with the
`WARNING:` prefix the repo already uses where logr has no warn level. That
preserves the diagnostic the eager version was written for. The caching test
deliberately deletes the key file after a successful load and asserts the
cached value is reused, which pins the rotation behaviour the docs promise:
a rotated Secret needs a manager restart. `TestNew_badKeyPathFailsFast` was
inverted rather than deleted, so the old contract stays visible in the diff
next to the new one.
## Step 4 — Guest agent via vendor-data script
The review filed two findings against `withGuestAgent` — detection too
narrow, and YAML re-typing on the round-trip. Reading cloud-init 26.1's
sources while planning turned both into one finding: the operator should
not be editing the user's document at all.
Vendor-data gets its own handler chain (`stages.py:_default_vendordata_handlers`),
with separate script and cloud-config paths. A vendor-data **script** is
therefore written to the vendor script directory and run by
`cc_scripts_vendor`, never touching the config merger that drops colliding
keys. On the final-stage module list it lands after
`package_update_upgrade_install` and before the user's `runcmd`, so the
agent now comes up *earlier* than the old `runcmd` append did.
That also closed the door the previous approach had left ajar. The merge
cannot be rescued from inside either document: `merge_how` in user-data
governs only the first source, and `merge_how` in vendor-data is popped by
`dict_extract_mergers` and discarded before the document is written. There
was no fix that kept the merge.
`withGuestAgent`, `isCloudConfig` and `appendUnique` are gone, along with
seed.go's `sigs.k8s.io/yaml` dependency. `installGuestAgent` (default true)
turns the script off. When `vendorData` is also configured the two ship as
a MIME multipart, with the user's document declared `text/plain` — a type
cloud-init re-sniffs from the content's own header line, so this code never
classifies what the user wrote.
Before deleting the old path, its damage was captured rather than taken on
trust. Round-tripping a plausible cloud-config through it:
```sh
# temporary test in the package, deleted afterwards
go test -run TestZZProbeRetyping -v ./internal/provider/kvm/
# in: version: 1.10
# out: version: 1.1
```
`1.10` arriving as `1.1` is worse than the reviewer's `passwd` example,
since nothing about it looks wrong in the seed. Comments were dropped and
keys reordered on the same pass.
The generated vendor-data was then checked against the cloud-init actually
installed on this machine, rather than against the docs:
```sh
python3 -c 'from cloudinit import user_data, handlers; ...'
# vendor-script: multipart=False sniffed=text/x-shellscript
# vendor-multipart: multipart=True part 1 text/plain -> text/cloud-config
# part 2 text/x-shellscript
```
and the script itself through `sh -n`.
Worth noting: the script has to choose a package manager where cloud-init's
`packages:` key was distro-agnostic. It covers apt, dnf, yum, zypper and
apk, and both systemd and OpenRC for the enable step, but an image outside
that set now needs `installGuestAgent: false` and the agent baked in. The
seed's user-data is byte-identical to the Proxy's `cloudInit` from here on,
which is what the new `TestBuildSeedISO_userDataIsNeverRewritten` pins
across seven formats. One consequence worth remembering: a user-data that
sets `vendor_data: {enabled: false}` disables the agent, and cloud-init
documents that as intended behaviour.
## Step 5 — Validation
Unit suite, `go vet` and `gofmt` clean across the repo. Then the two real
runs.
**Integration against beelink**, unchanged environment from the KVM plan's
log, passed in 22 s on a warm cache, with
`virDomainInterfaceAddresses source=DomainInterfaceAddressesSrcAgent
interfaces=2` — the agent installed by the new vendor script answering, on
a stock Debian 13 image.
**In-cluster**, both Proxies deleted and recreated against the rebuilt
image: Ready with an address 2 min 3 s after `kubectl apply`, 204 through
squid from the dev box for both. Inside the debug VM:
```sh
ssh novakj@192.168.0.94 'systemctl is-active qemu-guest-agent; cloud-init status; \
sudo head -4 /var/lib/cloud/instance/user-data.txt; sudo ls /var/lib/cloud/instance/scripts/vendor/'
# active
# status: done
# #cloud-config
# # Login for debugging: user "novakj", password "kvm-dev", passwordless sudo.
# ...
# part-001
```
Two things worth having seen with your own eyes there: the user's **comment
lines survived** into the guest, which the old merge stripped, and the
script really did land in the vendor directory rather than being merged
into the user's config.
**Negative check for the Secret**, done by pointing the deployment's volume
at a name that does not exist rather than deleting the real Secret:
```sh
kubectl -n egress-proxies-operator-system patch deploy egress-proxies-operator-controller-manager \
--type=json -p '[{"op":"replace","path":"/spec/template/spec/volumes/1/secret/secretName","value":"kvm-ssh-absent"}]'
```
The manager came up and stayed up, 0 restarts, logging
`WARNING: kvm ssh material is not usable ... open /etc/proxy-operator/kvm/id_ed25519:
no such file or directory`. The Kubernetes Proxies kept reconciling; the
KVM ones failed per reconcile and retried. Before this branch the same
config exited the process before any controller started. Patched back
afterwards, everything Ready.
Worth noting: the KVM Proxies kept reporting `Ready` during that window
rather than degrading visibly. That is existing behaviour, not a
regression — a transient provider error rides the workqueue and does not
clear a healthy status — but it does mean the only signal of a missing
Secret is the log and the reconcile errors. Also, the ListByTag sweep on
beelink still reports `managed=2` where one domain is an orphan from an
older failed run; unrelated to this work, and the same leftover noted in
the KVM plan's Run 6.
Worth noting: the review filed this as a KVM finding, but the fix is mostly
in the controller, because KVM is the only provider that resolves a
providerID segment against operator config at all. GCP and Kubernetes hand
the lookup to the backend, which answers 404 for a retired zone or a deleted
namespace, so they reach not-found and release on their own. Anything else
that classifies permanently on the delete path — revoked RBAC on the
Kubernetes provider is the realistic one — was equally stuck before this
step and is what the annotation is really for. The KVM table case
`"host not configured"` moved out of `TestDelete_errors` into its own named
test, since it is no longer an error.
@@ -0,0 +1,378 @@
# Execution: provider health checks
Plan: [docs/plans/2026-09-11-1526-provider-health-checks.md](../plans/2026-09-11-1526-provider-health-checks.md)
- [x] Step 0 — Save the plan
- [x] Step 1 — ProviderHealth API and CRD
- [x] Step 2 — Contract plus the kubernetes and GCP checks
- [x] Step 3 — KVM check
- [x] Step 4 — Monitor
- [x] Step 5 — Metrics and wiring
- [x] Step 6 — Docs
- [x] Step 7 — Validate, record, push, open the PR
## Step 0 — Save the plan
Branched off `main` (at the PR #5 merge, `eb8f075`). The plan was copied out of
`~/.claude/plans/` and committed on its own, before any code, as the repo's plan rules
require:
```bash
git checkout -b feat/provider-health main
cp ~/.claude/plans/now-i-would-like-flickering-wall.md docs/plans/2026-09-11-1526-provider-health-checks.md
```
The uncommitted `.claude/settings.json` change was left unstaged and stays out of this
branch.
## Step 1 — ProviderHealth API and CRD
`kubebuilder` isn't installed here (only `bin/controller-gen`, `bin/kustomize` and
`bin/setup-envtest` are), so `api/v1alpha1/providerhealth_types.go` and the `PROJECT`
entry were written by hand instead of with `kubebuilder create api`. For a cluster-scoped
resource `PROJECT` simply omits `namespaced`, which is what kubebuilder itself writes.
The new types are registered in the existing `AddKnownTypes` call in `proxy_types.go`,
not in a second `init()`.
The deepcopy code, the CRD and the RBAC come from the usual generators:
```bash
make generate manifests
go build ./... && go vet ./api/... && go test -short ./api/...
```
To confirm the new CRD and the viewer role actually reach the rendered install, I built
the kustomization and grepped for them:
```bash
bin/kustomize build config/default | grep -n providerhealth
# providerhealths.crawl.example.com (scope: Cluster, shortName ph)
# egress-proxies-operator-providerhealth-viewer-role
```
Worth noting:
- The only role added is the viewer role. The operator is the object's only writer, so
the admin and editor roles kubebuilder scaffolds for Proxy were not added.
- The type file has no unit test, since it has no logic. The schema, including the
required `ready`/`lastTransitionTime` and the list-map keys, is proven by the envtest
round-trip in Step 4.
- `config/rbac/role.yaml` is unchanged so far. The manager's own permissions arrive in
Step 4 with the Monitor's RBAC markers.
## Step 2 — Contract plus the kubernetes and GCP checks
`provider.Checker` is an optional interface in `internal/provider/check.go`, kept out of
`Provider`. Each implementation has a compile-time assertion, because the monitor's
type assertion would otherwise fail silently.
- **kubernetes:** lists at most one managed Pod cluster-wide, using the same client, verb
and scope as `ListByTag`. The target is `apiserver`, at the `restCfg.Host` now stored
in the new `apiHost` field.
- **GCP:** calls a new seam method, `Probe`. `realInstances.Probe` lives in `check.go`,
next to its only caller. `ListByTag`'s `Sprintf` filter became the shared
`managedFilter` constant, so the check and the GC sweep can't drift apart.
Before relying on "MaxResults is only a page size", I checked the SDK's REST
`AggregatedList` in the module cache:
```bash
grep -n "func (c \*instancesRESTClient) AggregatedList" -A80 \
"$(go list -m -f '{{.Dir}}' cloud.google.com/go/compute)"/apiv1/instances_client.go
# InternalFetch: req.MaxResults = pageSize ... return elems, resp.GetNextPageToken(), nil
```
```bash
go build ./... && go vet ./internal/provider/... && go test -race -count=1 ./internal/provider/...
```
Worth noting:
- The planned `TestRealInstances_Probe_fetchesOnePage` became a three-case
`TestRealInstances_Probe` table.
- It keeps the first page's `nextPageToken`, and asserts exactly one request with
`maxResults=1` for each case.
- "empty project" covers `iterator.Done`.
- "api error keeps its status" proves the real client returns a `*googleapi.Error`, so
a disabled Compute API is reported as `Forbidden` rather than `Unreachable`.
- Once a context has ended, both checks report `Timeout` whatever the error says. The
monitor's timeout is the usual cause, and the underlying error only repeats it.
## Step 3 — KVM check
`conn.go` now wraps each of the four dial stages in a `dialError`: ssh-material, tcp,
ssh-handshake and libvirt-connect. `Error()` returns the inner message unchanged and
`Unwrap()` returns the cause, so `classify`/`isClassified` and every existing
message-matching test behave as before. `TestDialError_keepsClassification` pins this
down.
The three new RPCs went into `libvirtAPI`, `tracingAPI` and `fakeAPI`. The method-count
guard in `TestTracingAPI_logsEveryMethod` fails until a traced row exists for each new
method, which is what forced the three new rows.
`Check` runs one goroutine per host, over the shared connection, through
`hostPool.call`. The pool verdict is decided only after `call` returns, because
returning it as an error from inside the callback would make `call` drop a perfectly
good connection.
Build, vet (also with the integration tag, since that file changed) and race tests:
```bash
go build ./internal/provider/... && go vet ./internal/provider/kvm/ \
&& go vet -tags=integration ./internal/provider/kvm/ \
&& go test -race -count=1 ./internal/provider/kvm/
```
Worth noting:
- **Pool state wording:** `poolState` uses virsh's words (`running`, `inactive`, …)
rather than go-libvirt's constant names, so it reads like `virsh pool-list` output.
- **Details:** each host also reports its SSH `user`. That is the first thing to check
when the reason is `SSHHandshakeFailed`.
- **Tests renamed or merged:**
- The planned `TestSSHAuth_dial_stages` is `TestLazySSH_dial_tagsTheFailingStage`. It
dials through `lazySSH`, so the ssh-material stage is covered as well as the three
stages the in-process SSH server can trigger.
- The pool-inactive and pool-missing cases are merged into `TestCheck_libvirtVerdicts`,
which also covers a daemon error. Each case runs two checks and asserts a single
dial.
- **Tests added beyond the plan:**
- `TestCheck_timeout`: a daemon that stops answering gives `Timeout`.
- `TestDialError_keepsClassification`.
- **Not run yet:** `TestKVMCheck_realHost` in the integration file only compiles here.
It needs beelink, so it goes on the Step 7 checklist for the user to run.
## Step 4 — Monitor
`internal/providerhealth` has the `Monitor` runnable plus the pure `buildStatus`.
- **Scheduling:** the Monitor is leader-only. It cleans up once at start, checks
immediately, then checks on a ticker.
- **Each round:** providers are checked concurrently under one timeout, then logged and
published one after another. A failed write is logged, and the next round writes
again.
- **Reads:** objects are read through the reader it is given. `main.go` passes the
manager's API reader (Step 5).
The first `make manifests` left `config/rbac/role.yaml` unchanged, with no error.
I had put the `+kubebuilder:rbac` markers inside `Monitor`'s doc comment, and
controller-gen only reads RBAC markers from package-level comment groups. So they were
silently attached to the type and ignored. An empty `git diff config/rbac/role.yaml` was
the only sign. Moving them into their own comment block fixed it:
```bash
make manifests && git diff config/rbac/role.yaml
# + providerhealths: create, delete, get, list
# + providerhealths/status: get, patch
```
Vet, race tests and the envtest round-trip (using the `bin/k8s/1.36.2-linux-amd64`
binaries `make setup-envtest` had already fetched):
```bash
go vet ./internal/providerhealth/ && go test -race -count=1 -v ./internal/providerhealth/
# --- PASS: TestMonitor_envtestRoundTrip (6.45s) ... ok
```
Worth noting:
- **Fake client scope:** the fake client needs a RESTMapper that registers
ProviderHealth as `RESTScopeRoot`. Its default mapper treats every kind as namespaced.
- **Why the envtest calls `publish` directly:** `checkOnce` only logs write errors, so a
schema rejection would look like a missing object. Calling `publish` makes the API
server's answer the test failure.
- Its first round writes a 2000-character message; the stored message is truncated to
1024.
- Its second round proves that only the target that flipped gets a new
`lastTransitionTime`.
- **No guard against a hung checker:** the Monitor trusts `Checker`'s contract to return
once the context ends, and doesn't abandon a checker that ignores it. All three
checkers stop when the context ends: KVM through `awaitOrCancel`, and kubernetes and
GCP through their clients.
- **Test renamed:** the planned `TestMonitor_timeoutBoundsBlockingChecker` is
`TestMonitor_checkOnce_timeoutBoundsEachRound`. It also asserts that a fast provider
still publishes next to a hung one.
## Step 5 — Metrics and wiring
`metrics.RegisterProviderTargets` registers a scrape-time collector for three gauges,
each labelled `{provider,target}`: `up`, check duration and check timestamp.
`Register`'s signature is unchanged.
`cmd/main.go`:
- gains `--provider-check-interval` (1m) and `--provider-check-timeout` (15s);
- validates them before even loading the providers config;
- collects the `Checker`s from the unwrapped providers in config order, just before the
metrics and tracing wrap loop;
- adds the Monitor with the manager's API reader;
- registers the collector through a small `providerTargetSamples` closure next to
`proxyPhaseCounts`.
Build, vet and metrics tests:
```bash
go build ./... && go vet ./cmd/... ./internal/metrics/ && go test -race -count=1 ./internal/metrics/
```
To confirm bad flags fail fast, before anything touches the cluster, I ran the binary
with a 5s interval. zap's development mode prints a stack trace after the error, hence
the grep:
```bash
go run ./cmd --providers-config=/dev/null --provider-check-interval=5s 2>&1 | grep -m1 "Invalid provider check flags"
# ERROR setup Invalid provider check flags {"error": "--provider-check-interval must be 0 (off) or at least 10s, got 5s"}
go vet -tags=e2e ./test/e2e/
```
The e2e suite gets a new ordered spec, "should publish provider health". It waits for
`providerhealth kubernetes` to report `Ready=True`. It runs before the existing metrics
spec, which now also asserts
`proxy_operator_provider_target_up{provider="kubernetes",target="apiserver"} 1`. By then
the first round is known to be done, so the series is certain to exist.
Worth noting:
- **Main-only wiring:** `providerTargetSamples` is an 8-line loop in `package main`,
which has no test file. The e2e metrics assertion is its only coverage.
- **The KVM entry isn't asserted in e2e:** the default providers config also ships
`kvm-home` (beelink), which would report `SSHMaterialUnavailable` in kind. That entry
is the user's own host, and asserting it would break the suite as soon as the default
config changes.
- **Manifest:** `config/manager/manager.yaml` only gained a comment about the two flags.
The defaults need no args.
## Step 6 — Docs
- **`docs/architecture.md`:**
- component rows for the CRD, the `Checker` contract and the monitor;
- a new §11 covering the provider table, the loop diagram, the write, read and
decorator choices, the gauges, and sample `kubectl get ph` output;
- cross-references from the metrics (§9) and tracing (§10) sections;
- four Decisions entries.
- **`docs/kvm-provider.md`:**
- a "Health check" section with the reason → meaning → where-to-look table and the
two consequences of sharing the connection;
- under Known gaps, the capacity bullet now points at `poolAvailable`, and a new
bullet says a rotated `kvm-ssh` Secret needs a restart;
- the Testing section names `TestKVMCheck_realHost`.
- **`docs/testing.md`:**
- suite rows for the three `check_test.go` files, `internal/providerhealth` (unit and
envtest) and the metrics gauges;
- "17 libvirt calls" is now 20;
- the integration and e2e sections describe the new tests.
- **`CLAUDE.md`:**
- the Architecture TODO now points at `docs/architecture.md`;
- the stale "only `go.mod` exists" gotcha is replaced by a note that the "What this
is" and "Commands" sections predate the code, plus Step 4's RBAC-marker gotcha.
Worth noting:
- `docs/api.md` is untouched. It documents the discovery HTTP API, which this change
doesn't alter.
- `CLAUDE.md`'s "What this is" and "Commands" sections were left stale on purpose.
Rewriting them is outside this plan, so the new gotcha flags them instead of leaving
them silently wrong.
## Step 7 — Validate, record, push, open the PR
The full suite runs codegen, `go fmt` and `go vet`, then `go test -race` on every
package, with both envtest suites included:
```bash
make test
# every package ok; internal/providerhealth 92.5 %, internal/provider/kvm 92.0 % coverage
```
`go fmt` rewrote nothing, and `git status` showed only the Step 6 docs.
`make lint` could not run, for a reason unrelated to this branch. The Makefile rebuilt
the custom golangci-lint, which reports "built with go1.27.0", and its bundled
staticcheck (honnef.co/go/tools v0.7.0) panics on the standard library before it
reaches any repo package:
```bash
make lint
# buildir: package "poll" (isInitialPkg: false, needAnalyzeSource: true): unexpected expr: *ast.KeyValueExpr
# make: *** [Makefile:107: lint] Error 3
```
The local toolchain is go1.27.0 while `go.mod` says 1.26.0; the otel-tracing plan's
log shows `make lint` running normally before the toolchain moved. Retrying with
`GOTOOLCHAIN=go1.26.0` would have meant downloading a toolchain, and the user declined
that. `go vet` is clean in all three configurations: plain inside `make test`, plus
`-tags=integration` and `-tags=e2e`.
The kind e2e suite creates and deletes its own `egress-proxies-operator-test-e2e`
cluster. I checked `KIND_CLUSTER` first, because the two clusters already on the
machine (`kind`, `proxy-operator-demo`) must not be torn down by `cleanup-test-e2e`:
```bash
make test-e2e > <scratch>/e2e.log 2>&1
# Ran 3 of 5 Specs in 86.210 seconds
# SUCCESS! -- 3 Passed | 0 Failed | 0 Pending | 2 Skipped
```
In the deployed manager, the `kubernetes` ProviderHealth reported `Ready=True` about a
second after the pod was up (20:30:05.6 → 20:30:06.7). The metrics scrape that followed
contained `proxy_operator_provider_target_up{provider="kubernetes",target="apiserver"} 1`.
That exercises the generated RBAC, the API-reader path and the `main.go` gauge wiring
end to end. The two skipped specs are the Tempo-gated tracing specs; `TEMPO_URL` was not
set.
### Follow-up — the check must not cost the reconciles their connection
Reviewing the shared-connection decision after the automated validation turned up two
ways the health check could hurt the work it shares the link with, both of which the
plan's "a check against a hung host drops the shared connection" line accepted too
easily:
- A check that timed out went through `hostPool.call`, whose cancel path drops the
connection. A `libvirtd` slow to answer one `ConnectGetLibVersion` would therefore
cut off an image upload or a `Create` running on the same link.
- `hostEntry` was guarded by a `sync.Mutex`, so waiting for a host's slot could not be
interrupted. A reconcile redialing a host that is down holds the slot for as long as
the SSH dial takes — minutes on a blackholed address — and the whole check round
would wait behind it, past its own timeout.
So the check now calls `hostPool.probe` instead of `call`: on `ctx` ending it abandons
the RPC and leaves the connection alone (a link that is really dead is closed by the
keepalive anyway, which ends the abandoned `fn` too). One abandoned probe per host is
allowed to stay waiting — `hostEntry.probing` is an `atomic.Bool`, not a counter, so a
daemon that never answers cannot pile up goroutines; further probes get the new
`errProbeBusy`, which `checkReason` maps to `Timeout`. `hostEntry.mu` became a one-slot
channel with an `acquire(ctx)` that only bounds *waiting*: an uncontended caller takes a
free slot even with an expired context, so `call`'s behaviour is unchanged where nothing
is contended.
The second change came out of the same pass: `--provider-check-timeout` defaulted to a
flat 15s while the flag validation rejected a timeout longer than the interval, so
`--provider-check-interval=12s` alone failed to start. The flag now defaults to 0 and
`Monitor.setDefaults` derives `min(15s, Interval)`; `ValidateSchedule` accepts 0 and
rejects only negatives and timeouts past the interval.
Re-verified with the same commands as above:
```bash
go vet ./...
go test -race -count=1 ./internal/provider/kvm/ ./internal/providerhealth/
make test
# every package ok; internal/provider/kvm 92.2 %, internal/providerhealth 93.7 % coverage
```
Worth noting:
- `make lint` still cannot run, for the toolchain reason recorded above; unchanged by
this follow-up.
- The two new `kvm` tests are timing-shaped by necessity — they assert that a caller
with a 50ms deadline does not wait out a dial that is blocked on a channel. The
assertions are one-sided (`> 1s` fails) so a slow machine does not make them flaky.
- `docs/architecture.md`, `docs/kvm-provider.md` and `docs/testing.md` were corrected in
the same commit: the old text promised the drop-on-timeout behaviour that is now gone.
- The real-hypervisor commands from the plan's Verification section are still the user's
to run; nothing here replaces them.
### Real-hypervisor validation and PR
The user ran the plan's real-hypervisor checks against beelink (the `TestKVMCheck`
integration test plus the manual `ProviderHealth` and metrics checks) and confirmed
they passed. After that the CHANGELOG entry went in, and the branch was pushed with
the token-file credential helper. The PR against `main` was opened through the Gitea
API because `tea` isn't installed.
Worth noting:
- The branch also carries `docs/plans/2026-09-16-0841-provider-blueprint.md`, on
purpose: that plan reaches `main` with this merge, and the blueprint work starts on a
new branch afterwards.
@@ -0,0 +1,195 @@
# Plan: Fix the five code-review findings on `feat/kvm-provider`
**Created:** 2026-09-10 22:22 UTC
## Context
PR #5 (`feat/kvm-provider` → `main`, 19 commits) is open and awaiting merge. A
`/code-review high` pass over the branch returned five confirmed findings: two that can
pin a Proxy's finalizer forever, one startup crash-loop, and two in the guest-agent merge
committed earlier today. All five are fixed on the same branch so the PR updates in place,
then re-validated against the real hypervisor before merging.
Four decisions, made by the user during planning:
1. **Wedged deletes** — attempt `Delete` even when `Get` fails permanently, make an
unconfigured KVM host a no-op delete, and add an annotation that abandons cleanup for
whatever still cannot finish.
2. **Missing `kvm-ssh` Secret** — read the SSH material lazily, so only KVM Proxies fail.
3. **Guest agent** — stop rewriting the user's cloud-config. Ship the bootstrap as a
vendor-data shell script instead.
4. Validation is unit tests, the integration test against beelink, and the kind e2e. The
uncommitted dev-only files stay exactly as they are and out of the PR.
## Why the guest-agent fix is a rewrite, not a patch
Both cloud-init findings come from the same root cause: this operator edits the user's
document. Reading cloud-init 26.1's sources settles that it never has to.
Vendor-data is consumed by its **own** handler chain with separate script and config paths
(`stages.py:_default_vendordata_handlers`). A vendor-data **shell script** is therefore
written to the vendor script directory and run by `cc_scripts_vendor`, never passing
through the config merger that drops colliding keys. On the final-stage module list it
runs after `package_update_upgrade_install` and before the user's own `runcmd`, so the
agent comes up early regardless of what the user's config does.
That also closes the door the previous approach left open. The merge cannot be rescued
from inside the documents: `merge_how` in the user-data governs only the first source, and
`merge_how` in vendor-data is popped and discarded by `dict_extract_mergers` before the
document is ever written. There is no supported way to make vendor-data cloud-config keys
survive a user-data that defines the same key.
Consequences: user-data reaches the seed byte for byte in any format, so Jinja templates,
MIME archives, gzip payloads and shell scripts stop being special cases (finding 4), and
nothing re-serialises YAML, so unquoted scalars keep their type (finding 5). The cost is
that the script picks the package manager itself where cloud-init's `packages` key was
distro-agnostic.
## Why the delete fix is shaped this way
KVM is the only provider that resolves a providerID segment against operator config. GCP
and Kubernetes let the backend answer "not found", so retiring a location yields
`ErrNotFound` and the finalizer drops. `Provider.host()`
([kvm.go:118-139](internal/provider/kvm/kvm.go#L118-L139)) returns `ErrPermanent`, and
`reconcileDelete` ([proxy_controller.go:281-291](internal/controller/proxy_controller.go#L281-L291))
drops the finalizer only on `ErrNotFound`. `deletionFailure`
([:327-336](internal/controller/proxy_controller.go#L327-L336)) says in its own comment
that it retries "until an operator intervenes", and no intervention mechanism exists. The
annotation is that mechanism, and it also rescues the unconfigured-provider wedge at
[:276-279](internal/controller/proxy_controller.go#L276-L279).
`Get` keeps returning `ErrPermanent` for an unknown host deliberately. Turning it into
`ErrNotFound` would make a typo in the host list look like a missing VM on the live path,
and the reconciler would recreate every affected Proxy elsewhere while the originals keep
running where garbage collection cannot see them.
## Steps
One commit per step on `feat/kvm-provider`, each ending with
`Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>`.
### Step 0 — Save this plan into the repo
Copy to `docs/plans/2026-09-10-2222-kvm-review-fixes.md` with the same header timestamp and
commit it alone, before any code change. Start
`docs/plans-executions/2026-09-10-2222-kvm-review-fixes.md` with the status checklist when
Step 1 lands.
### Step 1 — Unwedge deletion (finding 1)
- `api/v1alpha1/proxy_types.go`: add `AnnotationSkipCleanup = "crawl.example.com/skip-cleanup"`
beside `AnnotationSpecHash` ([:59-63](api/v1alpha1/proxy_types.go#L59-L63)), documented as
abandoning the backend instance.
- `internal/controller/proxy_controller.go`, `reconcileDelete`: honour the annotation after
the empty-providerID branch and **before** the provider lookup, so it also covers a
provider that is no longer configured. Log the abandoned providerID at Info.
`ProxyReconciler` has no `EventRecorder`; do not add one for this.
- Same function: replace the two `if`s after `Get` with a switch. On
`provider.Class(err) == provider.ErrPermanent`, call `Delete` anyway, since it is
idempotent, and remove the finalizer when it succeeds rather than requeueing into a `Get`
that can never converge. On failure, `deletionFailure(errors.Join(getErr, delErr))`.
Guard on `err != nil`, since `Class(nil)` is nil.
- `internal/provider/kvm/kvm.go`, `Delete`: an unconfigured host segment means there is
nothing this operator can clean, so log it and return success, mirroring `removeVolumes`
([kvm.go:506-514](internal/provider/kvm/kvm.go#L506-L514)) for a missing pool. `Get` is
unchanged.
- Tests: extend the deletion table in `internal/controller/reconcile_test.go`
([:357-393](internal/controller/reconcile_test.go#L357-L393)) with permanent-Get plus
succeeding Delete, permanent-Get plus failing Delete, the annotation with the provider
never called, and the annotation on an unconfigured provider. In `kvm_test.go`, the
`"host not configured"` case at [:737](internal/provider/kvm/kvm_test.go#L737) flips from
`ErrPermanent` to success; add a named test beside `TestDelete_missingPoolIsNotAnError`
([:715](internal/provider/kvm/kvm_test.go#L715)).
- Docs: the annotation in `README.md` (~line 163) and `docs/architecture.md` (~line 487);
a *Known gaps* entry in `docs/kvm-provider.md` saying a domain on a host removed from
config can no longer be cleaned up by the operator, the Proxy still finishes deleting,
and the VM must be removed by hand because garbage collection only sweeps configured
hosts.
### Step 2 — Validate names that become identifiers (finding 2)
- `internal/provider/config_kvm.go`: add a name regexp beside `sha256Hex`
([:6](internal/provider/config_kvm.go#L6)) allowing letters, digits, dot, dash and
underscore. Apply it to `hosts[].name` after the existing empty check
([:129-131](internal/provider/config_kvm.go#L129-L131)) and to each `images` map key,
which becomes the cached volume name `base-<key>.<format>`
([image.go:49-55](internal/provider/kvm/image.go#L49-L55)) and half of the image-cache
lock key ([image.go:96](internal/provider/kvm/image.go#L96)). `machineTypes` keys are only
compared, never embedded, so leave them.
- Keep the file's first-error-wins style and `fmt.Sprintf` field paths. The host message
should name the consequence: the value becomes part of `hosts/<name>/domains/<vm>`, which
`parseProviderID` splits on `/` ([id.go:11-21](internal/provider/kvm/id.go#L11-L21)).
- Tests: two entries in the `TestLoadConfig_kvmInvalid` table
([config_kvm_test.go:76](internal/provider/config_kvm_test.go#L76)), existing
name/yaml/wantErrSub shape.
### Step 3 — Read the SSH material lazily (finding 3)
- `internal/provider/kvm/conn.go`: keep `newSSHAuth`, and add a lazy dialer that builds it
on first dial and caches it once it succeeds, so a `kvm-ssh` Secret created after the
manager starts heals on the next reconcile with no restart.
- `internal/provider/kvm/kvm.go`, `New`: stop calling `newSSHAuth`; pass the lazy dialer.
Use the context parameter it currently ignores to log a warning when either file is
unreadable at startup, keeping the diagnostic without the exit.
- Error classes: a missing or unreadable file stays unclassified, so `Class` defaults it to
transient and the Proxy retries until the Secret appears; an unparseable key or malformed
known_hosts wraps `ErrPermanent`. Distinguish with `errors.Is(err, fs.ErrNotExist)`.
- Fix the two doc comments promising startup failure
([kvm.go:79-81](internal/provider/kvm/kvm.go#L79-L81),
[conn.go:182-185](internal/provider/kvm/conn.go#L182-L185)) and the matching claim in
`docs/kvm-provider.md` (~lines 107-109). `DISCOVERY_TOKEN` already sets the house
convention for an optional mount: degrade with a loud warning.
- Tests in `conn_test.go`, which already writes key files: `New` succeeds with no key
present, the first dial fails transiently, a dial after the file appears succeeds, and a
corrupt key is permanent.
### Step 4 — Guest agent via vendor-data script (findings 4 and 5)
- `internal/provider/kvm/seed.go`: delete `withGuestAgent`, `isCloudConfig`, `appendUnique`
and the `sigs.k8s.io/yaml` import. User-data goes onto the seed exactly as the Proxy
supplied it.
- Add the bootstrap as a `#!/bin/sh` vendor-data script: skip when the agent is already
present, otherwise install `qemu-guest-agent` through whichever of `apt-get`, `dnf`,
`yum`, `zypper` or `apk` exists, then `systemctl enable --now`. Run `apt-get update`
before installing, since nothing else guarantees a fresh index when the user's config has
no `packages` key. Keep it idempotent and let it log to stdout, which lands in
`cloud-init-output.log`.
- When the provider config also sets `vendorData`, wrap both into a MIME multipart
vendor-data: the user's document as one part, the script as another. Cloud-init detects
multipart by finding `mime-version:` in the first 4096 bytes, so the header must lead.
- `internal/provider/config_kvm.go`: add `installGuestAgent`, defaulting to on. Off means
the seed carries only the user's own vendor-data and IP discovery falls back to the host
ARP table. Nothing fails permanently any more, so the error path in `writeSeed`
([kvm.go:252-255](internal/provider/kvm/kvm.go#L252-L255)) narrows to MIME assembly.
- Tests in `seed_test.go`: user-data byte-identical for cloud-config, Jinja, MIME, gzip and
script inputs; the script present in vendor-data; the opt-out; the multipart case
including the `mime-version:` position; keep the existing ISO tests.
- Docs: rewrite the *What lands on the host* passage added earlier today, the `vendorData`
row and a new `installGuestAgent` row in the config table, and the ARP note in
*Known gaps*. Cite the cloud-init behaviour that forced the change.
### Step 5 — Validate, record, push
- `gofmt -l internal/`, `go vet ./...`, `go test -short ./...`.
- `make test-integration-kvm` against beelink, environment as recorded in the KVM execution
log.
- Rebuild the image, `kind load`, restart the manager, delete and recreate both Proxies,
curl a test URL through each.
- `CHANGELOG.md` entry at the top with a real `date` timestamp, noting it supersedes the
merge approach described in today's earlier entry. Add a Run 8 section to
`docs/plans-executions/2026-09-04-1404-kvm-provider.md` plus this plan's own execution
file. Push with the token helper; PR #5 updates in place.
## Verification
- Unit: the controller cases prove a permanent `Get` no longer pins the finalizer and that
the annotation short-circuits before the provider is touched. `TestDelete_*` proves an
unconfigured host is a clean delete while `TestGet_*` stays permanent, which is what keeps
the live path from recreating VMs.
- Seed: assert the uploaded user-data equals the Proxy's `cloudInit` byte for byte, which is
the property the old merge could not offer.
- Integration on beelink: Create, IP via the guest agent, ListByTag, Delete. In the guest,
`cloud-init status --long` clean and `systemctl is-active qemu-guest-agent` active.
- In-cluster: both Proxies reach Ready with an IP and return 204 through squid. Their
cloud-config defines its own `packages`, which is the exact case that failed in Run 7.
- Negative check in kind: rename the `kvm-ssh` Secret away and restart. The pod must stay
up, the Kubernetes Proxies must stay Ready, and only the KVM Proxies degrade. Restore the
Secret and confirm they recover without a restart.
## Out of scope
- The two items the review noted but did not report: deleting a cached base image while
running VMs back onto it, and an ad-hoc image URL always being treated as qcow2. Both are
documented known gaps and neither is a regression from this branch.
- The uncommitted dev-only files, by the user's decision.
@@ -0,0 +1,354 @@
# Plan: Provider health checks — see whether the cluster can reach each provider
**Created:** 2026-09-11 15:26 UTC
## Context
Today the operator can't say whether a provider's backend is reachable. The `Provider`
interface ([provider.go:89-109](internal/provider/provider.go#L89-L109)) only has
Create/Get/Delete/ListByTag, and `/healthz` and `/readyz` are bare `healthz.Ping`. A
broken KVM path only shows up later: as a Proxy stuck in Provisioning, or as an orphan-GC
sweep logging an error. Several failures stay invisible until a Proxy needs that host:
- a missing `kvm-ssh` Secret;
- a rotated host key;
- a Tailscale route that is down;
- a storage pool that is inactive or full.
The goal is a periodic sanity check of every configured provider from inside the cluster,
with results you can see through `kubectl` and Prometheus.
Decisions made by the user during planning:
1. **Surface:** a new operator-owned, read-only, cluster-scoped **`ProviderHealth`** CRD,
with one object per configured provider, a `Ready` condition and per-target detail.
Also Prometheus gauges, and a log line whenever a target goes up or down. No
EventRecorder, matching the earlier decision.
2. **Scope:** real checks for all three providers.
- **kvm:** checked per host.
- **kubernetes:** confirms the API server and RBAC work.
- **gcp:** confirms credentials and the API work.
3. **KVM depth:** reachability plus the storage pool.
- Over the existing pooled SSH+libvirt connection, call the libvirt version and
hostname RPCs.
- Confirm the configured pool is running and report its free space.
- No bridge check.
Not affected: the Proxy reconcile behaviour, and the manager's `/healthz` and `/readyz`.
A dead hypervisor must never make the manager unready.
## Design
### Contract: `internal/provider/check.go`
```go
type TargetStatus struct {
Name, Address string
Ready bool
Reason, Message string
Latency time.Duration
Details map[string]string
}
// Check never errors; each target carries its own verdict. Must return promptly once ctx ends.
type Checker interface{ Check(ctx context.Context) []TargetStatus }
```
- **Shared reasons:** `Reachable`, `Timeout`, `Unreachable`, `Unauthorized`, `Forbidden`,
`APIError`.
- **Compile-time checks:** each provider's `check.go` has `var _ provider.Checker = (*Provider)(nil)`.
- **Bypass the wrappers.** `cmd/main.go:197-201` wraps every provider in
`WithTracing(WithMetrics(...))`, so the Checkers are collected from the **raw** providers
map, before that loop. As a result, checks don't inflate `provider_requests_total`.
### The checks
**kvm** (`internal/provider/kvm/check.go`)
- **Concurrency:** one goroutine per `cfg.Hosts` entry (`wg.Go`), writing to `out[i]` so
results stay in config order.
- **Inside `p.hosts.call`:** `ConnectGetLibVersion`, `ConnectGetHostname`,
`StoragePoolLookupByName(host.Pool)`, then `StoragePoolGetInfo`.
- **Rules for `fn`:**
- It returns only raw RPC errors. `call` drops the shared connection on any other error
([conn.go:160](internal/provider/kvm/conn.go#L160)), so "pool not running" is decided
after `call` returns, never returned from `fn`.
- It fills a captured result struct only after all four calls succeed. That struct is
read only when `call` returned nil, which avoids a race with an abandoned goroutine.
- **Details:** `hostname`, `libvirtVersion` (decoded as major.minor.patch), `pool`,
`poolState`, `poolCapacity` and `poolAvailable` (in GiB).
- **New RPCs:** add the three to `libvirtAPI` ([conn.go:29-47](internal/provider/kvm/conn.go#L29-L47)),
`tracingAPI` (`trace.go`) and `fakeAPI` (`fake_test.go`). `fakeAPI` gets a hostname, a
fixed version, and a per-pool state map that defaults to Running.
- **Telling failure stages apart without matching strings:** add
`type dialError struct{ stage dialStage; err error }` in `conn.go`, with `Error()`
returning the inner message unchanged and `Unwrap()`. It wraps at four places:
- `lazySSH.dial`, when loading the key material fails (`ssh-material`)
- `dialSSH`, at the dial step (`tcp`)
- `dialSSH`, at the handshake (`ssh-handshake`)
- `connectLibvirt` (`libvirt-connect`)
Because `Unwrap` is kept, `classify` and `isClassified` still see `ErrPermanent` and
`fs.ErrNotExist`, so reconcile behaviour doesn't change.
- **`checkReason(err)`, tested in order:**
| Condition | Reason |
|---|---|
| stage ssh-material | `SSHMaterialUnavailable` |
| stage tcp | `Unreachable` |
| stage ssh-handshake | `SSHHandshakeFailed` |
| stage libvirt-connect | `LibvirtConnectFailed` |
| ctx deadline or cancel | `Timeout` |
| `ErrNoStoragePool` | `PoolNotFound` |
| any other libvirt error | `LibvirtError` |
| anything else | `Unreachable` |
| success, pool not running | `PoolNotActive` |
| success | `Reachable` |
- **Accepted behaviour:**
- A check that hits its timeout drops the shared connection. That already happens for
any cancelled reconcile context, and it only fires when the host isn't answering.
- The leader now keeps one SSH connection per host open from startup, with keepalives
every 15s. Before, the connection only opened with the first KVM reconcile.
- No forced periodic redial. `lazySSH` caches the key material forever, so a redial
would not pick up a rotated Secret anyway; that gap goes into the docs under
"Known gaps".
**kubernetes** (`internal/provider/kubernetes/check.go`)
- **Call:** `List` pods with the managed-label selector and `client.Limit(1)`,
cluster-wide. It uses the same uncached client, verb and RBAC as `ListByTag` and GC.
There is no namespace setting in the kubernetes config.
- **Target:** one target named `apiserver`, with the address from a new `apiHost` field
set in `NewWithTransportWrapper`.
- **Reasons:**
- `Timeout`: the context ended, or the error is `IsTimeout`/`IsServerTimeout`.
- `Unauthorized`, `Forbidden`.
- `APIError`: any other API status.
- `Unreachable`: anything else.
**gcp** (`internal/provider/gcp/check.go`, plus a seam change in `gcp.go`)
- **Why a new seam method:** the existing `AggregatedList` seam reads every page
([gcp.go:63-78](internal/provider/gcp/gcp.go#L63-L78)), and MaxResults is only the page
size. So add `Probe(ctx, *computepb.AggregatedListInstancesRequest) error`, which makes
one `it.Next()` call and treats `iterator.Done` as nil.
- **Request:** `Project`, `MaxResults: 1`, `ReturnPartialSuccess: true`, and the same
managed-label filter `ListByTag` uses.
- **Target:** one per project, `Name: cfg.Project`, `Address: compute.googleapis.com`.
GCP config has no zones, and reading them from Proxy placements would tie the check to
the Proxy cache.
- **Reasons:**
- `Timeout`: the context ended.
- For `*googleapi.Error`: 401 is `Unauthorized`, 403 is `Forbidden` (this also covers a
disabled API), anything else is `APIError`.
- `Unreachable`: anything else, including a failure to get a token.
### The `ProviderHealth` CRD: `api/v1alpha1/providerhealth_types.go`
- **Written by hand**, since kubebuilder isn't installed; `PROJECT` is edited by hand too.
- **Scope and markers:** cluster-scoped, `shortName=ph`, status subresource, no spec.
- **Scheme registration:** add the type to the existing `AddKnownTypes` call in
[proxy_types.go:362](api/v1alpha1/proxy_types.go#L362), not a second `init()`.
- **Status:**
- `type`
- `conditions` (`Ready`; `ConditionReady` constant)
- `targets[]`, as a list-map keyed by `name`, each with: `name`, `address`, `ready`,
`reason`, `message` (at most 1024 characters), `latencyMillis`,
`lastTransitionTime`, `details`
- `targetsReady` (for example `"1/2"`, used by a printer column)
- `lastCheckTime`
- **Printer columns:** Type, Ready, Targets, Last Check, Age.
- **Viewer role:** `config/rbac/providerhealth_viewer_role.yaml`. No editor role, since the
operator owns the object.
### Monitor: `internal/providerhealth`
- **Fields:** `Monitor{Client, Reader, Targets []Target{Name, Type, Checker}, Interval, Timeout}`.
- **Scheduling:**
- `NeedLeaderElection() true`.
- Checks once immediately, then on a ticker. Copy the ticker loop from
[gc.go:54-80](internal/gc/gc.go#L54-L80).
- **Each cycle:**
- Runs every provider concurrently under `context.WithTimeout(Timeout)`, inside one
`tracing.Start` span.
- Stores the snapshot under a mutex; `Snapshot()` returns a deep copy.
- Logs changes. Going down logs `WARNING: provider target not ready` with provider,
target, address, reason and message. Coming back logs `Provider target ready`. A
target that is ready on first sight logs at V(1) only.
- **Writes:**
- Get through `mgr.GetAPIReader()`, not the cache. That avoids a cluster-wide informer
and the watch permission it would need, whatever `--proxy-namespace` is set to.
- Create the object if it is missing, labelled
`app.kubernetes.io/managed-by=egress-proxies-operator`.
- Then `Status().Patch(MergeFrom(base))` when the status changed, following
[status.go:114-121](internal/controller/status.go#L114-L121).
- This means one write per provider per interval, because `lastCheckTime` moves each
time. That's acceptable given the 10s minimum interval.
- **`buildStatus(prev, result, now)` is a pure function:**
- A target keeps its `lastTransitionTime` unless `ready` flipped.
- The `Ready` condition is set through `apimeta.SetStatusCondition`:
- `True/AllTargetsReady` when every target is ready
- `False/TargetsNotReady` otherwise, with the failing targets in the message
- `Unknown/ChecksNotImplemented` when the provider has no Checker
- `Unknown/NoTargets` when there are zero targets
- **Cleanup, once at `Start`:** delete labelled objects whose name isn't in the config.
The config is loaded once per process, and unlabelled objects are never touched.
- **Interval 0 (disabled):** run the cleanup with nothing kept, then return nil, so no
object is left showing a stale `Ready=True`.
- **Invalid provider names:** provider names aren't validated as DNS-1123 names. For an
invalid name, warn once and skip the object writes; metrics are still emitted.
- **`ValidateSchedule(interval, timeout)`** is called in `main.go` before the manager
starts. Interval must be 0 or at least 10s; timeout must be in (0, interval].
- **RBAC markers on `Monitor`:**
- `providerhealths`: get, list, create, delete
- `providerhealths/status`: get, patch
- `make manifests` regenerates `config/rbac/role.yaml`.
### Metrics and wiring
- **New function:** `metrics.RegisterProviderTargets(reg, read func() []ProviderTarget) error`.
`Register`'s signature is left alone.
- **Gauges:** a scrape-time collector, in the style of `constCollector`, reads the Monitor
snapshot and emits three gauges labelled `{provider,target}`:
- `proxy_operator_provider_target_up`
- `proxy_operator_provider_check_duration_seconds`
- `proxy_operator_provider_check_timestamp_seconds`
- **Conversion closure:** the one that turns the Monitor snapshot into gauges lives in
`main.go`, next to `proxyPhaseCounts`. So `providerhealth` doesn't import `metrics`.
- **No pool-bytes gauge:** that value is in status, and node-exporter is the right source
for disk alerts.
- **`cmd/main.go`:**
- New flags `--provider-check-interval` (1m) and `--provider-check-timeout` (15s).
- Build the `Targets` from `cfg.Providers` in config order, with
`c, _ := raw[pc.Name].(provider.Checker)`.
- `mgr.Add(&providerhealth.Monitor{Client: mgr.GetClient(), Reader: mgr.GetAPIReader(), ...})`,
then register the collector.
## Steps (one commit each, execution-log entry per step)
**Step 0 — Save the plan.**
- Create branch `feat/provider-health` off `main`.
- Copy this plan to `docs/plans/2026-09-11-1526-provider-health-checks.md` and commit it
alone.
- The uncommitted `.claude/settings.json` change stays out of every commit.
**Step 1 — ProviderHealth API and CRD.**
- `api/v1alpha1/providerhealth_types.go`
- `zz_generated.deepcopy.go`, from `make generate`
- `config/crd/bases/crawl.example.com_providerhealths.yaml`, from `make manifests`
- the CRD and RBAC `kustomization.yaml` files, plus the viewer role
- `PROJECT`
**Step 2 — Contract plus the kubernetes and GCP checks.**
- `internal/provider/check.go`
- `internal/provider/kubernetes/{kubernetes.go,check.go,check_test.go}`
- `internal/provider/gcp/{gcp.go,check.go,check_test.go,gcp_test.go}`
**Step 3 — KVM check.**
- `internal/provider/kvm/{conn.go,trace.go,check.go,check_test.go,fake_test.go,conn_test.go,trace_test.go,kvm_integration_test.go}`
**Step 4 — Monitor.**
- `internal/providerhealth/{monitor.go,status.go,monitor_test.go,status_test.go,envtest_test.go}`
- `config/rbac/role.yaml`, from `make manifests`
**Step 5 — Metrics and wiring.**
- `internal/metrics/{metrics.go,metrics_test.go}`
- `cmd/main.go`
- `config/manager/manager.yaml`: a comment for the new flags
- `test/e2e/e2e_test.go`
**Step 6 — Docs.**
- `docs/architecture.md`: a component row plus a provider-health section
- `docs/kvm-provider.md`: the reason table, the shared-connection behaviour, and the
Secret-rotation gap under "Known gaps"
- `docs/testing.md`: the new tests and the commands
- `CLAUDE.md`: its Architecture and Gotchas sections still say "empty scaffold"; point
them at `docs/architecture.md`
**Step 7 — Validate, record, push, open the PR.**
- Run `go vet ./...`, `go test -short ./...`, `make test` and `make test-e2e`.
- Hand the user the KVM commands below, and record their results in the execution log.
- Add a CHANGELOG entry once the user confirms.
- Push, using the token-file credential helper from memory.
- Open the PR against `main` through the Gitea API (`tea` isn't installed).
- Don't merge.
## Tests
**Provider checks** (table-driven, `Test<Func>_<scenario>`):
- kubernetes:
- reachable
- list options: `Limit==1`, the label selector, no namespace
- failure reasons: forbidden, unauthorized, timeout, cancelled, internal, and a
network `*url.Error`
- gcp:
- reachable, asserting the request fields
- failure reasons: 401, 403, 500, deadline, network
- `TestRealInstances_Probe_fetchesOnePage` against `httptest`: exactly one request with
`maxResults=1`, even though the page has a `nextPageToken`
- kvm (using `hostFakes`/`newTestProvider` from `kvm_test.go`):
- reachable, with details filled in
- per-host verdicts, order preserved
- pool inactive, pool missing
- hosts checked concurrently (a sequential check would deadlock)
- a pool verdict keeps the connection (the dial count stays 1)
- a `checkReason` table
- `TestSSHAuth_dial_stages`, using the in-process SSH server in `conn_test.go`
- three new rows in the tracing table
**Monitor:**
- Setup: the fake client is built with a status subresource and a RESTMapper that
registers ProviderHealth as cluster-scoped.
- Tests:
- `buildStatus` table
- objects are created with the label, and existing ones are patched
- the timeout bounds a checker that blocks
- a nil checker gives Unknown
- only transitions are logged (`funcr` logger)
- cleanup deletes only labelled objects that aren't configured
- the disabled path cleans up and returns
- an invalid name skips the writes
- `ValidateSchedule` table
- An envtest round-trip, skipped under `-short`, which proves the generated schema is
accepted by a real apiserver.
**Metrics:** `TestRegisterProviderTargets_scrape`, using a fresh registry and
`GatherAndCompare`; an empty snapshot produces no series.
**e2e (kind):** `providerhealth kubernetes` reaches `Ready=True`, which proves the
generated RBAC works end to end.
## Verification
**Automated (Claude runs):** `go vet ./...`, `go test -short ./...`, `make test` (envtest
with `-race`) and `make test-e2e`.
**Against the real hypervisor (handed to the user, not run by Claude)**, with beelink as
the host:
```bash
KVM_TEST_HOST=beelink.tail8c69c.ts.net:22 KVM_TEST_USER=novakj KVM_TEST_SSH_KEY=~/.ssh/id_ed25519 \
KVM_TEST_KNOWN_HOSTS=~/.ssh/known_hosts KVM_TEST_BRIDGE=<bridge> KVM_TEST_IMAGE_URL=<image-url> \
go test -tags=integration -count=1 -v -run TestKVMCheck ./internal/provider/kvm/
make install && go run ./cmd/main.go --providers-config <scratch copy of kvm-home config, local key paths> \
--metrics-bind-address :8080 --metrics-secure=false --provider-check-interval=15s
kubectl get ph -w
kubectl get ph kvm-home -o yaml
curl -s localhost:8080/metrics | grep -E 'proxy_operator_provider_(target_up|check_)'
```
Each negative case is a one-line edit to the scratch config; nothing changes on the host.
| Edit | Expected reason |
|---|---|
| `pool: nope` | `PoolNotFound` |
| `address: …:2` | `Unreachable` |
| `knownHostsFile: /dev/null` or `user: nobody` | `SSHHandshakeFailed` |
| `sshKeyFile: /nonexistent` | `SSHMaterialUnavailable` |
| `socket: /nonexistent` | `LibvirtConnectFailed` |
## Out of scope
- Gating Proxy creates on a host that is known to be down.
- Tying `/readyz` to provider health.
- Bridge checks.
- SelfSubjectAccessReview checks of create/delete rights.
- Hot-reloading the providers config.
- Picking up a rotated `kvm-ssh` Secret without a restart. This is an existing gap; it
gets documented.
@@ -0,0 +1,119 @@
# Plan: Provider blueprint (docs/providers.md)
**Created:** 2026-09-16 08:41
## Context
The provider contract is spread across `docs/prompts/__initial-prompt.md` §5,
the comments in `internal/provider/{provider,errors,check}.go`, `docs/architecture.md`
§5 and §11, `docs/kvm-provider.md`, and plan Step 2/3/8. Many rules exist only in
code: providerID must be self-contained, UID/CreatedAt must be read back, Create
must survive a crash partway through, calls must stop when ctx is cancelled,
decorators are wired in `cmd/main.go`, and so on. With "~5 providers soon", the
next provider author needs one blueprint. It should say what a provider MUST,
SHOULD or MAY do, show which features each existing provider (kubernetes, gcp,
kvm) actually implements (with file:line evidence), give a checklist for adding
one, and list the gaps.
Decisions (user): store in `docs/providers.md`; scope = current state plus a
separate "Gaps and proposed features" section (nothing implemented); wait for
`feat/provider-health` to merge, then branch off `main`.
## Step 0 — Save the plan; wait for the merge
- Right after ExitPlanMode, copy this plan to `docs/plans/2026-09-16-0841-provider-blueprint.md`
and commit it on its own. The current branch is `feat/provider-health`, so the
plan reaches `main` with that merge. CLAUDE.md requires the plan commit to
come before any branching.
- Stop and wait for the user to confirm that `feat/provider-health` is merged.
Then `git switch main && git pull`, and `git switch -c feat/provider-blueprint`.
## Step 1 — Collect evidence (read-only, in parallel)
Run 3 Explore agents at the same time, one each for `internal/provider/kubernetes`,
`internal/provider/gcp` and `internal/provider/kvm`, including their tests. Each
answers the **same questionnaire**, citing `file:line` for every answer and writing
"not implemented" explicitly when that's the case:
1. **Identity**: providerID format and parsing; what it carries (zone, host, namespace).
2. **Create**: how it stays idempotent (name lookup, 409, DomainLookupByName);
whether it returns before the VM is up or waits; what happens when an earlier
attempt crashed partway; which Placement fields are used or ignored; how
cloud-init is delivered; how the port is used; how GC labels/UID are attached.
3. **Get**: native state → InstanceState mapping; IP discovery rules; UID and
CreatedAt readback; how NotFound is detected.
4. **Delete**: idempotency; what gets removed (disks, volumes, seeds); returns
before or after the resource is gone; unknown/malformed providerID behaviour.
5. **ListByTag**: scope (namespaces, zones, hosts); partial-failure behaviour; how
managed resources are recognised.
6. **Error classification**: table of backend error → NotFound/Quota/Transient/
Permanent; whether quota is detected at all.
7. **Health check** (`Checker`): targets, provider-specific Reasons, Details keys.
8. **Config**: YAML section, defaults, validation (`internal/provider/config*.go`),
credentials/Secrets, which settings are read once per process.
9. **Connections and cancellation**: pooling, reconnects, timeouts, ctx honouring.
10. **Observability**: log keys and verbosity levels, spans (`kvm/trace.go`, gcp
`wirelog.go`), anything beyond the shared metrics/tracing decorators.
11. **Testing**: fakes, envtest/kind, `//go:build integration` tests, what is
not covered.
12. **Known gaps**: TODOs, stale comments, documented limitations.
Meanwhile, read the shared layer myself: `internal/provider/{provider,errors,check,
name,config,metrics,tracing}.go`, `internal/provider/registry/registry.go`,
`cmd/main.go` (registry wiring, decorator order, Checker collection), and the
places that consume the contract, so every MUST rule traces to a caller that depends on it:
`internal/controller` (error-class branching, ErrNotFound → replace/adopt),
`internal/gc` (UID, CreatedAt age skip, ListByTag errors),
`internal/providerhealth`. Then cross-check against the existing docs listed in Context.
## Step 2 — Write `docs/providers.md`
Sections:
1. **What a provider is**: a few sentences plus links to architecture.md §5/§11.
2. **The contract**: per method (Create/Get/Delete/ListByTag), MUST/SHOULD/MAY
rules, each followed by *why*: the reconciler or GC behaviour it protects,
linked to code.
3. **Error taxonomy**: what each class makes the reconciler do; how to map backend
errors; "unclassified = transient" pitfall.
4. **Identity and GC labels**: providerID guidance, `LabelManaged`/`LabelUID`,
CreatedAt, deterministic `NameFromUID`.
5. **Optional interfaces**: `Checker` (shared reasons, never fails as a whole,
collected before decorators).
6. **Feature matrix**: one row per questionnaire item, columns kubernetes/gcp/kvm,
cells `yes`/`partial`/`no` plus a short note. Put the file:line evidence in a
compact per-provider notes subsection under the table, not in the cells.
7. **Adding a provider: checklist**: package layout, config section and validation,
registry constructor, `cmd/main.go` wiring, Checker, error mapping with tests,
`name`/label tests, integration test behind a build tag, provider doc page,
architecture.md rows, demo script (optional).
8. **Gaps and proposed features**: current-state gaps per provider (e.g. no kvm
quota detection, orphan volumes invisible to GC, Secret rotation needs a
restart, ListByTag partial-failure inconsistency, stale `mock` references in
`provider.go` comments), plus proposals that are clearly marked as not
implemented. Only list them; don't design them.
Also: link it from `docs/architecture.md` (Components table → provider contract row)
and from the CLAUDE.md "Architecture" section. Leave per-provider docs where they are.
Follow the plan-execution convention: create `docs/plans-executions/2026-09-16-0841-provider-blueprint.md`
with the status checklist and step entries, and commit it with each step.
## Step 3 — Verify, then open the MR
- A verification agent (Explore) re-checks each matrix cell and each MUST rule
against the cited file:line and reports any mismatch. Fix what it finds.
- Check that every relative link in the new and edited docs resolves (grep the
`](` targets and `ls` each one).
- `go build ./... && go test -short ./...` as a sanity check (docs-only change,
expected no-op).
- Push `feat/provider-blueprint` and open the MR against `main` via the Gitea API,
following the project's push/MR memory note. After the user confirms, add a CHANGELOG.md entry.
## Critical files
- New: `docs/providers.md`, `docs/plans/2026-09-16-0841-provider-blueprint.md`,
`docs/plans-executions/2026-09-16-0841-provider-blueprint.md`
- Edited: `docs/architecture.md` (link), `CLAUDE.md` (link)
- Read-only sources: `internal/provider/**`, `cmd/main.go`, `internal/controller`,
`internal/gc`, `internal/providerhealth`, `docs/prompts/__initial-prompt.md`,
`docs/kvm-provider.md`, `docs/gcp-vm-validation.md`
+28 -6
View File
@@ -53,18 +53,23 @@ manual runs too.
| | `config_test.go` | providers-config parsing + fail-fast validation | pure |
| | `metrics_test.go` | `WithMetrics` decorator: classified result labels, error passthrough | fake recorder + static provider |
| `internal/provider/kubernetes` | `kubernetes_test.go`, `pod_test.go` | Create/Get/Delete/ListByTag over real Pod objects; pure `buildPod`/`squidConf` incl. the `max_filedescriptors` OOM-regression assertion | `client/fake` (real `corev1.Pod`s, no kubelet) |
| | `check_test.go` | health check: a single cluster-wide list with `Limit=1` and the managed-label selector; the reason for every API error class | `client/fake` + interceptor |
| `internal/provider/gcp` | `insert_test.go`, `errors_test.go`, `gcp_test.go` | field-by-field `buildInsertRequest`; HTTP-code classification table; zone-qualified IDs, 409-is-success, 9-row state mapping, ListByTag filter + `ReturnPartialSuccess` | pure builder needs **no fake**; the rest uses the flattened `instancesAPI` seam |
| | `check_test.go` | health check: request shape and the reason per HTTP code or network error; the **real SDK client** makes exactly one request with `maxResults=1`, even when a next page exists | fake seam + `httptest` |
| `internal/provider/kvm` | `domainxml_test.go`, `volume_test.go`, `seed_test.go`, `id_test.go`, `metadata_test.go` | pure builders: domain/volume XML unmarshalled back and checked field by field, seed ISO reopened with the same library, providerID round-trip, metadata in both namespace spellings | pure |
| | `errors_test.go`, `conn_test.go` | libvirt-code classification table; connection pool (reuse, redial, drop on transport error, cancel releases a blocked RPC, one dial per host under concurrency); SSH dial, host-key policy, keepalive | fake dialer; a **real in-process `x/crypto/ssh` server** forwarding a unix socket |
| | `image_test.go`, `kvm_test.go` | image cache (fresh fetch, ready, marker missing, checksum change/mismatch, 4xx/5xx, concurrent callers); Create resume after every partial step, leftover truncated seed, state/IP mapping, every Delete already-gone case, ListByTag across hosts / one down / unreadable domain; logging tiers (V0 silent, V1 one line per RPC, V2 XML) with cloud-init never logged | `httptest` image server + in-memory `fakeAPI` behind the `libvirtAPI` seam |
| | `trace_test.go`, `conn_test.go`, `errors_test.go` | every `libvirtAPI` method traced exactly once (guarded by reflection on the interface), results and errors pass through, libvirt error fields, detail keys only at V2, upload content never logged; pool connect/drop lines, no wrapping below V1 | `fakeAPI` + `funcr` capture |
| | `kvm_integration_test.go` (`-tags=integration`) | one real VM: image fetch, Create, idempotent re-Create, Running with IP, ListByTag, Delete, pool state afterwards | a **real hypervisor** — see below |
| | `check_test.go`, `conn_test.go` | health check: details, per-host verdicts in config order, hosts checked concurrently, a timeout that keeps the connection and refuses a second check while the first still waits, no waiting behind a reconcile's dial, the reason table; `probe` abandons without closing, but still drops on a transport error; waiting for a host's slot ends with the caller's deadline; an inactive or missing pool and daemon errors keep the connection; every dial stage tagged through the real SSH server, with classification unchanged | `fakeAPI`, fake dialers, in-process SSH server |
| | `kvm_integration_test.go` (`-tags=integration`) | one real VM: image fetch, Create, idempotent re-Create, Running with IP, ListByTag, Delete, pool state afterwards; `TestKVMCheck_realHost` runs the health check against the host and creates nothing | a **real hypervisor** — see below |
| `internal/health` | `probe_test.go` | probes through a **real CONNECT-capable proxy stub** to a real TLS server: tunnel success, refused CONNECT, wrong status, dead proxy, plain-http forward | `httptest` + hijacked bidirectional tunnel |
| | `engine_test.go` | thresholds, first-verdict, latency suppression matrix, dropped-event retry, stale-probe UID guard, tick scheduling/seeding/pruning, `Start` end-to-end | fake `client.Reader` + injected `probeFn` |
| `internal/lease` | `store_test.go` | capacity, `MaxLeases=0`, all three selection tie-breaks, target-scoped vs global cooldowns, expiry via fake clock, report-on-retained-lease, idempotent release, **40 concurrent acquires never exceeding capacity** | injectable clock; the concurrency case is why `-race` matters |
| `internal/discovery` | `server_test.go` | auth matrix (incl. `/healthz` exemption), list filtering, grant shape, full 409 arithmetic, invalid TTL/body/result, idempotent release, report→cooldown→409 round trip, `Start`/shutdown | `httptest` over the real handler chain, fake cache reader, **real** `lease.Store` |
| `internal/gc` | `gc_test.go` | the true-orphan matrix (live/deleting/young/unlabelled all kept), broken-provider isolation, list-failure skips sweep, namespace guard, sweep loop | fake reader + canned-list provider |
| `internal/metrics` | `metrics_test.go` | scrape-time collectors (`GatherAndCompare`), per-proxy series cleanup via `ForgetProxy`, label counts, fresh-registry-per-test property | `prometheus/client_golang/testutil` |
| `internal/providerhealth` | `status_test.go`, `monitor_test.go` | `buildStatus` (the Ready condition, per-target transition times, truncation); objects created with the label and patched, the round timeout, no-checker Unknown, invalid names, transition-only logging, cleanup of labelled unconfigured objects, the checks-off path, `ValidateSchedule` | fake client with a cluster-scoped RESTMapper + `funcr` capture |
| | `envtest_test.go` | the generated `ProviderHealth` schema against a real API server: cluster scope, status subresource, required fields, list-map keys, the 1024-character message limit | envtest (skipped under `-short`) |
| `internal/metrics` | `metrics_test.go` | scrape-time collectors (`GatherAndCompare`) incl. the provider-target gauges, per-proxy series cleanup via `ForgetProxy`, label counts, fresh-registry-per-test property | `prometheus/client_golang/testutil` |
Conventions (from `CLAUDE.md`): stdlib `testing`, table-driven by default,
`t.Parallel()` where safe, tests next to source, ginkgo only in the
@@ -89,7 +94,7 @@ envtest suite the scaffold generated.
- **Live GCP**: no test talks to Google. The seam boundary
(`buildInsertRequest` + classification) is tested exhaustively instead.
- **Live libvirt, by default**: the KVM unit tests run over an in-memory
fake of the 17 libvirt calls the provider uses. Whether libvirt accepts
fake of the 20 libvirt calls the provider uses. Whether libvirt accepts
the domain XML, whether a raw qcow2 upload plus pool refresh yields a
usable backing image, and whether the guest actually boots and reports
an address are provable only on a real host — that is the opt-in
@@ -109,13 +114,19 @@ had.
export KVM_TEST_HOST=nas1.home.lan:22 # SSH endpoint
export KVM_TEST_USER=virt # must be in the libvirt group
export KVM_TEST_SSH_KEY=$HOME/.ssh/id_ed25519 # private key for that user
export KVM_TEST_KNOWN_HOSTS=$HOME/.ssh/kvm_known_hosts # the hypervisors' host keys
export KVM_TEST_BRIDGE=br0 # bridge to a network with DHCP
export KVM_TEST_IMAGE_URL=https://cloud.debian.org/images/cloud/trixie/latest/debian-13-generic-amd64.qcow2
make test-integration-kvm
```
Optional: `KVM_TEST_KNOWN_HOSTS` (without it host keys are **not**
verified — the log says so), `KVM_TEST_IMAGE_SHA256`, `KVM_TEST_POOL`
`KVM_TEST_KNOWN_HOSTS` is technically optional, but leaving it out turns
host-key verification **off** for the run (the log says so); keep a
`kvm_known_hosts` holding just the hypervisors rather than pointing the
test at your everyday `known_hosts`, so a re-keyed hypervisor is a
one-line fix in a file nothing else uses.
Also optional: `KVM_TEST_IMAGE_SHA256`, `KVM_TEST_POOL`
(default `default`), `KVM_TEST_SOCKET`, `KVM_TEST_TIMEOUT` (default
`20m`, must cover the first-time image download), `KVM_TEST_KEEP`
to leave the VM behind for `virsh console` when something goes wrong,
@@ -139,13 +150,24 @@ but no per-VM overlay or seed. It also tries a TCP connect to port 22 on
the guest, but only logs the outcome — the machine running the test may
not route to the bridge network.
`TestKVMCheck_realHost` runs the provider health check
([kvm-provider.md](kvm-provider.md#health-check)) against the same host.
It must find the host reachable and the pool running, and it creates
nothing. It needs the same variables; to run only this test:
```sh
go test -tags=integration -count=1 -v -run TestKVMCheck ./internal/provider/kvm/
```
The test is not part of `make test` and not run in CI. Keep it compiling
with `go vet -tags=integration ./...` when the provider's internals move.
## The scaffolded `make test-e2e` suite
`test/e2e/` is the kubebuilder-generated smoke suite (build image → kind
cluster → deploy → assert the manager pod runs and serves metrics). It
cluster → deploy → assert the manager pod runs, publishes a Ready
`ProviderHealth` for the `kubernetes` provider, and serves metrics that
include that provider's `proxy_operator_provider_target_up` series). It
compiles only under `-tags=e2e`, manages its own kind cluster
(`make test-e2e` / `make cleanup-test-e2e`), and has been kept compiling
(`go vet -tags=e2e ./...` is part of the routine) but is **not part of
+28 -6
View File
@@ -270,8 +270,14 @@ func (r *ProxyReconciler) reconcileDelete(ctx context.Context, p *crawlv1alpha1.
if p.Status.ProviderID == "" {
// Nothing was ever recorded as created; orphan GC reaps any stray
// instance a crashed create might have left behind.
controllerutil.RemoveFinalizer(p, crawlv1alpha1.FinalizerName)
return ctrl.Result{}, r.Update(ctx, p)
return r.releaseFinalizer(ctx, p)
}
if p.Annotations[crawlv1alpha1.AnnotationSkipCleanup] == "true" {
// Checked before the provider lookup so it also releases a Proxy whose
// provider is gone from the config. The instance is left running.
logf.FromContext(ctx).Info("skip-cleanup annotation set; abandoning the instance",
"providerID", p.Status.ProviderID, "provider", p.Spec.Provider)
return r.releaseFinalizer(ctx, p)
}
prov, ok := r.Providers[p.Spec.Provider]
if !ok {
@@ -279,11 +285,22 @@ func (r *ProxyReconciler) reconcileDelete(ctx context.Context, p *crawlv1alpha1.
"provider %q is not configured; cannot clean up instance %s", p.Spec.Provider, p.Status.ProviderID)
}
_, err := prov.Get(ctx, p.Status.ProviderID)
if provider.Class(err) == provider.ErrNotFound {
controllerutil.RemoveFinalizer(p, crawlv1alpha1.FinalizerName)
return ctrl.Result{}, r.Update(ctx, p)
switch {
case err == nil:
case provider.Class(err) == provider.ErrNotFound:
return r.releaseFinalizer(ctx, p)
case provider.Class(err) == provider.ErrPermanent:
// Get will never answer for this instance — a host retired from the
// config, a providerID the provider can no longer resolve. Delete is
// idempotent and may still finish, so try it instead of polling a Get
// that cannot converge, and release the object when it does.
if delErr := prov.Delete(ctx, p.Status.ProviderID); delErr != nil {
return r.deletionFailure(errors.Join(err, delErr))
}
if err != nil {
logf.FromContext(ctx).Info("instance unreachable but cleanup succeeded; removing the finalizer",
"providerID", p.Status.ProviderID, "error", err.Error())
return r.releaseFinalizer(ctx, p)
default:
return r.deletionFailure(err)
}
if err := prov.Delete(ctx, p.Status.ProviderID); err != nil {
@@ -294,6 +311,11 @@ func (r *ProxyReconciler) reconcileDelete(ctx context.Context, p *crawlv1alpha1.
return ctrl.Result{RequeueAfter: r.DeletionPoll}, nil
}
func (r *ProxyReconciler) releaseFinalizer(ctx context.Context, p *crawlv1alpha1.Proxy) (ctrl.Result, error) {
controllerutil.RemoveFinalizer(p, crawlv1alpha1.FinalizerName)
return ctrl.Result{}, r.Update(ctx, p)
}
func (r *ProxyReconciler) reconcileExternal(_ context.Context, p *crawlv1alpha1.Proxy) (ctrl.Result, error) {
if p.Spec.Endpoint == nil {
// CEL guarantees an endpoint on any object that went through the API
+67
View File
@@ -2,6 +2,7 @@ package controller
import (
"context"
"slices"
"testing"
"time"
@@ -78,6 +79,10 @@ func notFoundErr() error {
return provider.Wrap(provider.ErrNotFound, "get", "stub", "some-id", nil)
}
func permanentErr() error {
return provider.Wrap(provider.ErrPermanent, "get", "stub", "some-id", nil)
}
func testScheme(t *testing.T) *runtime.Scheme {
t.Helper()
s := runtime.NewScheme()
@@ -125,6 +130,15 @@ func withSpecHashAnnotation(hash string) func(*crawlv1alpha1.Proxy) {
}
}
func withSkipCleanup() func(*crawlv1alpha1.Proxy) {
return func(p *crawlv1alpha1.Proxy) {
if p.Annotations == nil {
p.Annotations = map[string]string{}
}
p.Annotations[crawlv1alpha1.AnnotationSkipCleanup] = "true"
}
}
func deleting() func(*crawlv1alpha1.Proxy) {
return func(p *crawlv1alpha1.Proxy) {
now := metav1.Now()
@@ -375,6 +389,59 @@ func TestReconcile_actionTable(t *testing.T) {
assertProxyGone(t, r)
},
},
{
// A permanent Get (a retired KVM host, a providerID the provider
// cannot resolve) used to retry forever with the finalizer on.
name: "deletion with an unreachable instance still deletes and releases",
proxy: managedProxy(deleting(), withProviderID("stub-id-1")),
stub: &stubProvider{getErr: permanentErr()},
wantResult: ctrl.Result{},
verify: func(t *testing.T, r *ProxyReconciler, stub *stubProvider) {
assertProxyGone(t, r)
if stub.deleteCalls != 1 {
t.Errorf("deleteCalls = %d, want 1 — Delete must be tried when Get cannot answer", stub.deleteCalls)
}
},
},
{
name: "deletion keeps the finalizer when cleanup itself fails",
proxy: managedProxy(deleting(), withProviderID("stub-id-1")),
stub: &stubProvider{
getErr: permanentErr(),
deleteErr: provider.Wrap(provider.ErrPermanent, "delete", "stub", "stub-id-1", nil),
},
wantResult: ctrl.Result{},
wantErr: true,
verify: func(t *testing.T, r *ProxyReconciler, _ *stubProvider) {
// getProxy fails the test if the object is gone, which is what
// finalizer removal would have caused.
if p := getProxy(t, r); !slices.Contains(p.Finalizers, crawlv1alpha1.FinalizerName) {
t.Error("finalizer removed although cleanup failed; the instance would leak silently")
}
},
},
{
name: "skip-cleanup annotation abandons the instance",
proxy: managedProxy(deleting(), withProviderID("stub-id-1"), withSkipCleanup()),
stub: &stubProvider{getInst: &provider.Instance{ID: "stub-id-1", State: provider.StateRunning}},
wantResult: ctrl.Result{},
verify: func(t *testing.T, r *ProxyReconciler, stub *stubProvider) {
assertProxyGone(t, r)
if stub.getCalls != 0 || stub.deleteCalls != 0 {
t.Errorf("getCalls/deleteCalls = %d/%d, want 0/0 — the provider must not be asked", stub.getCalls, stub.deleteCalls)
}
},
},
{
name: "skip-cleanup annotation releases a proxy whose provider is gone",
proxy: managedProxy(deleting(), withProviderID("stub-id-1"), withSkipCleanup(),
func(p *crawlv1alpha1.Proxy) { p.Spec.Provider = "retired" }),
stub: &stubProvider{},
wantResult: ctrl.Result{},
verify: func(t *testing.T, r *ProxyReconciler, _ *stubProvider) {
assertProxyGone(t, r)
},
},
{
name: "deletion deletes the instance and polls",
proxy: managedProxy(deleting(), withProviderID("stub-id-1")),
+48
View File
@@ -117,3 +117,51 @@ func (c *constCollector) Collect(ch chan<- prometheus.Metric) {
ch <- prometheus.MustNewConstMetric(c.desc, prometheus.GaugeValue, float64(value), label)
}
}
// ProviderTarget is one provider health-check target as the gauges see it.
type ProviderTarget struct {
Provider, Target string
Up bool
Duration time.Duration
CheckedAt time.Time
}
// RegisterProviderTargets registers the provider-check gauges. Like the
// other scrape-time collectors they are read from the source at every
// scrape, so a target that goes away stops being reported rather than
// leaving a stale series behind.
func RegisterProviderTargets(reg prometheus.Registerer, read func() []ProviderTarget) error {
labels := []string{"provider", "target"}
return reg.Register(&providerTargetCollector{
up: prometheus.NewDesc("proxy_operator_provider_target_up",
"Whether the target passed its latest provider health check (1) or not (0).", labels, nil),
duration: prometheus.NewDesc("proxy_operator_provider_check_duration_seconds",
"Duration of the target's latest provider health check.", labels, nil),
checked: prometheus.NewDesc("proxy_operator_provider_check_timestamp_seconds",
"Unix time of the target's latest provider health check.", labels, nil),
read: read,
})
}
type providerTargetCollector struct {
up, duration, checked *prometheus.Desc
read func() []ProviderTarget
}
func (c *providerTargetCollector) Describe(ch chan<- *prometheus.Desc) {
ch <- c.up
ch <- c.duration
ch <- c.checked
}
func (c *providerTargetCollector) Collect(ch chan<- prometheus.Metric) {
for _, t := range c.read() {
up := 0.0
if t.Up {
up = 1
}
ch <- prometheus.MustNewConstMetric(c.up, prometheus.GaugeValue, up, t.Provider, t.Target)
ch <- prometheus.MustNewConstMetric(c.duration, prometheus.GaugeValue, t.Duration.Seconds(), t.Provider, t.Target)
ch <- prometheus.MustNewConstMetric(c.checked, prometheus.GaugeValue, float64(t.CheckedAt.UnixNano())/1e9, t.Provider, t.Target)
}
}
+42
View File
@@ -111,3 +111,45 @@ func TestRegister_freshRegistryPerTest(t *testing.T) {
register(t, m1, nil, 0)
register(t, m2, nil, 0)
}
func TestRegisterProviderTargets_scrape(t *testing.T) {
t.Parallel()
checked := time.Unix(1757602800, 0)
targets := []ProviderTarget{
{Provider: "kvm-home", Target: "nas1", Up: true, Duration: 84 * time.Millisecond, CheckedAt: checked},
{Provider: "kvm-home", Target: "nas2", Up: false, Duration: 15 * time.Second, CheckedAt: checked},
}
reg := prometheus.NewRegistry()
if err := RegisterProviderTargets(reg, func() []ProviderTarget { return targets }); err != nil {
t.Fatalf("RegisterProviderTargets: %v", err)
}
expected := `
# HELP proxy_operator_provider_check_duration_seconds Duration of the target's latest provider health check.
# TYPE proxy_operator_provider_check_duration_seconds gauge
proxy_operator_provider_check_duration_seconds{provider="kvm-home",target="nas1"} 0.084
proxy_operator_provider_check_duration_seconds{provider="kvm-home",target="nas2"} 15
# HELP proxy_operator_provider_check_timestamp_seconds Unix time of the target's latest provider health check.
# TYPE proxy_operator_provider_check_timestamp_seconds gauge
proxy_operator_provider_check_timestamp_seconds{provider="kvm-home",target="nas1"} 1.7576028e+09
proxy_operator_provider_check_timestamp_seconds{provider="kvm-home",target="nas2"} 1.7576028e+09
# HELP proxy_operator_provider_target_up Whether the target passed its latest provider health check (1) or not (0).
# TYPE proxy_operator_provider_target_up gauge
proxy_operator_provider_target_up{provider="kvm-home",target="nas1"} 1
proxy_operator_provider_target_up{provider="kvm-home",target="nas2"} 0
`
if err := testutil.GatherAndCompare(reg, strings.NewReader(expected)); err != nil {
t.Error(err)
}
}
func TestRegisterProviderTargets_emptySnapshotHasNoSeries(t *testing.T) {
t.Parallel()
reg := prometheus.NewRegistry()
if err := RegisterProviderTargets(reg, func() []ProviderTarget { return nil }); err != nil {
t.Fatalf("RegisterProviderTargets: %v", err)
}
if n, err := testutil.GatherAndCount(reg); err != nil || n != 0 {
t.Errorf("series before the first check = %d (%v), want 0", n, err)
}
}
+46
View File
@@ -0,0 +1,46 @@
package provider
import (
"context"
"time"
)
// Reasons shared by every Checker, so a listing reads the same across
// providers. Providers add their own for failures only they can tell
// apart (KVM's SSHHandshakeFailed or PoolNotActive, say).
const (
ReasonReachable = "Reachable"
ReasonTimeout = "Timeout"
ReasonUnreachable = "Unreachable"
ReasonUnauthorized = "Unauthorized"
ReasonForbidden = "Forbidden"
ReasonAPIError = "APIError"
)
// TargetStatus is one target's verdict from a Checker.
type TargetStatus struct {
// Name identifies the target within its provider: a KVM host name,
// "apiserver", a GCP project.
Name string
// Address is what the check connected to, for humans.
Address string
Ready bool
// Reason is ReasonReachable when Ready, otherwise why not.
Reason string
// Message is human-readable detail, usually the error.
Message string
Latency time.Duration
// Details are provider-specific facts gathered by the check.
Details map[string]string
}
// Checker is the optional interface of a provider that can verify its
// backend is reachable and usable without touching any instance. The
// provider health monitor type-asserts for it on the unwrapped providers,
// so it stays out of the Provider contract.
type Checker interface {
// Check returns one verdict per target, in a stable order. It never
// fails as a whole — a failure is a verdict — and must return promptly
// once ctx ends.
Check(ctx context.Context) []TargetStatus
}
+47 -9
View File
@@ -22,10 +22,18 @@ type KVMConfig struct {
// DiskSizeGB is the capacity of each VM's overlay disk. Default 10. The
// guest's cloud-init growpart expands the root filesystem into it.
DiskSizeGB int64 `json:"diskSizeGb,omitempty"`
// VendorData is cloud-init vendor-data placed on every seed next to the
// Proxy's own user-data. Default: installs and starts qemu-guest-agent so
// the provider can read the VM's IP even before it shows up in ARP.
// VendorData is optional cloud-init vendor-data placed on every seed next
// to the Proxy's own user-data. Note that cloud-init drops any top-level
// key of a vendor-data cloud-config that the user-data also defines.
// When InstallGuestAgent is on, this document and the agent script are
// carried as separate parts of a MIME multipart vendor-data.
VendorData string `json:"vendorData,omitempty"`
// InstallGuestAgent adds a vendor-data script that installs and starts
// qemu-guest-agent, which is how the provider learns a VM's address
// before the host's ARP table has seen traffic from it. Default true.
// Turn it off for images that already ship the agent, or that cannot
// reach a package mirror; discovery then depends on ARP alone.
InstallGuestAgent *bool `json:"installGuestAgent,omitempty"`
// Images are named base images; placement.image names a key here (or is
// an http(s) URL directly). The provider downloads each image into a
// host's storage pool on first use and caches it there.
@@ -80,8 +88,31 @@ type KVMHost struct {
Bridge string `json:"bridge"`
}
// Accepted KVMImage.Format and KVMImage.Firmware values.
const (
KVMFormatQcow2 = "qcow2"
KVMFormatRaw = "raw"
KVMFirmwareUEFI = "uefi"
KVMFirmwareBIOS = "bios"
)
// IsHTTPURL reports whether s is an absolute http(s) URL with a host, the
// only kind of image source the kvm provider can fetch.
func IsHTTPURL(s string) bool {
u, err := url.Parse(s)
return err == nil && (u.Scheme == "http" || u.Scheme == "https") && u.Host != ""
}
var sha256Hex = regexp.MustCompile(`^[0-9a-fA-F]{64}$`)
// kvmIdentifier constrains the config names that are pasted into
// identifiers elsewhere: a host name goes into the providerID
// hosts/<name>/domains/<vm>, which is split on "/" to parse, and an image
// key becomes the file name of the cached base volume. A slash in either
// produces an ID or a path that never resolves again, so the VM leaks with
// nothing able to address it.
var kvmIdentifier = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.-]*$`)
func validateKVM(prefix string, c *KVMConfig) error {
if c == nil {
return fmt.Errorf("%s: kvm block is required", prefix)
@@ -97,18 +128,21 @@ func validateKVM(prefix string, c *KVMConfig) error {
}
for name, img := range c.Images {
p := fmt.Sprintf("%s: kvm.images[%q]", prefix, name)
u, err := url.Parse(img.URL)
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
if !kvmIdentifier.MatchString(name) {
return fmt.Errorf("%s: key must be letters, digits, dots, dashes or underscores"+
" — it becomes the cached volume name base-<key>.<format>", p)
}
if !IsHTTPURL(img.URL) {
return fmt.Errorf("%s.url must be an http(s) URL (got %q)", p, img.URL)
}
if img.SHA256 != "" && !sha256Hex.MatchString(img.SHA256) {
return fmt.Errorf("%s.sha256 must be 64 hex characters", p)
}
if img.Format != "" && img.Format != "qcow2" && img.Format != "raw" {
return fmt.Errorf("%s.format must be qcow2 or raw (got %q)", p, img.Format)
if img.Format != "" && img.Format != KVMFormatQcow2 && img.Format != KVMFormatRaw {
return fmt.Errorf("%s.format must be %s or %s (got %q)", p, KVMFormatQcow2, KVMFormatRaw, img.Format)
}
if img.Firmware != "" && img.Firmware != "uefi" && img.Firmware != "bios" {
return fmt.Errorf("%s.firmware must be uefi or bios (got %q)", p, img.Firmware)
if img.Firmware != "" && img.Firmware != KVMFirmwareUEFI && img.Firmware != KVMFirmwareBIOS {
return fmt.Errorf("%s.firmware must be %s or %s (got %q)", p, KVMFirmwareUEFI, KVMFirmwareBIOS, img.Firmware)
}
}
if len(c.MachineTypes) == 0 {
@@ -128,6 +162,10 @@ func validateKVM(prefix string, c *KVMConfig) error {
if h.Name == "" {
return fmt.Errorf("%s: name is required", p)
}
if !kvmIdentifier.MatchString(h.Name) {
return fmt.Errorf("%s: name %q must be letters, digits, dots, dashes or underscores"+
" — it becomes part of the providerID hosts/<name>/domains/<vm>", p, h.Name)
}
if seen[h.Name] {
return fmt.Errorf("%s: duplicate host name %q", p, h.Name)
}
+13
View File
@@ -129,6 +129,19 @@ hosts:
yaml: minimal("sshKeyFile: /key\nknownHostsFile: /kh\nmachineTypes:\n small: {vcpus: 1, memoryMiB: 1024}\nhosts:\n - {name: nas1, address: a:22, user: virt, bridge: br0}\n - {name: nas1, address: b:22, user: virt, bridge: br0}\n"),
wantErrSub: `duplicate host name "nas1"`,
},
{
// A slash would produce hosts/dc1/rack2/domains/<vm>, which
// parseProviderID can never split back, so the VM would be
// unreachable for Get, Delete and GC alike.
name: "host name with a slash",
yaml: minimal("sshKeyFile: /key\nknownHostsFile: /kh\nmachineTypes:\n small: {vcpus: 1, memoryMiB: 1024}\nhosts:\n - {name: dc1/rack2, address: a:22, user: virt, bridge: br0}\n"),
wantErrSub: `name "dc1/rack2" must be letters, digits, dots, dashes or underscores`,
},
{
name: "image key with a slash",
yaml: minimal("sshKeyFile: /key\nknownHostsFile: /kh\nimages:\n ../etc/d: {url: https://x/y.qcow2}\n" + hostsAndTypes),
wantErrSub: `images["../etc/d"]: key must be letters, digits, dots, dashes or underscores`,
},
{
name: "host missing address",
yaml: minimal("sshKeyFile: /key\nknownHostsFile: /kh\nmachineTypes:\n small: {vcpus: 1, memoryMiB: 1024}\nhosts:\n - {name: nas1, user: virt, bridge: br0}\n"),
+78
View File
@@ -0,0 +1,78 @@
package gcp
import (
"context"
"errors"
"net/http"
"time"
"cloud.google.com/go/compute/apiv1/computepb"
"google.golang.org/api/googleapi"
"google.golang.org/api/iterator"
"google.golang.org/protobuf/proto"
"gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/internal/provider"
)
var _ provider.Checker = (*Provider)(nil)
// computeAddress is the endpoint the SDK talks to, shown only to humans.
const computeAddress = "compute.googleapis.com"
// managedFilter selects the instances this operator created.
const managedFilter = "labels." + provider.LabelManaged + " = " + provider.LabelManagedYes
// Check asks the project for one page holding at most one managed
// instance. That exercises the credentials, the Compute API and the
// compute.instances.list permission orphan GC depends on, in a single
// request whatever the project's size. The target is the project: the
// provider config names no zones.
func (p *Provider) Check(ctx context.Context) []provider.TargetStatus {
start := time.Now()
err := p.api.Probe(ctx, &computepb.AggregatedListInstancesRequest{
Project: p.cfg.Project,
Filter: proto.String(managedFilter),
MaxResults: proto.Uint32(1),
ReturnPartialSuccess: proto.Bool(true),
})
ts := provider.TargetStatus{Name: p.cfg.Project, Address: computeAddress, Latency: time.Since(start)}
if err != nil {
logAPIError(p.logger(ctx), "check", err)
ts.Reason = checkReason(ctx, err)
ts.Message = err.Error()
return []provider.TargetStatus{ts}
}
ts.Ready = true
ts.Reason = provider.ReasonReachable
return []provider.TargetStatus{ts}
}
// Probe fetches the first page of an aggregated list and discards it. The
// SDK treats MaxResults as the page size, so AggregatedList, which drains
// the iterator, would still walk every page of the project.
func (r *realInstances) Probe(ctx context.Context, req *computepb.AggregatedListInstancesRequest) error {
_, err := r.client.AggregatedList(ctx, req).Next()
if err == iterator.Done {
return nil
}
return err
}
// checkReason maps a failed probe to a reason. A token that cannot be
// obtained never reaches the API, so it has no status code and lands on
// Unreachable, with the detail in the message.
func checkReason(ctx context.Context, err error) string {
var gerr *googleapi.Error
switch {
case ctx.Err() != nil, errors.Is(err, context.DeadlineExceeded):
return provider.ReasonTimeout
case !errors.As(err, &gerr):
return provider.ReasonUnreachable
case gerr.Code == http.StatusUnauthorized:
return provider.ReasonUnauthorized
case gerr.Code == http.StatusForbidden:
return provider.ReasonForbidden
default:
return provider.ReasonAPIError
}
}
+165
View File
@@ -0,0 +1,165 @@
package gcp
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"net/url"
"sync"
"syscall"
"testing"
compute "cloud.google.com/go/compute/apiv1"
"cloud.google.com/go/compute/apiv1/computepb"
"google.golang.org/api/googleapi"
"google.golang.org/api/option"
"google.golang.org/protobuf/proto"
"gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/internal/provider"
)
func TestCheck_reachable(t *testing.T) {
t.Parallel()
api := &fakeAPI{}
p := newTestProvider(api)
got := p.Check(context.Background())
if len(got) != 1 {
t.Fatalf("Check() returned %d targets, want 1", len(got))
}
ts := got[0]
if !ts.Ready || ts.Reason != provider.ReasonReachable || ts.Message != "" {
t.Errorf("Check() = %+v, want ready and Reachable", ts)
}
if ts.Name != "my-project" || ts.Address != "compute.googleapis.com" {
t.Errorf("target = %q at %q, want my-project at compute.googleapis.com", ts.Name, ts.Address)
}
want := &computepb.AggregatedListInstancesRequest{
Project: "my-project",
Filter: proto.String("labels.proxy-operator-managed = true"),
MaxResults: proto.Uint32(1),
ReturnPartialSuccess: proto.Bool(true),
}
if !proto.Equal(api.probeReq, want) {
t.Errorf("probe request = %v, want %v", api.probeReq, want)
}
}
func TestCheck_failureReasons(t *testing.T) {
t.Parallel()
ended, cancel := context.WithCancel(context.Background())
cancel()
tests := []struct {
name string
ctx context.Context
err error
want string
}{
{"unauthorized", context.Background(), gerr(401), provider.ReasonUnauthorized},
{"forbidden", context.Background(), gerr(403, "forbidden"), provider.ReasonForbidden},
{"api disabled", context.Background(), gerr(403, "accessNotConfigured"), provider.ReasonForbidden},
{"server error", context.Background(), gerr(500), provider.ReasonAPIError},
{"deadline", context.Background(), &url.Error{
Op: "Get", URL: "https://compute.googleapis.com/", Err: context.DeadlineExceeded,
}, provider.ReasonTimeout},
{"context ended", ended, context.Canceled, provider.ReasonTimeout},
{"connection refused", context.Background(), &url.Error{
Op: "Get", URL: "https://compute.googleapis.com/", Err: syscall.ECONNREFUSED,
}, provider.ReasonUnreachable},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
p := newTestProvider(&fakeAPI{probeErr: tc.err})
got := p.Check(tc.ctx)
if len(got) != 1 {
t.Fatalf("Check() returned %d targets, want 1", len(got))
}
if got[0].Ready || got[0].Reason != tc.want || got[0].Message != tc.err.Error() {
t.Errorf("Check() = %+v, want not ready, reason %s, message %q", got[0], tc.want, tc.err.Error())
}
})
}
}
// TestRealInstances_Probe runs the real SDK client against a local server,
// since the point of Probe is how many requests the SDK makes.
func TestRealInstances_Probe(t *testing.T) {
t.Parallel()
tests := []struct {
name string
status int
body string
wantCode int
}{
{
name: "fetches only the first page",
status: http.StatusOK,
body: `{"items":{"zones/europe-west1-b":{"instances":[{"name":"proxy-a"}]}},"nextPageToken":"page-2"}`,
},
{
name: "empty project",
status: http.StatusOK,
body: `{}`,
},
{
name: "api error keeps its status",
status: http.StatusForbidden,
body: `{"error":{"code":403,"message":"Compute Engine API has not been used","errors":[{"reason":"accessNotConfigured"}]}}`,
wantCode: http.StatusForbidden,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
var (
mu sync.Mutex
reqs []*url.URL
)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
reqs = append(reqs, r.URL)
mu.Unlock()
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(tc.status)
_, _ = w.Write([]byte(tc.body))
}))
t.Cleanup(srv.Close)
ctx := context.Background()
c, err := compute.NewInstancesRESTClient(ctx, option.WithEndpoint(srv.URL), option.WithoutAuthentication())
if err != nil {
t.Fatalf("NewInstancesRESTClient: %v", err)
}
t.Cleanup(func() { _ = c.Close() })
err = (&realInstances{client: c}).Probe(ctx, &computepb.AggregatedListInstancesRequest{
Project: "my-project",
MaxResults: proto.Uint32(1),
})
var gerr *googleapi.Error
switch {
case tc.wantCode == 0 && err != nil:
t.Fatalf("Probe() = %v, want nil", err)
case tc.wantCode != 0 && (!errors.As(err, &gerr) || gerr.Code != tc.wantCode):
t.Fatalf("Probe() = %v, want a googleapi error with code %d", err, tc.wantCode)
}
mu.Lock()
defer mu.Unlock()
if len(reqs) != 1 {
t.Fatalf("server saw %d requests, want 1", len(reqs))
}
if got := reqs[0].Query().Get("maxResults"); got != "1" {
t.Errorf("maxResults = %q, want 1", got)
}
if want := "/compute/v1/projects/my-project/aggregated/instances"; reqs[0].Path != want {
t.Errorf("path = %q, want %q", reqs[0].Path, want)
}
})
}
}
+4 -3
View File
@@ -33,6 +33,8 @@ type instancesAPI interface {
Get(ctx context.Context, req *computepb.GetInstanceRequest) (*computepb.Instance, error)
Delete(ctx context.Context, req *computepb.DeleteInstanceRequest) (opName string, err error)
AggregatedList(ctx context.Context, req *computepb.AggregatedListInstancesRequest) ([]*computepb.Instance, error)
// Probe fetches only the first page of an aggregated list; see Check.
Probe(ctx context.Context, req *computepb.AggregatedListInstancesRequest) error
}
// realInstances adapts *compute.InstancesClient to the seam.
@@ -221,17 +223,16 @@ func (p *Provider) Delete(ctx context.Context, providerID string) error {
// entire GC sweep.
func (p *Provider) ListByTag(ctx context.Context) ([]provider.Instance, error) {
log := p.logger(ctx)
filter := fmt.Sprintf("labels.%s = %s", provider.LabelManaged, provider.LabelManagedYes)
instances, err := p.api.AggregatedList(ctx, &computepb.AggregatedListInstancesRequest{
Project: p.cfg.Project,
Filter: proto.String(filter),
Filter: proto.String(managedFilter),
ReturnPartialSuccess: proto.Bool(true),
})
if err != nil {
logAPIError(log, "list", err)
return nil, p.wrapErr("list", "", err)
}
log.V(1).Info("GCP instances listed", "filter", filter, "count", len(instances))
log.V(1).Info("GCP instances listed", "filter", managedFilter, "count", len(instances))
out := make([]provider.Instance, 0, len(instances))
for _, inst := range instances {
conv := toInstance(inst, lastPathSegment(inst.GetZone()))
+8
View File
@@ -31,6 +31,14 @@ type fakeAPI struct {
listReq *computepb.AggregatedListInstancesRequest
listInsts []*computepb.Instance
listErr error
probeReq *computepb.AggregatedListInstancesRequest
probeErr error
}
func (f *fakeAPI) Probe(_ context.Context, req *computepb.AggregatedListInstancesRequest) error {
f.probeReq = req
return f.probeErr
}
func (f *fakeAPI) Insert(_ context.Context, req *computepb.InsertInstanceRequest) (string, error) {
+53
View File
@@ -0,0 +1,53 @@
package kubernetes
import (
"context"
"errors"
"time"
corev1 "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"sigs.k8s.io/controller-runtime/pkg/client"
"gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/internal/provider"
)
var _ provider.Checker = (*Provider)(nil)
// Check lists at most one managed Pod across the cluster: the same client,
// verb and scope ListByTag and orphan GC rely on, so a pass means the API
// server answers and the operator's RBAC still covers them. It does not
// prove create or delete rights.
func (p *Provider) Check(ctx context.Context) []provider.TargetStatus {
start := time.Now()
var pods corev1.PodList
err := p.client.List(ctx, &pods, client.MatchingLabels{
provider.LabelManaged: provider.LabelManagedYes,
}, client.Limit(1))
ts := provider.TargetStatus{Name: "apiserver", Address: p.apiHost, Latency: time.Since(start)}
if err != nil {
ts.Reason = checkReason(ctx, err)
ts.Message = err.Error()
return []provider.TargetStatus{ts}
}
ts.Ready = true
ts.Reason = provider.ReasonReachable
return []provider.TargetStatus{ts}
}
func checkReason(ctx context.Context, err error) string {
var status apierrors.APIStatus
switch {
case ctx.Err() != nil, errors.Is(err, context.DeadlineExceeded),
apierrors.IsTimeout(err), apierrors.IsServerTimeout(err):
return provider.ReasonTimeout
case apierrors.IsUnauthorized(err):
return provider.ReasonUnauthorized
case apierrors.IsForbidden(err):
return provider.ReasonForbidden
case errors.As(err, &status):
return provider.ReasonAPIError
default:
return provider.ReasonUnreachable
}
}
+114
View File
@@ -0,0 +1,114 @@
package kubernetes
import (
"context"
"errors"
"net/url"
"syscall"
"testing"
corev1 "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/client/fake"
"sigs.k8s.io/controller-runtime/pkg/client/interceptor"
"gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/internal/provider"
)
type listFunc func(ctx context.Context, c client.WithWatch, list client.ObjectList, opts ...client.ListOption) error
func newCheckProvider(list listFunc) *Provider {
scheme := runtime.NewScheme()
if err := corev1.AddToScheme(scheme); err != nil {
panic(err)
}
c := fake.NewClientBuilder().WithScheme(scheme).WithInterceptorFuncs(interceptor.Funcs{List: list}).Build()
p := newWithClient(c, provider.ProviderConfig{Name: "kubernetes"})
p.apiHost = "https://10.96.0.1:443"
return p
}
func TestCheck_reachable(t *testing.T) {
t.Parallel()
p := newTestProvider(testPod("ns", "a", "uid-a", corev1.PodRunning, "10.0.0.1"))
p.apiHost = "https://10.96.0.1:443"
got := p.Check(context.Background())
if len(got) != 1 {
t.Fatalf("Check() returned %d targets, want 1", len(got))
}
ts := got[0]
if !ts.Ready || ts.Reason != provider.ReasonReachable || ts.Message != "" {
t.Errorf("Check() = %+v, want ready and Reachable", ts)
}
if ts.Name != "apiserver" || ts.Address != "https://10.96.0.1:443" {
t.Errorf("target = %q at %q, want apiserver at https://10.96.0.1:443", ts.Name, ts.Address)
}
}
func TestCheck_listOptions(t *testing.T) {
t.Parallel()
var got client.ListOptions
p := newCheckProvider(func(ctx context.Context, c client.WithWatch, list client.ObjectList, opts ...client.ListOption) error {
got.ApplyOptions(opts)
return c.List(ctx, list, opts...)
})
p.Check(context.Background())
if got.Limit != 1 {
t.Errorf("Limit = %d, want 1", got.Limit)
}
if got.Namespace != "" {
t.Errorf("Namespace = %q, want cluster-wide", got.Namespace)
}
want := provider.LabelManaged + "=" + provider.LabelManagedYes
if got.LabelSelector == nil || got.LabelSelector.String() != want {
t.Errorf("LabelSelector = %v, want %s", got.LabelSelector, want)
}
}
func TestCheck_failureReasons(t *testing.T) {
t.Parallel()
pods := schema.GroupResource{Resource: "pods"}
ended, cancel := context.WithCancel(context.Background())
cancel()
tests := []struct {
name string
ctx context.Context
err error
want string
}{
{"forbidden", context.Background(), apierrors.NewForbidden(pods, "", errors.New("rbac denied")), provider.ReasonForbidden},
{"unauthorized", context.Background(), apierrors.NewUnauthorized("token expired"), provider.ReasonUnauthorized},
{"api timeout", context.Background(), apierrors.NewTimeoutError("slow etcd", 1), provider.ReasonTimeout},
{"server timeout", context.Background(), apierrors.NewServerTimeout(pods, "list", 1), provider.ReasonTimeout},
{"context ended", ended, context.Canceled, provider.ReasonTimeout},
{"internal error", context.Background(), apierrors.NewInternalError(errors.New("etcd down")), provider.ReasonAPIError},
{"connection refused", context.Background(), &url.Error{
Op: "Get", URL: "https://10.96.0.1:443/api/v1/pods", Err: syscall.ECONNREFUSED,
}, provider.ReasonUnreachable},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
p := newCheckProvider(func(context.Context, client.WithWatch, client.ObjectList, ...client.ListOption) error {
return tc.err
})
got := p.Check(tc.ctx)
if len(got) != 1 {
t.Fatalf("Check() returned %d targets, want 1", len(got))
}
if got[0].Ready || got[0].Reason != tc.want || got[0].Message != tc.err.Error() {
t.Errorf("Check() = %+v, want not ready, reason %s, message %q", got[0], tc.want, tc.err.Error())
}
})
}
}
+5 -1
View File
@@ -36,6 +36,8 @@ const defaultImage = "ubuntu/squid:6.6-24.04_edge"
type Provider struct {
client client.Client
image string
// apiHost is the API server URL, shown by Check; empty in tests.
apiHost string
}
// New builds a kubernetes Provider from its config block. Satisfies
@@ -64,7 +66,9 @@ func NewWithTransportWrapper(_ context.Context, cfg provider.ProviderConfig, wra
if err != nil {
return nil, fmt.Errorf("kubernetes provider %q: building client: %w", cfg.Name, err)
}
return newWithClient(c, cfg), nil
p := newWithClient(c, cfg)
p.apiHost = restCfg.Host
return p, nil
}
// newWithClient builds a Provider around an already-constructed client,
+166
View File
@@ -0,0 +1,166 @@
package kvm
import (
"context"
"errors"
"fmt"
"sync"
"time"
libvirt "github.com/digitalocean/go-libvirt"
"gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/internal/provider"
)
var _ provider.Checker = (*Provider)(nil)
// Reasons only the KVM check can tell apart; the rest are provider-wide.
const (
reasonSSHMaterialUnavailable = "SSHMaterialUnavailable"
reasonSSHHandshakeFailed = "SSHHandshakeFailed"
reasonLibvirtConnectFailed = "LibvirtConnectFailed"
reasonLibvirtError = "LibvirtError"
reasonPoolNotFound = "PoolNotFound"
reasonPoolNotActive = "PoolNotActive"
)
// hostFacts is what a successful check learns about a host.
type hostFacts struct {
version uint64
hostname string
poolState libvirt.StoragePoolState
capacity uint64
available uint64
}
// Check verifies every configured host concurrently, in kvm.hosts order.
// It goes through the same pooled connection Create and Get use, so the
// SSH and libvirt handshakes only happen on the first check or after the
// link dropped; then two cheap RPCs prove the daemon answers, and the
// storage pool VMs are created in must exist and be running.
func (p *Provider) Check(ctx context.Context) []provider.TargetStatus {
out := make([]provider.TargetStatus, len(p.cfg.Hosts))
var wg sync.WaitGroup
for i, h := range p.cfg.Hosts {
wg.Go(func() { out[i] = p.checkHost(ctx, h) })
}
wg.Wait()
return out
}
// checkHost goes through probe, so a check that runs out of time abandons
// its RPCs rather than closing the connection the reconciles share. fn
// returns the RPC errors untouched, since a transport error still drops the
// link; a pool that exists but is stopped says nothing about the link, so
// that verdict is reached afterwards. facts is read only after probe
// returned nil, since an abandoned fn may still write it.
func (p *Provider) checkHost(ctx context.Context, host provider.KVMHost) provider.TargetStatus {
ts := provider.TargetStatus{
Name: host.Name,
Address: host.Address,
Details: map[string]string{"user": host.User, "pool": host.Pool},
}
start := time.Now()
var facts hostFacts
err := p.hosts.probe(ctx, p.logger(ctx), host, func(api libvirtAPI) error {
version, err := api.ConnectGetLibVersion()
if err != nil {
return err
}
hostname, err := api.ConnectGetHostname()
if err != nil {
return err
}
pool, err := api.StoragePoolLookupByName(host.Pool)
if err != nil {
return err
}
state, capacity, _, available, err := api.StoragePoolGetInfo(pool)
if err != nil {
return err
}
facts = hostFacts{
version: version,
hostname: hostname,
poolState: libvirt.StoragePoolState(state),
capacity: capacity,
available: available,
}
return nil
})
ts.Latency = time.Since(start)
if err != nil {
ts.Reason = checkReason(err)
ts.Message = err.Error()
return ts
}
ts.Details["hostname"] = facts.hostname
ts.Details["libvirtVersion"] = formatLibVersion(facts.version)
ts.Details["poolState"] = poolStateName(facts.poolState)
ts.Details["poolCapacity"] = formatGiB(facts.capacity)
ts.Details["poolAvailable"] = formatGiB(facts.available)
if facts.poolState != libvirt.StoragePoolRunning {
ts.Reason = reasonPoolNotActive
ts.Message = fmt.Sprintf("storage pool %q is %s", host.Pool, poolStateName(facts.poolState))
return ts
}
ts.Ready = true
ts.Reason = provider.ReasonReachable
return ts
}
// checkReason names where a failed check broke. The dial stage wins over
// a timeout: "the SSH handshake did not finish" says more than "the check
// ran out of time", and the message still carries the deadline.
func checkReason(err error) string {
var de *dialError
if errors.As(err, &de) {
switch de.stage {
case stageSSHMaterial:
return reasonSSHMaterialUnavailable
case stageSSHHandshake:
return reasonSSHHandshakeFailed
case stageLibvirtConnect:
return reasonLibvirtConnectFailed
default:
return provider.ReasonUnreachable
}
}
switch {
case errors.Is(err, context.DeadlineExceeded), errors.Is(err, context.Canceled), errors.Is(err, errProbeBusy):
return provider.ReasonTimeout
case hasCode(err, libvirt.ErrNoStoragePool):
return reasonPoolNotFound
case isLibvirtError(err):
return reasonLibvirtError
default:
return provider.ReasonUnreachable
}
}
// formatLibVersion decodes libvirt's major*1e6 + minor*1e3 + micro.
func formatLibVersion(v uint64) string {
return fmt.Sprintf("%d.%d.%d", v/1_000_000, v/1_000%1_000, v%1_000)
}
// poolStateName uses virsh's words for a pool state.
func poolStateName(s libvirt.StoragePoolState) string {
switch s {
case libvirt.StoragePoolInactive:
return "inactive"
case libvirt.StoragePoolBuilding:
return "building"
case libvirt.StoragePoolRunning:
return "running"
case libvirt.StoragePoolDegraded:
return "degraded"
case libvirt.StoragePoolInaccessible:
return "inaccessible"
default:
return fmt.Sprintf("state %d", s)
}
}
func formatGiB(bytes uint64) string {
return fmt.Sprintf("%.1fGiB", float64(bytes)/(1<<30))
}
+319
View File
@@ -0,0 +1,319 @@
package kvm
import (
"context"
"errors"
"fmt"
"io"
"maps"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
libvirt "github.com/digitalocean/go-libvirt"
"gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/internal/provider"
)
// withoutLatency zeroes the one field a test cannot predict.
func withoutLatency(in []provider.TargetStatus) []provider.TargetStatus {
out := make([]provider.TargetStatus, len(in))
for i, ts := range in {
ts.Latency = 0
out[i] = ts
}
return out
}
func TestCheck_reachable(t *testing.T) {
t.Parallel()
api := newFakeAPI("default")
api.poolCapacity = 10 << 30
api.pools["default"]["disk.qcow2"] = &fakeVol{capacity: 4 << 30}
p := newTestProvider(hostFakes{"nas1": api}, "nas1")
got := withoutLatency(p.Check(context.Background()))
want := provider.TargetStatus{
Name: "nas1",
Address: "nas1:22",
Ready: true,
Reason: provider.ReasonReachable,
Details: map[string]string{
"user": "virt",
"pool": "default",
"hostname": "fake-host",
"libvirtVersion": "10.2.0",
"poolState": "running",
"poolCapacity": "10.0GiB",
"poolAvailable": "6.0GiB",
},
}
if len(got) != 1 {
t.Fatalf("Check() returned %d targets, want 1", len(got))
}
if got[0].Name != want.Name || got[0].Address != want.Address || got[0].Ready != want.Ready ||
got[0].Reason != want.Reason || got[0].Message != "" || !maps.Equal(got[0].Details, want.Details) {
t.Errorf("Check() = %+v, want %+v", got[0], want)
}
}
func TestCheck_perHostVerdicts(t *testing.T) {
t.Parallel()
p := newTestProvider(hostFakes{"nas1": newFakeAPI("default"), "nas3": newFakeAPI("default")}, "nas1", "nas2", "nas3")
got := p.Check(context.Background())
if len(got) != 3 {
t.Fatalf("Check() returned %d targets, want 3", len(got))
}
for i, want := range []struct {
name string
ready bool
}{{"nas1", true}, {"nas2", false}, {"nas3", true}} {
if got[i].Name != want.name || got[i].Ready != want.ready {
t.Errorf("target %d = %s ready=%v, want %s ready=%v", i, got[i].Name, got[i].Ready, want.name, want.ready)
}
}
if got[1].Reason != provider.ReasonUnreachable || !strings.Contains(got[1].Message, errHostOff.Error()) {
t.Errorf("unreachable host = %s %q, want Unreachable carrying %q", got[1].Reason, got[1].Message, errHostOff)
}
if got[1].Details["pool"] != "default" || got[1].Details["hostname"] != "" {
t.Errorf("unreachable host details = %v, want the configured pool and nothing learned", got[1].Details)
}
}
// TestCheck_libvirtVerdicts also checks twice per case: none of these
// verdicts is evidence against the link, so the connection must survive.
func TestCheck_libvirtVerdicts(t *testing.T) {
t.Parallel()
tests := []struct {
name string
setup func(api *fakeAPI)
pools []string
wantReason string
wantMessage string
}{
{
name: "pool inactive",
pools: []string{"default"},
setup: func(api *fakeAPI) { api.poolStates["default"] = libvirt.StoragePoolInactive },
wantReason: reasonPoolNotActive,
wantMessage: `storage pool "default" is inactive`,
},
{
name: "pool missing",
pools: []string{"other"},
wantReason: reasonPoolNotFound,
wantMessage: "no storage pool with matching name 'default'",
},
{
name: "daemon error",
pools: []string{"default"},
setup: func(api *fakeAPI) { api.fail["ConnectGetHostname"] = lvError(libvirt.ErrInternalError, "boom") },
wantReason: reasonLibvirtError,
wantMessage: "boom",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
api := newFakeAPI(tc.pools...)
if tc.setup != nil {
tc.setup(api)
}
var dials atomic.Int32
dial := func(ctx context.Context, h provider.KVMHost) (*hostClient, error) {
dials.Add(1)
return hostFakes{"nas1": api}.dial(ctx, h)
}
pc := testConfig("nas1")
p := newWithDialer(pc.Name, *pc.KVM, dial, nil)
for range 2 {
got := p.Check(context.Background())
if got[0].Ready || got[0].Reason != tc.wantReason || !strings.Contains(got[0].Message, tc.wantMessage) {
t.Errorf("Check() = %+v, want not ready, %s, message containing %q", got[0], tc.wantReason, tc.wantMessage)
}
}
if n := dials.Load(); n != 1 {
t.Errorf("dialed %d times over two checks, want 1", n)
}
})
}
}
func TestCheck_hostsCheckedConcurrently(t *testing.T) {
t.Parallel()
nas2Dialing := make(chan struct{})
dial := func(ctx context.Context, h provider.KVMHost) (*hostClient, error) {
switch h.Name {
case "nas1":
select {
case <-nas2Dialing:
case <-ctx.Done():
return nil, ctx.Err()
}
case "nas2":
close(nas2Dialing)
}
return &hostClient{api: newFakeAPI("default"), close: func() {}}, nil
}
pc := testConfig("nas1", "nas2")
p := newWithDialer(pc.Name, *pc.KVM, dial, nil)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
got := p.Check(ctx)
for _, ts := range got {
if !ts.Ready {
t.Errorf("%s = %s %q, want ready: a sequential check would time out nas1", ts.Name, ts.Reason, ts.Message)
}
}
}
// A daemon that stopped answering times the check out, but the connection
// the reconciles share stays up, and the next check does not stack a
// second hung RPC on top of the first.
func TestCheck_timeoutKeepsTheConnection(t *testing.T) {
t.Parallel()
api := newFakeAPI("default")
block := make(chan struct{})
t.Cleanup(func() { close(block) })
blocked := &blockingAPI{fakeAPI: api, block: block}
var closed atomic.Bool
dial := func(context.Context, provider.KVMHost) (*hostClient, error) {
return &hostClient{api: blocked, close: func() { closed.Store(true) }}, nil
}
pc := testConfig("nas1")
p := newWithDialer(pc.Name, *pc.KVM, dial, nil)
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
defer cancel()
got := p.Check(ctx)
if got[0].Ready || got[0].Reason != provider.ReasonTimeout {
t.Errorf("Check() = %+v, want Timeout", got[0])
}
if closed.Load() {
t.Error("the timed-out check closed the connection the reconciles share")
}
again := p.Check(context.Background())
if again[0].Reason != provider.ReasonTimeout || !strings.Contains(again[0].Message, "still waiting") {
t.Errorf("second Check() = %+v, want an immediate Timeout while the first still waits", again[0])
}
}
// A reconcile redialing a dead host holds the host's slot for minutes; the
// check must give up at its own deadline rather than wait that out.
func TestCheck_doesNotWaitBehindAReconcileDial(t *testing.T) {
t.Parallel()
release := make(chan struct{})
dialing := make(chan struct{})
var once sync.Once
dial := func(context.Context, provider.KVMHost) (*hostClient, error) {
once.Do(func() { close(dialing) })
<-release
return nil, errHostOff
}
pc := testConfig("nas1")
p := newWithDialer(pc.Name, *pc.KVM, dial, nil)
reconcile := make(chan error, 1)
go func() {
_, err := p.Get(context.Background(), formatProviderID("nas1", testName))
reconcile <- err
}()
<-dialing
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
defer cancel()
start := time.Now()
got := p.Check(ctx)
if waited := time.Since(start); waited > time.Second {
t.Errorf("Check() took %s behind the reconcile's dial, want about 50ms", waited)
}
if got[0].Reason != provider.ReasonTimeout {
t.Errorf("Check() = %+v, want Timeout", got[0])
}
close(release)
<-reconcile
}
// blockingAPI hangs in ConnectGetLibVersion until block closes, like a
// daemon that stopped answering.
type blockingAPI struct {
*fakeAPI
block chan struct{}
}
func (b *blockingAPI) ConnectGetLibVersion() (uint64, error) {
<-b.block
return 0, io.EOF
}
func TestCheckReason(t *testing.T) {
t.Parallel()
tests := []struct {
name string
err error
want string
}{
{"ssh material", &dialError{stage: stageSSHMaterial, err: fmt.Errorf("%w: parsing key", provider.ErrPermanent)}, reasonSSHMaterialUnavailable},
{"tcp", &dialError{stage: stageTCP, err: errors.New("ssh dial: connection refused")}, provider.ReasonUnreachable},
{"ssh handshake", &dialError{stage: stageSSHHandshake, err: errors.New("ssh handshake: unable to authenticate")}, reasonSSHHandshakeFailed},
{"libvirt connect", &dialError{stage: stageLibvirtConnect, err: errors.New("libvirt connect via /sock: EOF")}, reasonLibvirtConnectFailed},
{"stage survives the pool's wrapping", fmt.Errorf("connecting to host nas1 (nas1:22): %w",
&dialError{stage: stageSSHHandshake, err: context.DeadlineExceeded}), reasonSSHHandshakeFailed},
{"deadline", context.DeadlineExceeded, provider.ReasonTimeout},
{"cancelled", context.Canceled, provider.ReasonTimeout},
{"previous check still waiting", errProbeBusy, provider.ReasonTimeout},
{"no pool", lvError(libvirt.ErrNoStoragePool, "no pool"), reasonPoolNotFound},
{"other libvirt error", lvError(libvirt.ErrInternalError, "boom"), reasonLibvirtError},
{"link died mid-call", io.EOF, provider.ReasonUnreachable},
}
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)
}
})
}
}
func TestDialError_keepsClassification(t *testing.T) {
t.Parallel()
err := fmt.Errorf("connecting to host nas1: %w",
&dialError{stage: stageSSHMaterial, err: fmt.Errorf("%w: parsing kvm.sshKeyFile: bad", provider.ErrPermanent)})
if got := classify(err); !errors.Is(got, provider.ErrPermanent) {
t.Errorf("classify() = %v, want ErrPermanent through the dialError", got)
}
if !isClassified(err) {
t.Error("isClassified() = false, want true")
}
if want := "connecting to host nas1: permanent: parsing kvm.sshKeyFile: bad"; !strings.HasSuffix(err.Error(), "parsing kvm.sshKeyFile: bad") {
t.Errorf("message = %q, want it unchanged, like %q", err.Error(), want)
}
}
func TestFormatLibVersion(t *testing.T) {
t.Parallel()
tests := []struct {
in uint64
want string
}{
{10002000, "10.2.0"},
{11006001, "11.6.1"},
{9000000, "9.0.0"},
}
for _, tc := range tests {
if got := formatLibVersion(tc.in); got != tc.want {
t.Errorf("formatLibVersion(%d) = %s, want %s", tc.in, got, tc.want)
}
}
}
+178 -31
View File
@@ -5,9 +5,11 @@ import (
"errors"
"fmt"
"io"
"io/fs"
"net"
"os"
"sync"
"sync/atomic"
"time"
libvirt "github.com/digitalocean/go-libvirt"
@@ -43,6 +45,9 @@ type libvirtAPI interface {
StorageVolUpload(vol libvirt.StorageVol, r io.Reader, offset, length uint64, flags libvirt.StorageVolUploadFlags) error
StorageVolDownload(vol libvirt.StorageVol, w io.Writer, offset, length uint64, flags libvirt.StorageVolDownloadFlags) error
StorageVolDelete(vol libvirt.StorageVol, flags libvirt.StorageVolDeleteFlags) error
ConnectGetLibVersion() (uint64, error)
ConnectGetHostname() (string, error)
StoragePoolGetInfo(pool libvirt.StoragePool) (state uint8, capacity, allocation, available uint64, err error)
}
// hostClient is one live connection to a hypervisor.
@@ -67,6 +72,27 @@ func (c *hostClient) alive() bool {
// real one; tests inject their own.
type hostDialer func(ctx context.Context, host provider.KVMHost) (*hostClient, error)
// dialStage names the step of a connection attempt that failed, so the
// health check can say why a host is unreachable without parsing messages.
type dialStage string
const (
stageSSHMaterial dialStage = "ssh-material"
stageTCP dialStage = "tcp"
stageSSHHandshake dialStage = "ssh-handshake"
stageLibvirtConnect dialStage = "libvirt-connect"
)
// dialError tags a dial failure with its stage. It keeps the message and
// unwraps to the cause, so classify sees exactly what it saw before.
type dialError struct {
stage dialStage
err error
}
func (e *dialError) Error() string { return e.err.Error() }
func (e *dialError) Unwrap() error { return e.err }
// hostPool caches one connection per host, dialing lazily and redialing
// after the connection drops. Dials for different hosts don't block each
// other; concurrent callers for the same host share a single dial.
@@ -77,9 +103,16 @@ type hostPool struct {
entries map[string]*hostEntry
}
// hostEntry is one host's slot in the pool. lock is a one-slot semaphore
// rather than a mutex so that waiting for it can end with the caller's
// context: whoever holds it may be dialing a dead host for as long as the
// dial takes.
type hostEntry struct {
mu sync.Mutex
lock chan struct{}
client *hostClient
// probing is set while a probe's fn runs, including one whose caller
// has already given up on it.
probing atomic.Bool
}
func newHostPool(dial hostDialer) *hostPool {
@@ -91,16 +124,37 @@ func (p *hostPool) entry(name string) *hostEntry {
defer p.mu.Unlock()
e, ok := p.entries[name]
if !ok {
e = &hostEntry{}
e = &hostEntry{lock: make(chan struct{}, 1)}
p.entries[name] = e
}
return e
}
// acquire takes e.lock. Only waiting is bounded by ctx: a free slot is
// taken even when ctx has already ended, so an uncontended caller behaves
// as it did with a mutex, and a contended one gives up with ctx's error.
func (e *hostEntry) acquire(ctx context.Context) error {
select {
case e.lock <- struct{}{}:
return nil
default:
}
select {
case e.lock <- struct{}{}:
return nil
case <-ctx.Done():
return ctx.Err()
}
}
func (e *hostEntry) release() { <-e.lock }
func (p *hostPool) get(ctx context.Context, log logr.Logger, host provider.KVMHost) (*hostClient, error) {
e := p.entry(host.Name)
e.mu.Lock()
defer e.mu.Unlock()
if err := e.acquire(ctx); err != nil {
return nil, fmt.Errorf("waiting for the connection to host %s (%s): %w", host.Name, host.Address, err)
}
defer e.release()
if e.client != nil {
if e.client.alive() {
return e.client, nil
@@ -124,8 +178,8 @@ func (p *hostPool) get(ctx context.Context, log logr.Logger, host provider.KVMHo
// connection that was already replaced by a redial is left alone.
func (p *hostPool) drop(host string, c *hostClient) {
e := p.entry(host)
e.mu.Lock()
defer e.mu.Unlock()
e.lock <- struct{}{}
defer e.release()
if e.client == c {
e.client = nil
}
@@ -135,29 +189,74 @@ func (p *hostPool) drop(host string, c *hostClient) {
// call runs fn against host's connection. go-libvirt RPCs take no context
// and block until the daemon answers, so when ctx ends first the connection
// is closed — which unblocks fn with an error — and ctx.Err() is returned.
// A transport error (anything that is not a libvirt.Error) also drops the
// cached connection so the next call redials instead of reusing a dead
// link. When log has V(1) enabled, every RPC fn makes is traced through it
// (see tracingAPI), as are connection events.
// A transport error also drops the cached connection so the next call
// redials instead of reusing a dead link. "Transport error" means anything
// that is neither a libvirt.Error nor an error fn already classified with a
// provider sentinel (a failed seed build, a bad placement): those are local
// verdicts, not evidence about the link. When log has V(1) enabled, every
// RPC fn makes is traced through it (see tracingAPI), as are connection
// events.
func (p *hostPool) call(ctx context.Context, log logr.Logger, host provider.KVMHost, fn func(api libvirtAPI) error) error {
log = log.WithName("libvirt")
c, err := p.get(ctx, log, host)
if err != nil {
return err
}
api := c.api
if log.V(1).Enabled() {
api = &tracingAPI{inner: c.api, log: log}
}
err = awaitOrCancel(ctx, func() error { return fn(api) }, func() {
err = awaitOrCancel(ctx, func() error { return fn(traced(c, log)) }, func() {
log.V(1).Info("libvirt connection dropped", "reason", "context cancelled", "error", ctx.Err().Error())
p.drop(host.Name, c)
})
if err != nil && !isLibvirtError(err) && !errors.Is(err, ctx.Err()) {
p.dropOnTransportError(ctx, log, host, c, err)
return err
}
// errProbeBusy refuses a probe while the previous one on the same host has
// not come back yet.
var errProbeBusy = errors.New("the previous check of this host is still waiting for libvirt to answer")
// probe is call for the health check, which must never cost the reconciles
// their connection: when ctx ends first, fn is abandoned and the connection
// left alone. A link that is really dead is closed by the keepalive, which
// ends fn too. At most one probe per host runs at a time, an abandoned one
// included, so a daemon that stopped answering cannot pile up goroutines;
// until it answers, further probes get errProbeBusy.
func (p *hostPool) probe(ctx context.Context, log logr.Logger, host provider.KVMHost, fn func(api libvirtAPI) error) error {
log = log.WithName("libvirt")
e := p.entry(host.Name)
if !e.probing.CompareAndSwap(false, true) {
return errProbeBusy
}
c, err := p.get(ctx, log, host)
if err != nil {
e.probing.Store(false)
return err
}
err = awaitOrCancel(ctx, func() error {
defer e.probing.Store(false)
return fn(traced(c, log))
}, func() {
log.V(1).Info("libvirt probe abandoned, connection kept", "error", ctx.Err().Error())
})
p.dropOnTransportError(ctx, log, host, c, err)
return err
}
// traced returns c's API, logging every RPC when log has V(1) enabled.
func traced(c *hostClient, log logr.Logger) libvirtAPI {
if log.V(1).Enabled() {
return &tracingAPI{inner: c.api, log: log}
}
return c.api
}
// dropOnTransportError drops c when err is evidence against the link:
// neither a libvirt error, nor a verdict already classified, nor the
// caller's own context ending.
func (p *hostPool) dropOnTransportError(ctx context.Context, log logr.Logger, host provider.KVMHost, c *hostClient, err error) {
if err != nil && !isLibvirtError(err) && !isClassified(err) && !errors.Is(err, ctx.Err()) {
log.V(1).Info("libvirt connection dropped", "reason", "transport error", "error", err.Error())
p.drop(host.Name, c)
}
return err
}
// awaitOrCancel runs fn in the background and returns its result, unless ctx
@@ -177,14 +276,17 @@ func awaitOrCancel(ctx context.Context, fn func() error, abort func()) error {
}
// sshAuth holds the pieces of the SSH client config shared by every host:
// the private key and the host-key policy, both loaded once at startup so a
// missing or unreadable file fails the operator fast rather than on the
// first Create.
// the private key and the host-key policy. Both are loaded on the first
// connection rather than at startup — see lazySSH.
type sshAuth struct {
signer ssh.Signer
hostKey ssh.HostKeyCallback
}
// newSSHAuth loads the key material. A file that is not there yet stays
// unclassified, so the provider treats it as transient and heals when the
// Secret appears; content that will never parse is permanent, since
// retrying cannot fix it.
func newSSHAuth(cfg provider.KVMConfig) (*sshAuth, error) {
key, err := os.ReadFile(cfg.SSHKeyFile)
if err != nil {
@@ -192,23 +294,69 @@ func newSSHAuth(cfg provider.KVMConfig) (*sshAuth, error) {
}
signer, err := ssh.ParsePrivateKey(key)
if err != nil {
return nil, fmt.Errorf("parsing kvm.sshKeyFile: %w", err)
return nil, fmt.Errorf("%w: parsing kvm.sshKeyFile: %v", provider.ErrPermanent, err)
}
hostKey := ssh.InsecureIgnoreHostKey() //nolint:gosec // opt-in via config, documented as insecure
if !cfg.InsecureIgnoreHostKey {
hostKey, err = knownhosts.New(cfg.KnownHostsFile)
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
return nil, fmt.Errorf("loading kvm.knownHostsFile: %w", err)
}
if err != nil {
return nil, fmt.Errorf("%w: loading kvm.knownHostsFile: %v", provider.ErrPermanent, err)
}
}
return &sshAuth{signer: signer, hostKey: hostKey}, nil
}
// dialer returns the production hostDialer: SSH to the host, forward its
// libvirt unix socket through the connection, and open a libvirt session
// over that.
func (a *sshAuth) dialer() hostDialer {
return func(ctx context.Context, host provider.KVMHost) (*hostClient, error) {
// lazySSH defers loading the SSH material until it is first needed and
// caches it once it works. The kvm-ssh Secret is an optional mount: the
// manager has to start without it and keep every other provider running,
// and a Secret created afterwards has to heal the KVM proxies on the next
// reconcile rather than needing a restart.
type lazySSH struct {
cfg provider.KVMConfig
mu sync.Mutex
auth *sshAuth
}
// newLazySSH keeps only the SSH fields of cfg, since the loader lives as
// long as the Provider.
func newLazySSH(cfg provider.KVMConfig) *lazySSH {
return &lazySSH{cfg: provider.KVMConfig{
SSHKeyFile: cfg.SSHKeyFile,
KnownHostsFile: cfg.KnownHostsFile,
InsecureIgnoreHostKey: cfg.InsecureIgnoreHostKey,
}}
}
func (l *lazySSH) load() (*sshAuth, error) {
l.mu.Lock()
defer l.mu.Unlock()
if l.auth != nil {
return l.auth, nil
}
auth, err := newSSHAuth(l.cfg)
if err != nil {
return nil, err
}
l.auth = auth
return auth, nil
}
// dial is the production hostDialer.
func (l *lazySSH) dial(ctx context.Context, host provider.KVMHost) (*hostClient, error) {
a, err := l.load()
if err != nil {
return nil, &dialError{stage: stageSSHMaterial, err: err}
}
return a.dial(ctx, host)
}
// dial SSHes to the host, forwards its libvirt unix socket through the
// connection, and opens a libvirt session over that.
func (a *sshAuth) dial(ctx context.Context, host provider.KVMHost) (*hostClient, error) {
client, err := a.dialSSH(ctx, host)
if err != nil {
return nil, err
@@ -219,7 +367,6 @@ func (a *sshAuth) dialer() hostDialer {
return nil, err
}
return hc, nil
}
}
// dialSSH establishes the SSH connection. The handshake is bounded by ctx's
@@ -233,7 +380,7 @@ func (a *sshAuth) dialSSH(ctx context.Context, host provider.KVMHost) (*ssh.Clie
var d net.Dialer
nc, err := d.DialContext(ctx, "tcp", host.Address)
if err != nil {
return nil, fmt.Errorf("ssh dial: %w", err)
return nil, &dialError{stage: stageTCP, err: fmt.Errorf("ssh dial: %w", err)}
}
_ = nc.SetDeadline(deadline)
conn, chans, reqs, err := ssh.NewClientConn(nc, host.Address, &ssh.ClientConfig{
@@ -243,7 +390,7 @@ func (a *sshAuth) dialSSH(ctx context.Context, host provider.KVMHost) (*ssh.Clie
})
if err != nil {
nc.Close()
return nil, fmt.Errorf("ssh handshake: %w", err)
return nil, &dialError{stage: stageSSHHandshake, err: fmt.Errorf("ssh handshake: %w", err)}
}
_ = nc.SetDeadline(time.Time{})
return ssh.NewClient(conn, chans, reqs), nil
@@ -267,7 +414,7 @@ func (u unixForward) Dial() (net.Conn, error) {
func connectLibvirt(ctx context.Context, client *ssh.Client, socket string) (*hostClient, error) {
lv := libvirt.NewWithDialer(unixForward{client: client, path: socket})
if err := awaitOrCancel(ctx, lv.Connect, func() { client.Close() }); err != nil {
return nil, fmt.Errorf("libvirt connect via %s: %w", socket, err)
return nil, &dialError{stage: stageLibvirtConnect, err: fmt.Errorf("libvirt connect via %s: %w", socket, err)}
}
stop := make(chan struct{})
var once sync.Once
+313 -74
View File
@@ -7,6 +7,7 @@ import (
"crypto/rand"
"encoding/pem"
"errors"
"fmt"
"io"
"net"
"os"
@@ -109,41 +110,48 @@ func TestHostPool_call_redialsAfterDisconnect(t *testing.T) {
}
}
func TestHostPool_call_transportErrorDropsConnection(t *testing.T) {
// Only a transport error is evidence about the link. A daemon-reported
// libvirt error, or a verdict fn reached locally, must not cost the
// connection.
func TestHostPool_call_dropsConnectionOnlyOnTransportError(t *testing.T) {
t.Parallel()
tests := []struct {
name string
err error
wantDropped bool
}{
{"transport error", io.ErrUnexpectedEOF, true},
{"libvirt error", lvErr(libvirt.ErrNoDomain), false},
{"locally classified error", fmt.Errorf("%w: creating iso writer: read-only file system", provider.ErrPermanent), false},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
d := &fakeDialer{}
p := newHostPool(d.dial)
err := p.call(context.Background(), discard, testHost, func(libvirtAPI) error { return io.ErrUnexpectedEOF })
if !errors.Is(err, io.ErrUnexpectedEOF) {
t.Fatalf("call() error = %v, want the transport error passed through", err)
log, snapshot := captureLogger(1)
err := p.call(context.Background(), log, testHost, func(libvirtAPI) error { return tc.err })
if !errors.Is(err, tc.err) {
t.Fatalf("call() error = %v, want %v passed through", err, tc.err)
}
if !d.clients[0].closed.Load() {
t.Error("connection not closed after a transport error")
if got := d.clients[0].closed.Load(); got != tc.wantDropped {
t.Errorf("connection closed = %v, want %v", got, tc.wantDropped)
}
joined := strings.Join(snapshot(), "\n")
if logged := strings.Contains(joined, `"msg"="libvirt connection dropped"`) && strings.Contains(joined, `"reason"="transport error"`); logged != tc.wantDropped {
t.Errorf("transport drop logged = %v, want %v:\n%s", logged, tc.wantDropped, joined)
}
if err := p.call(context.Background(), discard, testHost, noop); err != nil {
t.Fatalf("call() after transport error = %v", err)
t.Fatalf("next call() error = %v", err)
}
if d.dialCount() != 2 {
t.Errorf("dials = %d, want 2", d.dialCount())
wantDials := 1
if tc.wantDropped {
wantDials = 2
}
}
func TestHostPool_call_libvirtErrorKeepsConnection(t *testing.T) {
t.Parallel()
d := &fakeDialer{}
p := newHostPool(d.dial)
err := p.call(context.Background(), discard, testHost, func(libvirtAPI) error { return lvErr(libvirt.ErrNoDomain) })
if !hasCode(err, libvirt.ErrNoDomain) {
t.Fatalf("call() error = %v, want the libvirt error passed through", err)
if d.dialCount() != wantDials {
t.Errorf("dials = %d, want %d", d.dialCount(), wantDials)
}
if err := p.call(context.Background(), discard, testHost, noop); err != nil {
t.Fatalf("call() error = %v", err)
}
if d.dialCount() != 1 {
t.Errorf("dials = %d, want 1: a daemon-reported error must not cost the connection", d.dialCount())
}
if d.clients[0].closed.Load() {
t.Error("connection closed after a libvirt error")
})
}
}
@@ -224,6 +232,97 @@ func TestHostPool_call_hostsAreIndependent(t *testing.T) {
}
}
// A dial to a dead host holds the host's slot for as long as it takes; a
// caller with a deadline must not wait past it.
func TestHostPool_get_waitingRespectsContext(t *testing.T) {
t.Parallel()
release := make(chan struct{})
dialing := make(chan struct{})
var once sync.Once
dial := func(context.Context, provider.KVMHost) (*hostClient, error) {
once.Do(func() { close(dialing) })
<-release
return nil, errors.New("ssh dial: connection timed out")
}
p := newHostPool(dial)
slow := make(chan error, 1)
go func() { slow <- p.call(context.Background(), discard, testHost, noop) }()
<-dialing
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
defer cancel()
start := time.Now()
err := p.call(ctx, discard, testHost, noop)
if !errors.Is(err, context.DeadlineExceeded) {
t.Errorf("call() error = %v, want the caller's deadline", err)
}
if waited := time.Since(start); waited > time.Second {
t.Errorf("call() waited %s behind another caller's dial, want about 50ms", waited)
}
close(release)
if err := <-slow; err == nil {
t.Error("the blocked dial succeeded, want its error")
}
}
func TestHostPool_probe_abandonsWithoutClosing(t *testing.T) {
t.Parallel()
d := &fakeDialer{}
p := newHostPool(d.dial)
unblock := make(chan struct{})
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
defer cancel()
err := p.probe(ctx, discard, testHost, func(libvirtAPI) error {
<-unblock // a daemon that stopped answering
return nil
})
if !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("probe() error = %v, want DeadlineExceeded", err)
}
if d.clients[0].closed.Load() {
t.Error("probe closed the shared connection when it gave up")
}
if err := p.probe(context.Background(), discard, testHost, noop); !errors.Is(err, errProbeBusy) {
t.Errorf("probe() while the abandoned one still waits = %v, want errProbeBusy", err)
}
if err := p.call(context.Background(), discard, testHost, noop); err != nil {
t.Errorf("call() on the kept connection = %v", err)
}
close(unblock)
deadline := time.Now().Add(time.Second)
for {
err := p.probe(context.Background(), discard, testHost, noop)
if err == nil {
break
}
if !errors.Is(err, errProbeBusy) || time.Now().After(deadline) {
t.Fatalf("probe() after the abandoned one returned = %v, want nil", err)
}
time.Sleep(time.Millisecond)
}
if d.dialCount() != 1 {
t.Errorf("dials = %d, want 1: nothing should have redialed", d.dialCount())
}
}
func TestHostPool_probe_dropsConnectionOnTransportError(t *testing.T) {
t.Parallel()
d := &fakeDialer{}
p := newHostPool(d.dial)
err := p.probe(context.Background(), discard, testHost, func(libvirtAPI) error { return io.ErrUnexpectedEOF })
if !errors.Is(err, io.ErrUnexpectedEOF) {
t.Fatalf("probe() error = %v, want io.ErrUnexpectedEOF passed through", err)
}
if !d.clients[0].closed.Load() {
t.Error("a transport error during a probe left the connection cached")
}
}
// --- SSH dial against an in-process server ---
func newKeyPair(t *testing.T) (ssh.Signer, []byte) {
@@ -387,13 +486,19 @@ func newSSHFixture(t *testing.T, answerGlobalRequests bool) *sshFixture {
}
}
func TestSSHAuth_dialSSH_forwardsUnixSocket(t *testing.T) {
t.Parallel()
f := newSSHFixture(t, true)
auth, err := newSSHAuth(f.cfg)
func mustSSHAuth(t *testing.T, cfg provider.KVMConfig) *sshAuth {
t.Helper()
auth, err := newSSHAuth(cfg)
if err != nil {
t.Fatalf("newSSHAuth() error = %v", err)
}
return auth
}
func TestSSHAuth_dialSSH_forwardsUnixSocket(t *testing.T) {
t.Parallel()
f := newSSHFixture(t, true)
auth := mustSSHAuth(t, f.cfg)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
client, err := auth.dialSSH(ctx, f.host)
@@ -427,10 +532,7 @@ func TestSSHAuth_dialSSH_rejectsUnknownHostKey(t *testing.T) {
if err := os.WriteFile(f.cfg.KnownHostsFile, []byte(line+"\n"), 0o600); err != nil {
t.Fatal(err)
}
auth, err := newSSHAuth(f.cfg)
if err != nil {
t.Fatalf("newSSHAuth() error = %v", err)
}
auth := mustSSHAuth(t, f.cfg)
client, err := auth.dialSSH(context.Background(), f.host)
if err == nil {
client.Close()
@@ -447,11 +549,7 @@ func TestSSHAuth_dialSSH_insecureIgnoreHostKey(t *testing.T) {
f := newSSHFixture(t, true)
f.cfg.KnownHostsFile = ""
f.cfg.InsecureIgnoreHostKey = true
auth, err := newSSHAuth(f.cfg)
if err != nil {
t.Fatalf("newSSHAuth() error = %v", err)
}
client, err := auth.dialSSH(context.Background(), f.host)
client, err := mustSSHAuth(t, f.cfg).dialSSH(context.Background(), f.host)
if err != nil {
t.Fatalf("dialSSH() error = %v", err)
}
@@ -465,11 +563,7 @@ func TestSSHAuth_dialSSH_rejectsWrongClientKey(t *testing.T) {
if err := os.WriteFile(f.keyFile, otherPEM, 0o600); err != nil {
t.Fatal(err)
}
auth, err := newSSHAuth(f.cfg)
if err != nil {
t.Fatalf("newSSHAuth() error = %v", err)
}
client, err := auth.dialSSH(context.Background(), f.host)
client, err := mustSSHAuth(t, f.cfg).dialSSH(context.Background(), f.host)
if err == nil {
client.Close()
t.Fatal("dialSSH() succeeded with a key the server does not know")
@@ -482,13 +576,9 @@ func TestSSHAuth_dialSSH_rejectsWrongClientKey(t *testing.T) {
func TestSSHAuth_dialSSH_cancelledContext(t *testing.T) {
t.Parallel()
f := newSSHFixture(t, true)
auth, err := newSSHAuth(f.cfg)
if err != nil {
t.Fatalf("newSSHAuth() error = %v", err)
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
if client, err := auth.dialSSH(ctx, f.host); err == nil {
if client, err := mustSSHAuth(t, f.cfg).dialSSH(ctx, f.host); err == nil {
client.Close()
t.Fatal("dialSSH() succeeded with an already-cancelled context")
}
@@ -524,14 +614,181 @@ func TestNewSSHAuth_missingFiles(t *testing.T) {
}
}
// The kvm-ssh Secret is an optional mount, so the provider has to be
// constructible without it and has to start working once it appears — the
// alternative was a constructor error that crash-looped the whole manager.
func TestLazySSH_load_healsWhenTheKeyAppears(t *testing.T) {
t.Parallel()
f := newSSHFixture(t, true)
keyPEM, err := os.ReadFile(f.keyFile)
if err != nil {
t.Fatal(err)
}
if err := os.Remove(f.keyFile); err != nil {
t.Fatal(err)
}
auth := newLazySSH(f.cfg).load
if _, err := auth(); err == nil {
t.Fatal("loading with no key file succeeded, want an error")
} else if class := classify(err); class != provider.ErrTransient {
t.Errorf("class = %v, want ErrTransient so the proxy retries once the Secret lands", class)
}
if err := os.WriteFile(f.keyFile, keyPEM, 0o600); err != nil {
t.Fatal(err)
}
loaded, err := auth()
if err != nil {
t.Fatalf("loading after the key appeared: %v", err)
}
// Cached from here on: the file may vanish again (a Secret rotated out)
// without breaking connections that already work.
if err := os.Remove(f.keyFile); err != nil {
t.Fatal(err)
}
if again, err := auth(); err != nil || again != loaded {
t.Errorf("auth() = %v, %v; want the cached value reused", again, err)
}
}
func TestLazySSH_load_unparseableKeyIsPermanent(t *testing.T) {
t.Parallel()
f := newSSHFixture(t, true)
if err := os.WriteFile(f.keyFile, []byte("not a key"), 0o600); err != nil {
t.Fatal(err)
}
_, err := newLazySSH(f.cfg).load()
if classify(err) != provider.ErrPermanent {
t.Errorf("class = %v, want ErrPermanent: retrying cannot fix a key that does not parse", classify(err))
}
}
// The dialer must authenticate with the material the lazy loader produced,
// not a stale or empty one. The echo socket is not a libvirt server, so the
// call gets as far as the libvirt handshake and no further; what matters is
// that it is past SSH by then.
func TestLazySSH_dial_authenticatesOverSSH(t *testing.T) {
t.Parallel()
f := newSSHFixture(t, true)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_, err := newLazySSH(f.cfg).dial(ctx, f.host)
if err == nil {
t.Fatal("dial to an echo socket succeeded, want a libvirt handshake error")
}
if strings.Contains(err.Error(), "sshKeyFile") || strings.Contains(err.Error(), "ssh handshake") {
t.Errorf("dial failed before libvirt: %v", err)
}
if paths := f.server.requestedPaths(); len(paths) != 1 || paths[0] != f.host.Socket {
t.Errorf("server saw forward requests %v, want [%s]", paths, f.host.Socket)
}
}
// New must not fail on missing key material, or one misconfigured provider
// aborts registry.Build and the manager exits before any controller starts.
func TestNew_missingSSHMaterialStillBuilds(t *testing.T) {
t.Parallel()
p, err := New(context.Background(), provider.ProviderConfig{
Name: "kvm-home", Type: "kvm",
KVM: &provider.KVMConfig{
SSHKeyFile: filepath.Join(t.TempDir(), "absent"),
KnownHostsFile: filepath.Join(t.TempDir(), "absent"),
Hosts: []provider.KVMHost{{Name: "nas1", Address: "nas1:22", User: "virt", Bridge: "br0"}},
},
})
if err != nil || p == nil {
t.Fatalf("New() = %v, %v; want a usable provider", p, err)
}
if _, err := p.Get(context.Background(), "hosts/nas1/domains/proxy-x"); provider.Class(err) != provider.ErrTransient {
t.Errorf("Get class = %v, want ErrTransient", provider.Class(err))
}
}
// The health check reports where a connection broke, so every stage of a
// real dial must come back tagged.
func TestLazySSH_dial_tagsTheFailingStage(t *testing.T) {
t.Parallel()
tests := []struct {
name string
mutate func(t *testing.T, f *sshFixture)
want dialStage
wantReason string
}{
{
name: "key material missing",
mutate: func(t *testing.T, f *sshFixture) { f.cfg.SSHKeyFile = filepath.Join(t.TempDir(), "absent") },
want: stageSSHMaterial,
wantReason: reasonSSHMaterialUnavailable,
},
{
name: "nothing listening",
mutate: func(t *testing.T, f *sshFixture) {
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
f.host.Address = ln.Addr().String()
ln.Close()
},
want: stageTCP,
wantReason: provider.ReasonUnreachable,
},
{
name: "host key not in known_hosts",
mutate: func(t *testing.T, f *sshFixture) {
impostor, _ := newKeyPair(t)
line := knownhosts.Line([]string{f.server.addr}, impostor.PublicKey())
if err := os.WriteFile(f.cfg.KnownHostsFile, []byte(line+"\n"), 0o600); err != nil {
t.Fatal(err)
}
},
want: stageSSHHandshake,
wantReason: reasonSSHHandshakeFailed,
},
{
name: "client key rejected",
mutate: func(t *testing.T, f *sshFixture) {
_, otherPEM := newKeyPair(t)
if err := os.WriteFile(f.keyFile, otherPEM, 0o600); err != nil {
t.Fatal(err)
}
},
want: stageSSHHandshake,
wantReason: reasonSSHHandshakeFailed,
},
{
name: "libvirt socket missing",
mutate: func(_ *testing.T, f *sshFixture) { f.host.Socket = "/nonexistent/libvirt-sock" },
want: stageLibvirtConnect,
wantReason: reasonLibvirtConnectFailed,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
f := newSSHFixture(t, true)
tc.mutate(t, f)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_, err := newLazySSH(f.cfg).dial(ctx, f.host)
var de *dialError
if !errors.As(err, &de) || de.stage != tc.want {
t.Fatalf("dial() error = %v, want a %s dialError", err, tc.want)
}
if got := checkReason(err); got != tc.wantReason {
t.Errorf("checkReason() = %s, want %s", got, tc.wantReason)
}
})
}
}
func TestKeepalive_closesClientWhenServerStalls(t *testing.T) {
t.Parallel()
f := newSSHFixture(t, false)
auth, err := newSSHAuth(f.cfg)
if err != nil {
t.Fatalf("newSSHAuth() error = %v", err)
}
client, err := auth.dialSSH(context.Background(), f.host)
client, err := mustSSHAuth(t, f.cfg).dialSSH(context.Background(), f.host)
if err != nil {
t.Fatalf("dialSSH() error = %v", err)
}
@@ -552,11 +809,7 @@ func TestKeepalive_closesClientWhenServerStalls(t *testing.T) {
func TestKeepalive_keepsHealthyClientOpen(t *testing.T) {
t.Parallel()
f := newSSHFixture(t, true)
auth, err := newSSHAuth(f.cfg)
if err != nil {
t.Fatalf("newSSHAuth() error = %v", err)
}
client, err := auth.dialSSH(context.Background(), f.host)
client, err := mustSSHAuth(t, f.cfg).dialSSH(context.Background(), f.host)
if err != nil {
t.Fatalf("dialSSH() error = %v", err)
}
@@ -613,20 +866,7 @@ func TestHostPool_call_noTracingBelowV1(t *testing.T) {
}
}
func TestHostPool_call_logsDropReason(t *testing.T) {
t.Parallel()
t.Run("transport error", func(t *testing.T) {
t.Parallel()
d := &fakeDialer{}
p := newHostPool(d.dial)
log, snapshot := captureLogger(1)
_ = p.call(context.Background(), log, testHost, func(libvirtAPI) error { return io.ErrUnexpectedEOF })
joined := strings.Join(snapshot(), "\n")
if !strings.Contains(joined, `"msg"="libvirt connection dropped"`) || !strings.Contains(joined, `"reason"="transport error"`) {
t.Errorf("output missing the drop line:\n%s", joined)
}
})
t.Run("context cancelled", func(t *testing.T) {
func TestHostPool_call_logsCancelDropReason(t *testing.T) {
t.Parallel()
d := &fakeDialer{}
p := newHostPool(d.dial)
@@ -650,5 +890,4 @@ func TestHostPool_call_logsDropReason(t *testing.T) {
if strings.Contains(joined, `"reason"="transport error"`) {
t.Errorf("cancel was also reported as a transport error:\n%s", joined)
}
})
}
+14 -4
View File
@@ -19,10 +19,8 @@ import (
// function to recognise. libvirt exposes no quota signal, so nothing maps
// to QuotaExceeded.
func classify(err error) error {
for _, class := range []error{provider.ErrNotFound, provider.ErrQuotaExceeded, provider.ErrPermanent, provider.ErrTransient} {
if errors.Is(err, class) {
return class
}
if isClassified(err) {
return provider.Class(err)
}
var lerr libvirt.Error
if !errors.As(err, &lerr) {
@@ -51,6 +49,18 @@ func isLibvirtError(err error) bool {
return errors.As(err, &lerr)
}
// isClassified reports whether err already carries one of the provider's
// sentinels, i.e. the package reached a verdict itself rather than hitting
// a transport failure.
func isClassified(err error) bool {
for _, class := range []error{provider.ErrNotFound, provider.ErrQuotaExceeded, provider.ErrPermanent, provider.ErrTransient} {
if errors.Is(err, class) {
return true
}
}
return false
}
func hasCode(err error, code libvirt.ErrorNumber) bool {
var lerr libvirt.Error
return errors.As(err, &lerr) && libvirt.ErrorNumber(lerr.Code) == code
+56 -3
View File
@@ -28,10 +28,15 @@ type fakeAPI struct {
// uploadHook, when set, sees every StorageVolUpload before the fake
// reads the stream; it can return an error to simulate a broken upload.
uploadHook func(vol string, r io.Reader) error
// hostname, libVersion, poolCapacity and poolStates answer the health
// check's RPCs. A pool missing from poolStates is running.
hostname string
libVersion uint64
poolCapacity uint64
poolStates map[string]libvirt.StoragePoolState
}
type fakeVol struct {
name string
format string
capacity int64
backing string
@@ -54,6 +59,11 @@ func newFakeAPI(pools ...string) *fakeAPI {
domains: map[string]*fakeDomain{},
fail: map[string]error{},
failOnce: map[string]error{},
hostname: "fake-host",
libVersion: 10002000,
poolCapacity: 100 << 30,
poolStates: map[string]libvirt.StoragePoolState{},
}
for _, p := range pools {
f.pools[p] = map[string]*fakeVol{}
@@ -94,7 +104,7 @@ func (f *fakeAPI) vol(pool, name string) *fakeVol {
func (f *fakeAPI) putVol(pool, name, format string, data []byte) {
f.mu.Lock()
defer f.mu.Unlock()
f.pools[pool][name] = &fakeVol{name: name, format: format, capacity: int64(len(data)), data: data}
f.pools[pool][name] = &fakeVol{format: format, capacity: int64(len(data)), data: data}
}
func (f *fakeAPI) volNames(pool string) []string {
@@ -348,7 +358,7 @@ func (f *fakeAPI) StorageVolCreateXML(pool libvirt.StoragePool, raw string, _ li
if _, exists := vols[v.Name]; exists {
return libvirt.StorageVol{}, lvError(libvirt.ErrStorageVolExist, "storage volume '%s' exists already", v.Name)
}
fv := &fakeVol{name: v.Name, format: v.Target.Format.Type, capacity: v.Capacity.Value}
fv := &fakeVol{format: v.Target.Format.Type, capacity: v.Capacity.Value}
if v.BackingStore != nil {
fv.backing = v.BackingStore.Path
}
@@ -410,3 +420,46 @@ func (f *fakeAPI) StorageVolDelete(vol libvirt.StorageVol, _ libvirt.StorageVolD
delete(f.pools[vol.Pool], vol.Name)
return nil
}
// --- host ---
func (f *fakeAPI) ConnectGetLibVersion() (uint64, error) {
f.mu.Lock()
defer f.mu.Unlock()
if err := f.enter("ConnectGetLibVersion"); err != nil {
return 0, err
}
return f.libVersion, nil
}
func (f *fakeAPI) ConnectGetHostname() (string, error) {
f.mu.Lock()
defer f.mu.Unlock()
if err := f.enter("ConnectGetHostname"); err != nil {
return "", err
}
return f.hostname, nil
}
// StoragePoolGetInfo reports the pool's state and poolCapacity minus the
// volumes' capacities as available space.
func (f *fakeAPI) StoragePoolGetInfo(pool libvirt.StoragePool) (uint8, uint64, uint64, uint64, error) {
f.mu.Lock()
defer f.mu.Unlock()
if err := f.enter("StoragePoolGetInfo"); err != nil {
return 0, 0, 0, 0, err
}
vols, ok := f.pools[pool.Name]
if !ok {
return 0, 0, 0, 0, lvError(libvirt.ErrNoStoragePool, "no pool %s", pool.Name)
}
var used uint64
for _, v := range vols {
used += uint64(v.capacity)
}
state := libvirt.StoragePoolRunning
if s, ok := f.poolStates[pool.Name]; ok {
state = s
}
return uint8(state), f.poolCapacity, used, f.poolCapacity - used, nil
}
+4 -9
View File
@@ -8,7 +8,6 @@ import (
"fmt"
"io"
"net/http"
"net/url"
"strings"
"sync"
"time"
@@ -23,10 +22,7 @@ import (
// provider's images map (Key set) or a URL given directly.
type imageSource struct {
Key string
URL string
SHA256 string
Format string
Firmware string
provider.KVMImage
}
// resolveImage maps placement.image to a source. Anything that is neither a
@@ -34,11 +30,10 @@ type imageSource struct {
// surface, not retry.
func resolveImage(cfg provider.KVMConfig, image string) (imageSource, error) {
if img, ok := cfg.Images[image]; ok {
return imageSource{Key: image, URL: img.URL, SHA256: img.SHA256, Format: img.Format, Firmware: img.Firmware}, nil
return imageSource{Key: image, KVMImage: img}, nil
}
u, err := url.Parse(image)
if err == nil && (u.Scheme == "http" || u.Scheme == "https") && u.Host != "" {
return imageSource{URL: image, Format: formatQcow2, Firmware: firmwareUEFI}, nil
if provider.IsHTTPURL(image) {
return imageSource{KVMImage: imageDefaults(provider.KVMImage{URL: image})}, nil
}
return imageSource{}, fmt.Errorf("%w: placement.image %q is neither a key in kvm.images nor an http(s) URL",
provider.ErrPermanent, image)
+41 -55
View File
@@ -37,12 +37,11 @@ type imageServer struct {
status int
noLength bool
delay time.Duration
body []byte
}
func newImageServer(t *testing.T) *imageServer {
t.Helper()
s := &imageServer{status: http.StatusOK, body: testImage}
s := &imageServer{status: http.StatusOK}
s.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
s.hits.Add(1)
time.Sleep(s.delay)
@@ -55,16 +54,16 @@ func newImageServer(t *testing.T) *imageServer {
if s.noLength {
w.(http.Flusher).Flush()
} else {
w.Header().Set("Content-Length", strconv.Itoa(len(s.body)))
w.Header().Set("Content-Length", strconv.Itoa(len(testImage)))
}
_, _ = w.Write(s.body)
_, _ = w.Write(testImage)
}))
t.Cleanup(s.Close)
return s
}
func testSource(srv *imageServer, sha string) imageSource {
return imageSource{Key: "debian-13", URL: srv.URL + "/debian.qcow2", SHA256: sha, Format: formatQcow2}
return imageSource{Key: "debian-13", KVMImage: provider.KVMImage{URL: srv.URL + "/debian.qcow2", SHA256: sha, Format: formatQcow2}}
}
func ensureTest(t *testing.T, api *fakeAPI, src imageSource) (string, error) {
@@ -88,9 +87,9 @@ func TestResolveImage(t *testing.T) {
want imageSource
wantErr bool
}{
{"configured key", "debian-13", imageSource{Key: "debian-13", URL: "https://cloud.debian.org/d.qcow2", SHA256: "abc", Format: "qcow2", Firmware: "uefi"}, false},
{"direct https url", "https://x.example/img.qcow2", imageSource{URL: "https://x.example/img.qcow2", Format: "qcow2", Firmware: "uefi"}, false},
{"direct http url", "http://mirror/img.qcow2", imageSource{URL: "http://mirror/img.qcow2", Format: "qcow2", Firmware: "uefi"}, false},
{"configured key", "debian-13", imageSource{Key: "debian-13", KVMImage: provider.KVMImage{URL: "https://cloud.debian.org/d.qcow2", SHA256: "abc", Format: "qcow2", Firmware: "uefi"}}, false},
{"direct https url", "https://x.example/img.qcow2", imageSource{KVMImage: provider.KVMImage{URL: "https://x.example/img.qcow2", Format: "qcow2", Firmware: "uefi"}}, false},
{"direct http url", "http://mirror/img.qcow2", imageSource{KVMImage: provider.KVMImage{URL: "http://mirror/img.qcow2", Format: "qcow2", Firmware: "uefi"}}, false},
{"unknown key", "ubuntu", imageSource{}, true},
{"file url", "file:///tmp/x.qcow2", imageSource{}, true},
{"empty", "", imageSource{}, true},
@@ -100,9 +99,7 @@ func TestResolveImage(t *testing.T) {
t.Parallel()
got, err := resolveImage(cfg, tc.image)
if tc.wantErr {
if !errors.Is(err, provider.ErrPermanent) {
t.Fatalf("resolveImage(%q) error = %v, want ErrPermanent", tc.image, err)
}
wantClass(t, err, provider.ErrPermanent)
return
}
if err != nil {
@@ -117,16 +114,16 @@ func TestResolveImage(t *testing.T) {
func TestImageSource_volumeNames(t *testing.T) {
t.Parallel()
keyed := imageSource{Key: "debian-13", URL: "https://a/b.qcow2", Format: "qcow2"}
keyed := imageSource{Key: "debian-13", KVMImage: provider.KVMImage{URL: "https://a/b.qcow2", Format: "qcow2"}}
if keyed.volumeName() != "base-debian-13.qcow2" || keyed.markerName() != "base-debian-13.qcow2.sha256" {
t.Errorf("keyed names = %s / %s", keyed.volumeName(), keyed.markerName())
}
direct := imageSource{URL: "https://a/b.qcow2", Format: "qcow2"}
direct := imageSource{KVMImage: provider.KVMImage{URL: "https://a/b.qcow2", Format: "qcow2"}}
name := direct.volumeName()
if !strings.HasPrefix(name, "base-") || !strings.HasSuffix(name, ".qcow2") || len(name) != len("base-")+16+len(".qcow2") {
t.Errorf("direct-url name = %q, want base-<16 hex>.qcow2", name)
}
other := imageSource{URL: "https://a/c.qcow2", Format: "qcow2"}
other := imageSource{KVMImage: provider.KVMImage{URL: "https://a/c.qcow2", Format: "qcow2"}}
if other.volumeName() == name {
t.Error("different URLs produced the same volume name")
}
@@ -181,12 +178,27 @@ func TestImageCache_ensure_noChecksumConfigured(t *testing.T) {
}
}
func TestImageCache_ensure_alreadyCached(t *testing.T) {
func TestImageCache_ensure_existingVolumes(t *testing.T) {
t.Parallel()
tests := []struct {
name string
base []byte
marker []byte
wantHits int32
}{
{"already cached", testImage, []byte(testImageSHA()), 0},
{"marker missing is a partial upload", []byte("half an image"), nil, 1},
{"configured checksum changed", []byte("an older release"), []byte(strings.Repeat("0", 64)), 1},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
srv := newImageServer(t)
api := newFakeAPI("default")
api.putVol("default", "base-debian-13.qcow2", "qcow2", testImage)
api.putVol("default", "base-debian-13.qcow2.sha256", "raw", []byte(testImageSHA()))
api.putVol("default", "base-debian-13.qcow2", "qcow2", tc.base)
if tc.marker != nil {
api.putVol("default", "base-debian-13.qcow2.sha256", "raw", tc.marker)
}
path, err := ensureTest(t, api, testSource(srv, testImageSHA()))
if err != nil {
t.Fatalf("ensure() error = %v", err)
@@ -194,44 +206,19 @@ func TestImageCache_ensure_alreadyCached(t *testing.T) {
if path != "/var/lib/libvirt/images/base-debian-13.qcow2" {
t.Errorf("path = %q", path)
}
if srv.hits.Load() != 0 {
t.Errorf("http hits = %d, want 0 for a cached image", srv.hits.Load())
if srv.hits.Load() != tc.wantHits {
t.Errorf("http hits = %d, want %d", srv.hits.Load(), tc.wantHits)
}
if api.hasCall("StorageVolCreateXML") || api.hasCall("StorageVolDelete") {
t.Errorf("cached image was touched: calls = %v", api.calls)
}
}
func TestImageCache_ensure_markerMissingRedownloads(t *testing.T) {
t.Parallel()
srv := newImageServer(t)
api := newFakeAPI("default")
api.putVol("default", "base-debian-13.qcow2", "qcow2", []byte("half an image"))
if _, err := ensureTest(t, api, testSource(srv, testImageSHA())); err != nil {
t.Fatalf("ensure() error = %v", err)
}
if srv.hits.Load() != 1 {
t.Errorf("http hits = %d, want 1: a base without its marker is a partial upload", srv.hits.Load())
if touched := api.hasCall("StorageVolCreateXML") || api.hasCall("StorageVolDelete"); touched != (tc.wantHits > 0) {
t.Errorf("volumes touched = %v, want %v: calls = %v", touched, tc.wantHits > 0, api.calls)
}
if base := api.vol("default", "base-debian-13.qcow2"); base == nil || !bytes.Equal(base.data, testImage) {
t.Error("partial base volume was not replaced")
}
}
func TestImageCache_ensure_checksumChangedRedownloads(t *testing.T) {
t.Parallel()
srv := newImageServer(t)
api := newFakeAPI("default")
api.putVol("default", "base-debian-13.qcow2", "qcow2", []byte("an older release"))
api.putVol("default", "base-debian-13.qcow2.sha256", "raw", []byte(strings.Repeat("0", 64)))
if _, err := ensureTest(t, api, testSource(srv, testImageSHA())); err != nil {
t.Fatalf("ensure() error = %v", err)
}
if srv.hits.Load() != 1 {
t.Errorf("http hits = %d, want 1 after the configured checksum changed", srv.hits.Load())
t.Error("base volume does not hold the verified image")
}
if marker := api.vol("default", "base-debian-13.qcow2.sha256"); marker == nil || string(marker.data) != testImageSHA() {
t.Errorf("marker not updated: %+v", marker)
t.Errorf("marker = %+v, want the verified digest", marker)
}
})
}
}
@@ -240,9 +227,7 @@ func TestImageCache_ensure_checksumMismatchIsPermanent(t *testing.T) {
srv := newImageServer(t)
api := newFakeAPI("default")
_, err := ensureTest(t, api, testSource(srv, strings.Repeat("a", 64)))
if !errors.Is(err, provider.ErrPermanent) {
t.Fatalf("ensure() error = %v, want ErrPermanent", err)
}
wantClass(t, err, provider.ErrPermanent)
if names := api.volNames("default"); len(names) != 0 {
t.Errorf("volumes left behind after a checksum mismatch: %v", names)
}
@@ -283,8 +268,9 @@ func TestImageCache_ensure_noContentLengthIsPermanent(t *testing.T) {
srv.noLength = true
api := newFakeAPI("default")
_, err := ensureTest(t, api, testSource(srv, ""))
if !errors.Is(err, provider.ErrPermanent) || !strings.Contains(err.Error(), "Content-Length") {
t.Fatalf("ensure() error = %v, want ErrPermanent about Content-Length", err)
wantClass(t, err, provider.ErrPermanent)
if !strings.Contains(err.Error(), "Content-Length") {
t.Errorf("ensure() error = %v, want one about Content-Length", err)
}
}
+44 -30
View File
@@ -26,10 +26,10 @@ const (
defaultDiskSizeGB = 10
defaultSocket = "/var/run/libvirt/libvirt-sock"
defaultPool = "default"
formatQcow2 = "qcow2"
formatRaw = "raw"
firmwareUEFI = "uefi"
firmwareBIOS = "bios"
formatQcow2 = provider.KVMFormatQcow2
formatRaw = provider.KVMFormatRaw
firmwareUEFI = provider.KVMFirmwareUEFI
firmwareBIOS = provider.KVMFirmwareBIOS
// undefineFlags removes everything libvirt may keep alongside a domain
// definition, so an undefine never fails on leftover state.
@@ -42,18 +42,9 @@ func withDefaults(cfg provider.KVMConfig) provider.KVMConfig {
if cfg.DiskSizeGB == 0 {
cfg.DiskSizeGB = defaultDiskSizeGB
}
if cfg.VendorData == "" {
cfg.VendorData = defaultVendorData
}
images := make(map[string]provider.KVMImage, len(cfg.Images))
for k, img := range cfg.Images {
if img.Format == "" {
img.Format = formatQcow2
}
if img.Firmware == "" {
img.Firmware = firmwareUEFI
}
images[k] = img
images[k] = imageDefaults(img)
}
cfg.Images = images
hosts := make([]provider.KVMHost, len(cfg.Hosts))
@@ -79,30 +70,41 @@ type Provider struct {
now func() time.Time
}
func imageDefaults(img provider.KVMImage) provider.KVMImage {
if img.Format == "" {
img.Format = formatQcow2
}
if img.Firmware == "" {
img.Firmware = firmwareUEFI
}
return img
}
// New builds a Provider that reaches its hosts over SSH. The key and
// known_hosts files are read here so a bad path fails at startup rather
// than on the first Create. Satisfies registry.Constructor.
func New(_ context.Context, pc provider.ProviderConfig) (provider.Provider, error) {
// known_hosts files are tried once here and kept when usable, but a failure
// is only a warning, retried on the first connection: they come from an
// optional Secret mount, and a constructor that failed without it would
// abort the whole provider set and crash-loop the manager, taking the
// kubernetes and gcp providers down with it. Satisfies registry.Constructor.
func New(ctx context.Context, pc provider.ProviderConfig) (provider.Provider, error) {
cfg := provider.KVMConfig{}
if pc.KVM != nil {
cfg = *pc.KVM
}
auth, err := newSSHAuth(cfg)
if err != nil {
return nil, fmt.Errorf("kvm provider %q: %w", pc.Name, err)
loader := newLazySSH(cfg)
if _, err := loader.load(); err != nil {
logf.FromContext(ctx).WithName("kvm").WithValues("provider", pc.Name).Info(
"WARNING: kvm ssh material is not usable; proxies on this provider will fail until it is",
"error", err.Error())
}
return newWithDialer(pc, auth.dialer(), nil), nil
return newWithDialer(pc.Name, cfg, loader.dial, nil), nil
}
// newWithDialer is the test seam: the dialer decides what libvirtAPI each
// host resolves to, and the HTTP client is what image downloads go through.
func newWithDialer(pc provider.ProviderConfig, dial hostDialer, httpClient *http.Client) *Provider {
cfg := provider.KVMConfig{}
if pc.KVM != nil {
cfg = *pc.KVM
}
func newWithDialer(name string, cfg provider.KVMConfig, dial hostDialer, httpClient *http.Client) *Provider {
return &Provider{
name: pc.Name,
name: name,
cfg: withDefaults(cfg),
hosts: newHostPool(dial),
images: newImageCache(httpClient),
@@ -249,7 +251,11 @@ func (p *Provider) writeSeed(log logr.Logger, api libvirtAPI, pool libvirt.Stora
if err := deleteIfExists(api, pool, seedName); err != nil {
return libvirt.StorageVol{}, err
}
iso, err := buildSeedISO(name, userData, p.cfg.VendorData)
vendorData, err := buildVendorData(p.cfg.VendorData, p.cfg.InstallGuestAgent == nil || *p.cfg.InstallGuestAgent)
if err != nil {
return libvirt.StorageVol{}, fmt.Errorf("%w: %v", provider.ErrPermanent, err)
}
iso, err := buildSeedISO(name, userData, vendorData)
if err != nil {
return libvirt.StorageVol{}, fmt.Errorf("%w: %v", provider.ErrPermanent, err)
}
@@ -450,7 +456,8 @@ func mapState(s libvirt.DomainState, ip string) provider.InstanceState {
// domain is not destroyed again, a missing domain is not undefined, and
// the per-VM volumes are removed by name even when the domain is already
// gone, so a crash between volume creation and define is cleaned up on the
// next reconcile.
// next reconcile. A providerID naming a host that is no longer configured
// is a no-op success rather than an error.
func (p *Provider) Delete(ctx context.Context, providerID string) error {
hostName, name, err := parseProviderID(providerID)
if err != nil {
@@ -458,7 +465,14 @@ func (p *Provider) Delete(ctx context.Context, providerID string) error {
}
host, err := p.host(hostName)
if err != nil {
return p.wrapErr("delete", providerID, err)
// The host is no longer configured, so this operator has no way to
// reach the domain and nothing left to clean. Same reasoning as
// removeVolumes over a missing pool: refusing would pin the Proxy's
// finalizer. Whatever is still running there is the admin's to remove,
// and orphan GC cannot see it either — see docs/kvm-provider.md.
p.logger(ctx).Info("host is not configured; delete has nothing to clean up",
"host", hostName, "providerID", providerID)
return nil
}
log := p.logger(ctx).WithValues("host", host.Name, "name", name)
err = p.hosts.call(ctx, log, host, func(api libvirtAPI) error {
@@ -347,6 +347,36 @@ func TestKVM_lifecycle(t *testing.T) {
env.keep = false
}
// TestKVMCheck_realHost runs the provider health check against the host.
// It creates nothing: it must reach the host and find the pool running.
func TestKVMCheck_realHost(t *testing.T) {
env := loadIntegrationEnv(t)
log := integrationLogger(t, env.logLevel)
ctx, cancel := context.WithTimeout(logr.NewContext(context.Background(), log), time.Minute)
defer cancel()
prov, err := New(ctx, env.cfg)
if err != nil {
t.Fatalf("New: %v", err)
}
got := prov.(*Provider).Check(ctx)
if len(got) != 1 {
t.Fatalf("Check returned %d targets, want 1", len(got))
}
ts := got[0]
t.Logf("%s (%s): ready=%v reason=%s latency=%s details=%v",
ts.Name, ts.Address, ts.Ready, ts.Reason, ts.Latency.Round(time.Millisecond), ts.Details)
if !ts.Ready {
t.Fatalf("check failed: %s: %s", ts.Reason, ts.Message)
}
for _, key := range []string{"hostname", "libvirtVersion", "poolState", "poolAvailable"} {
if ts.Details[key] == "" {
t.Errorf("details[%s] is empty, want what the host reported", key)
}
}
}
// waitForRunning polls Get until the instance is Running with an IP or ctx
// ends. Transient errors are retried; anything else fails the test.
func waitForRunning(ctx context.Context, t *testing.T, p *Provider, id string) *provider.Instance {
+42 -26
View File
@@ -30,8 +30,8 @@ func TestWithDefaults(t *testing.T) {
if cfg.DiskSizeGB != defaultDiskSizeGB {
t.Errorf("diskSizeGb = %d, want %d", cfg.DiskSizeGB, defaultDiskSizeGB)
}
if cfg.VendorData != defaultVendorData {
t.Errorf("vendorData = %q, want the default", cfg.VendorData)
if cfg.VendorData != "" {
t.Errorf("vendorData = %q, want none: the guest-agent bootstrap is merged into the user-data, not defaulted here", cfg.VendorData)
}
if cfg.Images["debian"].Format != "qcow2" || cfg.Images["raw"].Format != "raw" {
t.Errorf("image formats = %q/%q, want qcow2 default and raw kept", cfg.Images["debian"].Format, cfg.Images["raw"].Format)
@@ -95,7 +95,8 @@ func testConfig(hosts ...string) provider.ProviderConfig {
}
func newTestProvider(fakes hostFakes, hosts ...string) *Provider {
p := newWithDialer(testConfig(hosts...), fakes.dial, nil)
pc := testConfig(hosts...)
p := newWithDialer(pc.Name, *pc.KVM, fakes.dial, nil)
p.now = func() time.Time { return testNow }
return p
}
@@ -103,7 +104,7 @@ func newTestProvider(fakes hostFakes, hosts ...string) *Provider {
// cacheImage pre-populates the pool with the configured image and its
// marker so Create never touches the network.
func cacheImage(api *fakeAPI) {
src := imageSource{Key: "debian-13", URL: testImageURL, Format: formatQcow2}
src := imageSource{Key: "debian-13", KVMImage: provider.KVMImage{URL: testImageURL, Format: formatQcow2}}
api.putVol("default", src.volumeName(), formatQcow2, []byte("qcow2"))
api.putVol("default", src.markerName(), formatRaw, []byte(testImageSHA()))
}
@@ -187,6 +188,13 @@ func assertCreated(t *testing.T, api *fakeAPI) {
if seed.format != formatRaw || len(seed.data) == 0 || int64(len(seed.data)) != seed.capacity {
t.Errorf("seed = format %s, %d bytes, capacity %d; want raw with capacity == uploaded size", seed.format, len(seed.data), seed.capacity)
}
_, files := readSeed(t, seed.data)
if files["user-data"] != testCloudInit {
t.Errorf("seed user-data = %q, want the Proxy's cloud-init verbatim", files["user-data"])
}
if !strings.Contains(files["vendor-data"], "qemu-guest-agent") {
t.Errorf("seed vendor-data = %q, want the guest-agent script", files["vendor-data"])
}
}
func TestCreate_freshHost(t *testing.T) {
@@ -234,7 +242,7 @@ func TestCreate_fetchesImageOnFirstUse(t *testing.T) {
api := newFakeAPI("default")
cfg := testConfig("nas1")
cfg.KVM.Images["debian-13"] = provider.KVMImage{URL: srv.URL + "/debian.qcow2", SHA256: testImageSHA()}
p := newWithDialer(cfg, hostFakes{"nas1": api}.dial, nil)
p := newWithDialer(cfg.Name, *cfg.KVM, hostFakes{"nas1": api}.dial, nil)
if _, err := p.Create(context.Background(), testRequest()); err != nil {
t.Fatalf("Create: %v", err)
@@ -472,28 +480,25 @@ func TestCreate_hostFailures(t *testing.T) {
t.Parallel()
cases := []struct {
name string
setup func() (hostFakes, string)
setup func() hostFakes
class error
}{
{
name: "host unreachable",
setup: func() (hostFakes, string) { return hostFakes{}, "nas1" },
setup: func() hostFakes { return hostFakes{} },
class: provider.ErrTransient,
},
{
name: "pool missing",
setup: func() (hostFakes, string) {
api := newFakeAPI("other")
return hostFakes{"nas1": api}, "nas1"
},
setup: func() hostFakes { return hostFakes{"nas1": newFakeAPI("other")} },
class: provider.ErrPermanent,
},
{
name: "domain xml rejected",
setup: func() (hostFakes, string) {
setup: func() hostFakes {
api := readyHost()
api.fail["DomainDefineXML"] = lvError(libvirt.ErrConfigUnsupported, "no q35 here")
return hostFakes{"nas1": api}, "nas1"
return hostFakes{"nas1": api}
},
class: provider.ErrPermanent,
},
@@ -501,8 +506,7 @@ func TestCreate_hostFailures(t *testing.T) {
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
fakes, host := tc.setup()
p := newTestProvider(fakes, host)
p := newTestProvider(tc.setup(), "nas1")
_, err := p.Create(context.Background(), testRequest())
wantClass(t, err, tc.class)
})
@@ -722,6 +726,28 @@ func TestDelete_missingPoolIsNotAnError(t *testing.T) {
}
}
// A host dropped from the config leaves the operator no way to reach the
// domain, so Delete has nothing left to clean. Failing instead would pin
// the Proxy's finalizer forever, since the reconciler's delete path cannot
// make progress without a Delete that can succeed.
func TestDelete_unconfiguredHostIsNotAnError(t *testing.T) {
t.Parallel()
api := readyHost()
managedDomain(t, api, testName, testUID, true, nil)
p := newTestProvider(hostFakes{"nas1": api}, "nas1")
if err := p.Delete(context.Background(), "hosts/nas9/domains/"+testName); err != nil {
t.Fatalf("Delete over an unconfigured host: %v", err)
}
if api.domain(testName) == nil {
t.Error("domain on the configured host was touched; only the named host's state may change")
}
// Get still refuses, so the live path cannot mistake a config typo for a
// missing VM and recreate it somewhere else.
_, err := p.Get(context.Background(), "hosts/nas9/domains/"+testName)
wantClass(t, err, provider.ErrPermanent)
}
func TestDelete_errors(t *testing.T) {
t.Parallel()
cases := []struct {
@@ -731,7 +757,6 @@ func TestDelete_errors(t *testing.T) {
class error
}{
{"malformed id", "nope", func(*testing.T) hostFakes { return hostFakes{"nas1": readyHost()} }, provider.ErrPermanent},
{"host not configured", "hosts/nas9/domains/x", func(*testing.T) hostFakes { return hostFakes{"nas1": readyHost()} }, provider.ErrPermanent},
{"host unreachable", "hosts/nas1/domains/x", func(*testing.T) hostFakes { return hostFakes{} }, provider.ErrTransient},
{
"undefine fails", "hosts/nas1/domains/" + testName,
@@ -880,15 +905,6 @@ func TestMapState(t *testing.T) {
}
}
func TestNew_badKeyPathFailsFast(t *testing.T) {
t.Parallel()
cfg := testConfig("nas1")
cfg.KVM.SSHKeyFile = t.TempDir() + "/missing"
if _, err := New(context.Background(), cfg); err == nil {
t.Fatal("New succeeded with a missing key file")
}
}
// runAllOps drives one full lifecycle over a ready fake host so logging
// tests see every provider method and every libvirt call.
func runAllOps(t *testing.T, ctx context.Context, req provider.CreateRequest) {
@@ -956,7 +972,7 @@ func TestLogging_verbosityTiers(t *testing.T) {
ctx, snapshot := captureContext(tc.verbosity)
req := testRequest()
const sentinel = "SENTINEL-cloud-init-must-never-be-logged"
req.CloudInit = "#cloud-config\n" + sentinel + "\n"
req.CloudInit = "#cloud-config\nhostname: " + sentinel + "\n"
runAllOps(t, ctx, req)
+2 -4
View File
@@ -38,14 +38,12 @@ func newInstanceMetadata(uid string, createdAt time.Time) instanceMetadata {
func parseInstanceMetadata(s string) (instanceMetadata, error) {
var raw struct {
XMLName xml.Name `xml:"instance"`
Managed string `xml:"managed"`
UID string `xml:"uid"`
CreatedAt string `xml:"createdAt"`
instanceMetadata
}
if err := xml.Unmarshal([]byte(s), &raw); err != nil {
return instanceMetadata{}, fmt.Errorf("parsing domain metadata: %w", err)
}
return instanceMetadata{Managed: raw.Managed, UID: raw.UID, CreatedAt: raw.CreatedAt}, nil
return raw.instanceMetadata, nil
}
func (m instanceMetadata) managed() bool {
+27 -37
View File
@@ -29,47 +29,37 @@ func TestInstanceMetadata_roundTrip(t *testing.T) {
}
}
func TestParseInstanceMetadata_libvirtPrefixedForm(t *testing.T) {
t.Parallel()
// libvirt re-serialises the stored element with whatever prefix it
// chooses; only the namespace URI is contractual.
in := `<op:instance xmlns:op="` + metadataURI + `">` +
`<op:managed>true</op:managed><op:uid>uid-2</op:uid>` +
`<op:createdAt>2026-09-04T10:00:00Z</op:createdAt></op:instance>`
m, err := parseInstanceMetadata(in)
if err != nil {
t.Fatalf("parseInstanceMetadata() error = %v", err)
}
if !m.managed() || m.UID != "uid-2" || m.createdAt().Hour() != 10 {
t.Errorf("parsed = %+v", m)
}
}
func TestParseInstanceMetadata_libvirtBareForm(t *testing.T) {
t.Parallel()
// What libvirt 11.6 actually returns from DomainGetMetadata: the element
// selected by our URI, serialised without any namespace declaration.
in := `<instance><managed>true</managed><uid>uid-3</uid>` +
`<createdAt>2026-09-10T07:30:00Z</createdAt></instance>`
m, err := parseInstanceMetadata(in)
if err != nil {
t.Fatalf("parseInstanceMetadata() error = %v", err)
}
if !m.managed() || m.UID != "uid-3" || m.createdAt().Minute() != 30 {
t.Errorf("parsed = %+v", m)
}
}
func TestParseInstanceMetadata_notManaged(t *testing.T) {
func TestParseInstanceMetadata(t *testing.T) {
t.Parallel()
tests := []struct {
name string
in string
wantErr bool
wantManaged bool
wantUID string
wantCreated time.Time
}{
{"managed false", `<instance xmlns="` + metadataURI + `"><managed>false</managed><uid>u</uid></instance>`, false},
{"foreign element", `<foo xmlns="urn:other"><managed>true</managed></foo>`, true},
{"garbage", `not xml`, true},
{
// libvirt re-serialises the stored element with whatever prefix it
// chooses; only the namespace URI is contractual.
name: "libvirt prefixed form",
in: `<op:instance xmlns:op="` + metadataURI + `">` +
`<op:managed>true</op:managed><op:uid>uid-2</op:uid>` +
`<op:createdAt>2026-09-04T10:00:00Z</op:createdAt></op:instance>`,
wantManaged: true, wantUID: "uid-2", wantCreated: time.Date(2026, 9, 4, 10, 0, 0, 0, time.UTC),
},
{
// What libvirt 11.6 actually returns from DomainGetMetadata: the
// element selected by our URI, serialised without any namespace
// declaration.
name: "libvirt bare form",
in: `<instance><managed>true</managed><uid>uid-3</uid>` +
`<createdAt>2026-09-10T07:30:00Z</createdAt></instance>`,
wantManaged: true, wantUID: "uid-3", wantCreated: time.Date(2026, 9, 10, 7, 30, 0, 0, time.UTC),
},
{name: "managed false", in: `<instance xmlns="` + metadataURI + `"><managed>false</managed><uid>u</uid></instance>`, wantUID: "u"},
{name: "foreign element", in: `<foo xmlns="urn:other"><managed>true</managed></foo>`, wantErr: true},
{name: "garbage", in: `not xml`, wantErr: true},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
@@ -78,8 +68,8 @@ func TestParseInstanceMetadata_notManaged(t *testing.T) {
if (err != nil) != tc.wantErr {
t.Fatalf("error = %v, wantErr %v", err, tc.wantErr)
}
if m.managed() {
t.Errorf("managed() = true for %q", tc.in)
if m.managed() != tc.wantManaged || m.UID != tc.wantUID || !m.createdAt().Equal(tc.wantCreated) {
t.Errorf("parsed = %+v, want managed=%v uid=%q createdAt=%v", m, tc.wantManaged, tc.wantUID, tc.wantCreated)
}
})
}
+92 -12
View File
@@ -3,6 +3,9 @@ package kvm
import (
"bytes"
"fmt"
"io"
"mime/multipart"
"net/textproto"
"strings"
"github.com/kdomanski/iso9660"
@@ -11,20 +14,97 @@ import (
// seedLabel is the volume label cloud-init's NoCloud datasource looks for.
const seedLabel = "cidata"
// defaultVendorData is the cloud-init vendor-data shipped on every seed
// unless the config overrides it. cloud-init merges it under the Proxy's own
// user-data, so stock images grow a guest agent (which lets the provider
// read the VM's IP before ARP sees traffic) without the user's cloud-init
// having to know about it.
const defaultVendorData = `#cloud-config
packages:
- qemu-guest-agent
runcmd:
- [systemctl, enable, --now, qemu-guest-agent]
// guestAgentScript installs and starts the QEMU guest agent, which is how
// the provider learns a VM's address before the host's ARP table has seen
// any traffic from it.
//
// It ships as vendor-data, and as a *script* rather than a cloud-config,
// because that is the only shape cloud-init cannot drop. Vendor-data
// cloud-config is merged under the Proxy's own user-data with mergers that
// keep the user-data's value for every key both documents define and never
// append lists, so a Proxy carrying its own packages: silently lost the
// agent. The strategy cannot be overridden from either document: merge_how
// in user-data governs only the first source, and merge_how in vendor-data
// is consumed and discarded before the document is stored. A vendor-data
// script bypasses all of it — cloud-init gives vendor-data its own handler
// chain, writes scripts to a separate directory and runs them from
// cc_scripts_vendor, after the user's packages are installed and before the
// user's runcmd.
const guestAgentScript = `#!/bin/sh
# Added by egress-proxies-operator so the hypervisor can report this VM's
# address. Set installGuestAgent: false on the provider to leave it out.
set -e
if ! command -v qemu-ga >/dev/null 2>&1 && [ ! -x /usr/sbin/qemu-ga ]; then
if command -v apt-get >/dev/null 2>&1; then
apt-get update
DEBIAN_FRONTEND=noninteractive apt-get install -y qemu-guest-agent
elif command -v dnf >/dev/null 2>&1; then
dnf install -y qemu-guest-agent
elif command -v yum >/dev/null 2>&1; then
yum install -y qemu-guest-agent
elif command -v zypper >/dev/null 2>&1; then
zypper --non-interactive install qemu-guest-agent
elif command -v apk >/dev/null 2>&1; then
apk add qemu-guest-agent
else
echo "no known package manager; install qemu-guest-agent in the image" >&2
exit 1
fi
fi
if command -v systemctl >/dev/null 2>&1; then
systemctl enable --now qemu-guest-agent
elif command -v rc-update >/dev/null 2>&1; then
rc-update add qemu-guest-agent default
rc-service qemu-guest-agent start
fi
`
// buildSeedISO renders a NoCloud seed: user-data verbatim, vendor-data as
// given, and a meta-data whose instance-id is the deterministic VM name, so
// buildVendorData renders the seed's vendor-data. With nothing configured
// it is the guest-agent script alone; with both, a MIME multipart carrying
// the configured document and the script as separate parts, since
// vendor-data is a single file and the two must not be concatenated.
func buildVendorData(configured string, installAgent bool) (string, error) {
switch {
case !installAgent:
return configured, nil
case strings.TrimSpace(configured) == "":
return guestAgentScript, nil
default:
return multipartVendorData(configured, guestAgentScript)
}
}
// multipartVendorData packs several documents into one vendor-data blob.
// cloud-init decides a payload is multipart by finding "mime-version:"
// within its first 4096 bytes, so those headers lead. The configured
// document goes in as text/plain on purpose: cloud-init re-sniffs that type
// from the content's own header line, which keeps this code out of the
// business of classifying what the user wrote.
func multipartVendorData(configured, script string) (string, error) {
var body bytes.Buffer
w := multipart.NewWriter(&body)
parts := []struct{ contentType, content string }{
{"text/plain", configured},
{"text/x-shellscript", script},
}
for _, p := range parts {
part, err := w.CreatePart(textproto.MIMEHeader{"Content-Type": {p.contentType}})
if err != nil {
return "", fmt.Errorf("building vendor-data part: %w", err)
}
if _, err := io.WriteString(part, p.content); err != nil {
return "", fmt.Errorf("writing vendor-data part: %w", err)
}
}
if err := w.Close(); err != nil {
return "", fmt.Errorf("closing vendor-data: %w", err)
}
return fmt.Sprintf("MIME-Version: 1.0\r\nContent-Type: multipart/mixed; boundary=%q\r\n\r\n%s",
w.Boundary(), body.String()), nil
}
// buildSeedISO renders a NoCloud seed: user-data and vendor-data verbatim,
// and a meta-data whose instance-id is the deterministic VM name, so
// cloud-init never mistakes a reboot for a new instance. The ISO's volume
// descriptor carries the build time, so two builds differ in a few
// timestamp bytes; the files and label are identical.
+136 -3
View File
@@ -4,6 +4,8 @@ import (
"bytes"
"io"
"maps"
"mime"
"mime/multipart"
"strings"
"testing"
@@ -42,7 +44,7 @@ func readSeed(t *testing.T, iso []byte) (label string, files map[string]string)
func TestBuildSeedISO_contents(t *testing.T) {
t.Parallel()
userData := "#cloud-config\npackages: [squid]\n"
iso, err := buildSeedISO("proxy-abc123def456ghij", userData, defaultVendorData)
iso, err := buildSeedISO("proxy-abc123def456ghij", userData, guestAgentScript)
if err != nil {
t.Fatalf("buildSeedISO() error = %v", err)
}
@@ -56,7 +58,7 @@ func TestBuildSeedISO_contents(t *testing.T) {
if files["user-data"] != userData {
t.Errorf("user-data = %q, want verbatim cloud-init", files["user-data"])
}
if files["vendor-data"] != defaultVendorData {
if files["vendor-data"] != guestAgentScript {
t.Errorf("vendor-data = %q", files["vendor-data"])
}
if got, want := files["meta-data"], "instance-id: proxy-abc123def456ghij\nlocal-hostname: proxy-abc123def456ghij\n"; got != want {
@@ -89,7 +91,7 @@ func TestBuildSeedISO_deterministicContent(t *testing.T) {
func TestBuildSeedISO_emptyUserData(t *testing.T) {
t.Parallel()
iso, err := buildSeedISO("proxy-x", "", defaultVendorData)
iso, err := buildSeedISO("proxy-x", "", guestAgentScript)
if err != nil {
t.Fatalf("buildSeedISO() error = %v", err)
}
@@ -98,3 +100,134 @@ func TestBuildSeedISO_emptyUserData(t *testing.T) {
t.Errorf("user-data = %q (present=%v), want an empty file to still exist", body, ok)
}
}
// The whole point of shipping the agent as vendor-data: whatever the Proxy
// wrote reaches the guest untouched, in any of cloud-init's formats. The
// previous implementation parsed and re-rendered cloud-config user-data,
// which dropped comments, reordered keys and re-typed unquoted scalars
// (version: 1.10 arrived as 1.1).
func TestBuildSeedISO_userDataIsNeverRewritten(t *testing.T) {
t.Parallel()
tests := []struct {
name string
userData string
}{
{"cloud-config with its own packages", "#cloud-config\n# keep me\npackages:\n - squid\nversion: 1.10\nchpasswd: {list: 'u:0123456789'}\n"},
{"jinja template", "## template: jinja\n#cloud-config\nhostname: {{ v1.local_hostname }}\n"},
{"shell script", "#!/bin/sh\napt-get install -y squid\n"},
{"include", "#include\nhttps://example.invalid/x\n"},
{"mime multipart", "MIME-Version: 1.0\r\nContent-Type: multipart/mixed; boundary=\"x\"\r\n\r\n--x\r\nContent-Type: text/cloud-config\r\n\r\n#cloud-config\npackages: [squid]\n\r\n--x--\r\n"},
{"gzip payload", "\x1f\x8b\x08\x00\x00\x00\x00\x00\x00\x03binary\x00bytes"},
{"leading blank line", "\n\n#cloud-config\npackages: [squid]\n"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
vendor, err := buildVendorData("", true)
if err != nil {
t.Fatalf("buildVendorData() error = %v", err)
}
iso, err := buildSeedISO("proxy-x", tc.userData, vendor)
if err != nil {
t.Fatalf("buildSeedISO() error = %v", err)
}
_, files := readSeed(t, iso)
if files["user-data"] != tc.userData {
t.Errorf("user-data was rewritten:\n got %q\nwant %q", files["user-data"], tc.userData)
}
if !strings.Contains(files["vendor-data"], "qemu-guest-agent") {
t.Errorf("vendor-data = %q, want the agent script", files["vendor-data"])
}
})
}
}
func TestBuildVendorData_agentScriptAlone(t *testing.T) {
t.Parallel()
got, err := buildVendorData("", true)
if err != nil {
t.Fatalf("buildVendorData() error = %v", err)
}
if got != guestAgentScript {
t.Errorf("vendor-data = %q, want the bare script", got)
}
if !strings.HasPrefix(got, "#!") {
t.Error("vendor-data must start with a shebang or cloud-init treats it as something other than a script")
}
}
func TestBuildVendorData_optOutKeepsConfiguredOnly(t *testing.T) {
t.Parallel()
configured := "#cloud-config\nntp: {enabled: true}\n"
tests := []struct {
name string
configured string
want string
}{
{"nothing configured", "", ""},
{"configured document passes through", configured, configured},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
got, err := buildVendorData(tc.configured, false)
if err != nil {
t.Fatalf("buildVendorData() error = %v", err)
}
if got != tc.want {
t.Errorf("vendor-data = %q, want %q", got, tc.want)
}
if strings.Contains(got, "qemu-guest-agent") {
t.Error("agent script present although installGuestAgent is off")
}
})
}
}
// Both documents have to survive in one file, and cloud-init only looks for
// the multipart marker in the first 4096 bytes.
func TestBuildVendorData_multipartCarriesBoth(t *testing.T) {
t.Parallel()
configured := "#cloud-config\nntp:\n enabled: true\n"
got, err := buildVendorData(configured, true)
if err != nil {
t.Fatalf("buildVendorData() error = %v", err)
}
head := got
if len(head) > 4096 {
head = head[:4096]
}
if !strings.Contains(strings.ToLower(head), "mime-version:") {
t.Fatalf("no mime-version: in the first 4096 bytes; cloud-init would treat this as a single document:\n%s", head)
}
_, params, err := mime.ParseMediaType(strings.SplitN(got, "\r\n\r\n", 2)[0][strings.Index(got, "Content-Type:")+len("Content-Type: "):])
if err != nil {
t.Fatalf("parsing the multipart content type: %v", err)
}
body := strings.SplitN(got, "\r\n\r\n", 2)[1]
r := multipart.NewReader(strings.NewReader(body), params["boundary"])
var bodies, types []string
for {
part, err := r.NextPart()
if err == io.EOF {
break
}
if err != nil {
t.Fatalf("reading part: %v", err)
}
content, err := io.ReadAll(part)
if err != nil {
t.Fatalf("reading part body: %v", err)
}
bodies = append(bodies, string(content))
types = append(types, part.Header.Get("Content-Type"))
}
if len(bodies) != 2 || bodies[0] != configured || bodies[1] != guestAgentScript {
t.Fatalf("parts = %q, want the configured document then the script", bodies)
}
// text/plain makes cloud-init re-sniff the user's document from its own
// header line, so this code never has to classify it.
if types[0] != "text/plain" || types[1] != "text/x-shellscript" {
t.Errorf("part types = %v, want [text/plain text/x-shellscript]", types)
}
}
+29 -2
View File
@@ -123,7 +123,10 @@ func (t *tracingAPI) DomainGetMetadata(d libvirt.Domain, typ int32, uri libvirt.
func (t *tracingAPI) DomainInterfaceAddresses(d libvirt.Domain, source uint32, flags uint32) ([]libvirt.DomainInterface, error) {
c := t.begin("DomainInterfaceAddresses", "domain", d.Name, "source", libvirt.DomainInterfaceAddressesSource(source))
ifaces, err := t.inner.DomainInterfaceAddresses(d, source, flags)
c.detail("addresses", formatAddresses(ifaces)).done(err, "interfaces", len(ifaces))
if t.log.V(2).Enabled() {
c.detail("addresses", formatAddresses(ifaces))
}
c.done(err, "interfaces", len(ifaces))
return ifaces, err
}
@@ -140,11 +143,14 @@ func formatAddresses(ifaces []libvirt.DomainInterface) []string {
func (t *tracingAPI) ConnectListAllDomains(needResults int32, flags libvirt.ConnectListAllDomainsFlags) ([]libvirt.Domain, uint32, error) {
c := t.begin("ConnectListAllDomains", flagKV(int64(flags))...)
doms, n, err := t.inner.ConnectListAllDomains(needResults, flags)
if t.log.V(2).Enabled() {
names := make([]string, 0, len(doms))
for _, d := range doms {
names = append(names, d.Name)
}
c.detail("names", names).done(err, "count", len(doms))
c.detail("names", names)
}
c.done(err, "count", len(doms))
return doms, n, err
}
@@ -203,3 +209,24 @@ func (t *tracingAPI) StorageVolDelete(vol libvirt.StorageVol, flags libvirt.Stor
c.done(err)
return err
}
func (t *tracingAPI) ConnectGetLibVersion() (uint64, error) {
c := t.begin("ConnectGetLibVersion")
v, err := t.inner.ConnectGetLibVersion()
c.done(err, "version", formatLibVersion(v))
return v, err
}
func (t *tracingAPI) ConnectGetHostname() (string, error) {
c := t.begin("ConnectGetHostname")
h, err := t.inner.ConnectGetHostname()
c.done(err, "hostname", h)
return h, err
}
func (t *tracingAPI) StoragePoolGetInfo(pool libvirt.StoragePool) (uint8, uint64, uint64, uint64, error) {
c := t.begin("StoragePoolGetInfo", "pool", pool.Name)
state, capacity, allocation, available, err := t.inner.StoragePoolGetInfo(pool)
c.done(err, "state", poolStateName(libvirt.StoragePoolState(state)), "capacity", capacity, "available", available)
return state, capacity, allocation, available, err
}
+3
View File
@@ -103,6 +103,9 @@ func TestTracingAPI_logsEveryMethod(t *testing.T) {
return api.StorageVolDownload(overlay, io.Discard, 0, 0, 0)
}},
{"StorageVolDelete", func(api libvirtAPI) error { return api.StorageVolDelete(overlay, 0) }},
{"ConnectGetLibVersion", func(api libvirtAPI) error { _, err := api.ConnectGetLibVersion(); return err }},
{"ConnectGetHostname", func(api libvirtAPI) error { _, err := api.ConnectGetHostname(); return err }},
{"StoragePoolGetInfo", func(api libvirtAPI) error { _, _, _, _, err := api.StoragePoolGetInfo(pool); return err }},
}
if n := reflect.TypeOf((*libvirtAPI)(nil)).Elem().NumMethod(); n != len(cases) {
t.Fatalf("libvirtAPI has %d methods, this test covers %d; trace the new one", n, len(cases))
+100
View File
@@ -0,0 +1,100 @@
package providerhealth
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"time"
"unicode/utf8"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/envtest"
crawlv1alpha1 "gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/api/v1alpha1"
"gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/internal/provider"
)
// TestMonitor_envtestRoundTrip writes through a real API server, which is
// the only thing that validates the generated schema: cluster scope, the
// status subresource, required fields, list-map keys and the message limit.
func TestMonitor_envtestRoundTrip(t *testing.T) {
if testing.Short() {
t.Skip("envtest skipped under -short")
}
env := &envtest.Environment{
CRDDirectoryPaths: []string{filepath.Join("..", "..", "config", "crd", "bases")},
ErrorIfCRDPathMissing: true,
BinaryAssetsDirectory: envtestBinaryDir(),
}
cfg, err := env.Start()
if err != nil {
t.Fatalf("starting envtest (run `make setup-envtest` first): %v", err)
}
t.Cleanup(func() { _ = env.Stop() })
scheme := runtime.NewScheme()
if err := crawlv1alpha1.AddToScheme(scheme); err != nil {
t.Fatal(err)
}
c, err := client.New(cfg, client.Options{Scheme: scheme})
if err != nil {
t.Fatal(err)
}
ctx := context.Background()
m := &Monitor{Client: c, Reader: c}
first := Result{Provider: "kvm-home", Type: "kvm", Supported: true, CheckedAt: t0, Targets: []provider.TargetStatus{
{Name: "nas1", Address: "nas1:22", Ready: true, Reason: provider.ReasonReachable,
Latency: 42 * time.Millisecond, Details: map[string]string{"libvirtVersion": "10.2.0"}},
{Name: "nas2", Address: "nas2:22", Reason: provider.ReasonUnreachable, Message: strings.Repeat("x", 2000)},
}}
if err := m.publish(ctx, first); err != nil {
t.Fatalf("first publish: %v", err)
}
got := getHealth(t, c, "kvm-home")
if got.Status.TargetsReady != "1/2" || got.Status.Targets[0].Details["libvirtVersion"] != "10.2.0" ||
got.Status.Targets[0].LatencyMillis != 42 || utf8.RuneCountInString(got.Status.Targets[1].Message) != maxMessageLen {
t.Errorf("status after the first round = %+v", got.Status)
}
second := Result{Provider: "kvm-home", Type: "kvm", Supported: true, CheckedAt: t1,
Targets: []provider.TargetStatus{up("nas1"), up("nas2")}}
if err := m.publish(ctx, second); err != nil {
t.Fatalf("second publish: %v", err)
}
got = getHealth(t, c, "kvm-home")
if cond := readyCondition(got); cond == nil || cond.Reason != reasonAllTargetsReady {
t.Errorf("Ready after recovery = %+v, want %s", cond, reasonAllTargetsReady)
}
if lt := got.Status.Targets[0].LastTransitionTime.Time; !lt.Equal(t0) {
t.Errorf("nas1 lastTransitionTime = %s, want %s", lt, t0)
}
if lt := got.Status.Targets[1].LastTransitionTime.Time; !lt.Equal(t1) {
t.Errorf("nas2 lastTransitionTime = %s, want %s", lt, t1)
}
m.cleanup(ctx, nil)
var gone crawlv1alpha1.ProviderHealth
if err := c.Get(ctx, client.ObjectKey{Name: "kvm-home"}, &gone); !apierrors.IsNotFound(err) {
t.Errorf("Get after cleanup = %v, want NotFound", err)
}
}
// envtestBinaryDir finds the binaries `make setup-envtest` downloads, so
// the test also runs outside make (which sets KUBEBUILDER_ASSETS instead).
func envtestBinaryDir() string {
base := filepath.Join("..", "..", "bin", "k8s")
entries, err := os.ReadDir(base)
if err != nil {
return ""
}
for _, e := range entries {
if e.IsDir() {
return filepath.Join(base, e.Name())
}
}
return ""
}
+296
View File
@@ -0,0 +1,296 @@
// Package providerhealth checks every configured provider on an interval
// and publishes the results: one ProviderHealth object per provider, a log
// line whenever a target goes down or comes back, and a snapshot the
// metrics collector reads at scrape time.
package providerhealth
import (
"context"
"fmt"
"slices"
"sync"
"time"
"github.com/go-logr/logr"
"go.opentelemetry.io/otel/attribute"
"k8s.io/apimachinery/pkg/api/equality"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/util/validation"
"sigs.k8s.io/controller-runtime/pkg/client"
logf "sigs.k8s.io/controller-runtime/pkg/log"
crawlv1alpha1 "gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/api/v1alpha1"
"gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/internal/provider"
"gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/internal/tracing"
)
const (
// labelManagedBy marks the ProviderHealth objects this operator owns;
// cleanup never deletes an object without it.
labelManagedBy = "app.kubernetes.io/managed-by"
managedByValue = "egress-proxies-operator"
minInterval = 10 * time.Second
defaultTimeout = 15 * time.Second
)
// Target is one configured provider. Checker is nil for a provider that
// cannot check itself.
type Target struct {
Name string
Type string
Checker provider.Checker
}
// Result is the outcome of one provider's latest check.
type Result struct {
Provider string
Type string
// Supported is false when the provider has no Checker.
Supported bool
Targets []provider.TargetStatus
CheckedAt time.Time
}
// controller-gen reads RBAC markers only at package level, so they stay
// out of Monitor's doc comment.
// +kubebuilder:rbac:groups=crawl.example.com,resources=providerhealths,verbs=get;list;create;delete
// +kubebuilder:rbac:groups=crawl.example.com,resources=providerhealths/status,verbs=get;patch
// Monitor is the manager Runnable that checks the providers.
type Monitor struct {
// Client writes the ProviderHealth objects.
Client client.Client
// Reader reads them; pass the manager's API reader. The objects are
// cluster-scoped, and reading them through the cache would start a
// cluster-wide informer needing watch rights, whatever --proxy-namespace
// says, for a handful of reads a minute.
Reader client.Reader
// Targets are the configured providers, in config order.
Targets []Target
// Interval between rounds of checks; 0 turns checking off. The first
// round runs at start: nothing races it, and early visibility is the
// point.
Interval time.Duration
// Timeout bounds each round; 0 means 15s, or the interval when that is
// shorter.
Timeout time.Duration
now func() time.Time
mu sync.Mutex
latest []Result
// prev holds each target's previous verdict for transition logging.
// Only the loop goroutine touches it.
prev map[string]provider.TargetStatus
}
// NeedLeaderElection is true: only the leader dials the hypervisors and
// writes the objects, and only its gauges describe the checks.
func (m *Monitor) NeedLeaderElection() bool { return true }
// ValidateSchedule checks --provider-check-interval and
// --provider-check-timeout. The interval is 0 (off) or at least 10s, which
// is what keeps one status write per provider per round cheap. The timeout
// is 0 (derived from the interval, see Monitor.Timeout) or positive and no
// longer than the interval.
func ValidateSchedule(interval, timeout time.Duration) error {
switch {
case interval == 0:
return nil
case interval < minInterval:
return fmt.Errorf("--provider-check-interval must be 0 (off) or at least %s, got %s", minInterval, interval)
case timeout < 0 || timeout > interval:
return fmt.Errorf("--provider-check-timeout must be at most the interval (%s), got %s", interval, timeout)
}
return nil
}
func (m *Monitor) setDefaults() {
if m.Timeout == 0 {
m.Timeout = min(defaultTimeout, m.Interval)
}
if m.now == nil {
m.now = time.Now
}
}
// Start deletes the objects of providers that are no longer configured,
// then checks every Interval until ctx ends. The providers config is read
// once per process, so cleaning up once is enough. With checking off,
// every object goes, so none is left claiming a stale Ready.
func (m *Monitor) Start(ctx context.Context) error {
m.setDefaults()
log := logf.FromContext(ctx).WithName("provider-health")
ctx = logf.IntoContext(ctx, log)
keep := map[string]bool{}
if m.Interval > 0 {
for _, t := range m.Targets {
keep[t.Name] = true
}
}
m.cleanup(ctx, keep)
if m.Interval == 0 {
log.Info("Provider checks are off (--provider-check-interval=0)")
return nil
}
for _, t := range m.Targets {
if errs := validation.IsDNS1123Subdomain(t.Name); len(errs) > 0 {
log.Info("WARNING: provider name is not a valid object name; its ProviderHealth object is not written, its metrics are",
"provider", t.Name, "problems", errs)
}
}
m.checkOnce(ctx)
ticker := time.NewTicker(m.Interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return nil
case <-ticker.C:
m.checkOnce(ctx)
}
}
}
// Snapshot returns the latest results in config order.
func (m *Monitor) Snapshot() []Result {
m.mu.Lock()
defer m.mu.Unlock()
out := make([]Result, len(m.latest))
for i, r := range m.latest {
r.Targets = slices.Clone(r.Targets)
out[i] = r
}
return out
}
// checkOnce runs every provider's check concurrently under one timeout,
// then records, logs and publishes the results provider by provider. A
// failed write is logged and retried by the next round.
func (m *Monitor) checkOnce(ctx context.Context) {
ctx, span := tracing.Start(ctx, "providerhealth.check")
defer span.End()
log := logf.FromContext(ctx)
checkCtx, cancel := context.WithTimeout(ctx, m.Timeout)
defer cancel()
results := make([]Result, len(m.Targets))
var wg sync.WaitGroup
for i, t := range m.Targets {
wg.Go(func() { results[i] = m.run(checkCtx, t) })
}
wg.Wait()
m.mu.Lock()
m.latest = results
m.mu.Unlock()
notReady := 0
for _, r := range results {
m.logTransitions(log, r)
for _, ts := range r.Targets {
if !ts.Ready {
notReady++
}
}
if err := m.publish(ctx, r); err != nil {
log.Error(err, "Writing provider health", "provider", r.Provider)
}
}
span.SetAttributes(
attribute.Int("providerhealth.providers", len(results)),
attribute.Int("providerhealth.targets_not_ready", notReady),
)
}
func (m *Monitor) run(ctx context.Context, t Target) Result {
r := Result{Provider: t.Name, Type: t.Type, Supported: t.Checker != nil}
if t.Checker != nil {
r.Targets = t.Checker.Check(ctx)
}
r.CheckedAt = m.now()
return r
}
// logTransitions logs a target going down, staying down for a different
// reason, or coming back. Steady states stay quiet; a target that is ready
// the first time it is seen only logs at V(1).
func (m *Monitor) logTransitions(log logr.Logger, r Result) {
if m.prev == nil {
m.prev = map[string]provider.TargetStatus{}
}
for _, ts := range r.Targets {
key := r.Provider + "/" + ts.Name
prev, seen := m.prev[key]
m.prev[key] = ts
kv := []any{"provider", r.Provider, "target", ts.Name, "address", ts.Address, "reason", ts.Reason}
switch {
case !ts.Ready && (!seen || prev.Ready || prev.Reason != ts.Reason):
log.Info("WARNING: provider target not ready", append(kv, "message", ts.Message)...)
case ts.Ready && seen && !prev.Ready:
log.Info("Provider target ready", append(kv, "latency", ts.Latency.Round(time.Millisecond))...)
case ts.Ready && !seen:
log.V(1).Info("Provider target ready", append(kv, "latency", ts.Latency.Round(time.Millisecond))...)
}
}
}
// publish creates the provider's ProviderHealth object when missing and
// patches its status. The status changes on every round, since
// lastCheckTime moves; the comparison only skips the patch when a clock
// did not advance.
func (m *Monitor) publish(ctx context.Context, r Result) error {
if len(validation.IsDNS1123Subdomain(r.Provider)) > 0 {
return nil
}
var ph crawlv1alpha1.ProviderHealth
err := m.Reader.Get(ctx, client.ObjectKey{Name: r.Provider}, &ph)
switch {
case apierrors.IsNotFound(err):
ph = crawlv1alpha1.ProviderHealth{ObjectMeta: metav1.ObjectMeta{
Name: r.Provider,
Labels: map[string]string{labelManagedBy: managedByValue},
}}
if err := m.Client.Create(ctx, &ph); err != nil {
return fmt.Errorf("creating ProviderHealth %s: %w", r.Provider, err)
}
case err != nil:
return fmt.Errorf("reading ProviderHealth %s: %w", r.Provider, err)
}
base := ph.DeepCopy()
ph.Status = buildStatus(ph.Status, r)
if equality.Semantic.DeepEqual(base.Status, ph.Status) {
return nil
}
if err := m.Client.Status().Patch(ctx, &ph, client.MergeFrom(base)); err != nil {
return fmt.Errorf("patching ProviderHealth %s status: %w", r.Provider, err)
}
return nil
}
// cleanup deletes this operator's ProviderHealth objects whose name is not
// in keep.
func (m *Monitor) cleanup(ctx context.Context, keep map[string]bool) {
log := logf.FromContext(ctx)
var list crawlv1alpha1.ProviderHealthList
if err := m.Reader.List(ctx, &list, client.MatchingLabels{labelManagedBy: managedByValue}); err != nil {
log.Error(err, "Listing provider health objects; stale ones stay until the next start")
return
}
for i := range list.Items {
ph := &list.Items[i]
if keep[ph.Name] {
continue
}
if err := m.Client.Delete(ctx, ph); client.IgnoreNotFound(err) != nil {
log.Error(err, "Deleting provider health of an unconfigured provider", "name", ph.Name)
continue
}
log.Info("Deleted provider health of an unconfigured provider", "name", ph.Name)
}
}
+377
View File
@@ -0,0 +1,377 @@
package providerhealth
import (
"context"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/go-logr/logr"
"github.com/go-logr/logr/funcr"
apierrors "k8s.io/apimachinery/pkg/api/errors"
apimeta "k8s.io/apimachinery/pkg/api/meta"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/client/fake"
crawlv1alpha1 "gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/api/v1alpha1"
"gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/internal/provider"
)
var (
t0 = time.Date(2026, 9, 11, 15, 0, 0, 0, time.UTC)
t1 = t0.Add(time.Minute)
)
type checkFunc func(ctx context.Context) []provider.TargetStatus
func (f checkFunc) Check(ctx context.Context) []provider.TargetStatus { return f(ctx) }
func verdicts(ts ...provider.TargetStatus) checkFunc {
return func(context.Context) []provider.TargetStatus { return ts }
}
func up(name string) provider.TargetStatus {
return provider.TargetStatus{Name: name, Address: name + ":22", Ready: true, Reason: provider.ReasonReachable}
}
func down(name, reason string) provider.TargetStatus {
return provider.TargetStatus{Name: name, Address: name + ":22", Reason: reason, Message: name + " is down"}
}
func managed(name string) *crawlv1alpha1.ProviderHealth {
return &crawlv1alpha1.ProviderHealth{ObjectMeta: metav1.ObjectMeta{
Name: name,
Labels: map[string]string{labelManagedBy: managedByValue},
}}
}
// newFakeClient registers ProviderHealth as cluster-scoped; the fake's
// default mapper would treat it as namespaced.
func newFakeClient(t *testing.T, objs ...client.Object) client.WithWatch {
t.Helper()
scheme := runtime.NewScheme()
if err := crawlv1alpha1.AddToScheme(scheme); err != nil {
t.Fatal(err)
}
mapper := apimeta.NewDefaultRESTMapper([]schema.GroupVersion{crawlv1alpha1.GroupVersion})
mapper.Add(crawlv1alpha1.GroupVersion.WithKind("ProviderHealth"), apimeta.RESTScopeRoot)
return fake.NewClientBuilder().
WithScheme(scheme).
WithRESTMapper(mapper).
WithStatusSubresource(&crawlv1alpha1.ProviderHealth{}).
WithObjects(objs...).
Build()
}
func newTestMonitor(c client.Client, targets ...Target) *Monitor {
return &Monitor{
Client: c,
Reader: c,
Targets: targets,
Interval: time.Minute,
Timeout: time.Second,
now: func() time.Time { return t1 },
}
}
func getHealth(t *testing.T, c client.Reader, name string) *crawlv1alpha1.ProviderHealth {
t.Helper()
var ph crawlv1alpha1.ProviderHealth
if err := c.Get(context.Background(), client.ObjectKey{Name: name}, &ph); err != nil {
t.Fatalf("Get ProviderHealth %s: %v", name, err)
}
return &ph
}
func readyCondition(ph *crawlv1alpha1.ProviderHealth) *metav1.Condition {
return apimeta.FindStatusCondition(ph.Status.Conditions, crawlv1alpha1.ConditionReady)
}
func TestMonitor_checkOnce_createsLabelledObjects(t *testing.T) {
t.Parallel()
c := newFakeClient(t)
m := newTestMonitor(c,
Target{Name: "kvm-home", Type: "kvm", Checker: verdicts(up("nas1"), down("nas2", provider.ReasonUnreachable))},
Target{Name: "local", Type: "kubernetes", Checker: verdicts(up("apiserver"))},
)
m.checkOnce(context.Background())
kvm := getHealth(t, c, "kvm-home")
if kvm.Labels[labelManagedBy] != managedByValue {
t.Errorf("labels = %v, want %s=%s", kvm.Labels, labelManagedBy, managedByValue)
}
if cond := readyCondition(kvm); cond == nil || cond.Status != metav1.ConditionFalse || cond.Reason != reasonTargetsNotReady {
t.Errorf("kvm-home Ready = %+v, want False/%s", cond, reasonTargetsNotReady)
}
if kvm.Status.Type != "kvm" || kvm.Status.TargetsReady != "1/2" || kvm.Status.LastCheckTime == nil || !kvm.Status.LastCheckTime.Time.Equal(t1) {
t.Errorf("kvm-home status = %+v, want type kvm, 1/2 ready, checked at %s", kvm.Status, t1)
}
local := getHealth(t, c, "local")
if cond := readyCondition(local); cond == nil || cond.Status != metav1.ConditionTrue {
t.Errorf("local Ready = %+v, want True", cond)
}
}
func TestMonitor_checkOnce_patchesExisting(t *testing.T) {
t.Parallel()
existing := managed("local")
existing.Status = crawlv1alpha1.ProviderHealthStatus{
Type: "kubernetes",
Targets: []crawlv1alpha1.ProviderTarget{readyAt("apiserver", t0)},
}
c := newFakeClient(t, existing)
m := newTestMonitor(c, Target{Name: "local", Type: "kubernetes", Checker: verdicts(up("apiserver"))})
m.checkOnce(context.Background())
got := getHealth(t, c, "local")
if got.Status.LastCheckTime == nil || !got.Status.LastCheckTime.Time.Equal(t1) {
t.Errorf("lastCheckTime = %v, want %s", got.Status.LastCheckTime, t1)
}
if lt := got.Status.Targets[0].LastTransitionTime.Time; !lt.Equal(t0) {
t.Errorf("apiserver lastTransitionTime = %s, want %s kept: it was ready before too", lt, t0)
}
}
func TestMonitor_checkOnce_timeoutBoundsEachRound(t *testing.T) {
t.Parallel()
c := newFakeClient(t)
var hadDeadline atomic.Bool
hanging := checkFunc(func(ctx context.Context) []provider.TargetStatus {
_, ok := ctx.Deadline()
hadDeadline.Store(ok)
<-ctx.Done()
return []provider.TargetStatus{down("nas1", provider.ReasonTimeout)}
})
m := newTestMonitor(c,
Target{Name: "kvm-home", Type: "kvm", Checker: hanging},
Target{Name: "local", Type: "kubernetes", Checker: verdicts(up("apiserver"))},
)
m.Timeout = 50 * time.Millisecond
done := make(chan struct{})
go func() {
m.checkOnce(context.Background())
close(done)
}()
select {
case <-done:
case <-time.After(5 * time.Second):
t.Fatal("checkOnce still running 5s after a 50ms timeout")
}
if !hadDeadline.Load() {
t.Error("the checker got a context without a deadline")
}
if cond := readyCondition(getHealth(t, c, "kvm-home")); cond == nil || cond.Status != metav1.ConditionFalse {
t.Errorf("kvm-home Ready = %+v, want False", cond)
}
if cond := readyCondition(getHealth(t, c, "local")); cond == nil || cond.Status != metav1.ConditionTrue {
t.Errorf("local Ready = %+v, want True despite the slow neighbour", cond)
}
}
func TestMonitor_checkOnce_noCheckerIsUnknown(t *testing.T) {
t.Parallel()
c := newFakeClient(t)
m := newTestMonitor(c, Target{Name: "gcp-eu", Type: "gcp"})
m.checkOnce(context.Background())
got := getHealth(t, c, "gcp-eu")
if cond := readyCondition(got); cond == nil || cond.Status != metav1.ConditionUnknown || cond.Reason != reasonChecksNotImplemented {
t.Errorf("Ready = %+v, want Unknown/%s", cond, reasonChecksNotImplemented)
}
}
func TestMonitor_checkOnce_invalidNameSkipsTheObject(t *testing.T) {
t.Parallel()
c := newFakeClient(t)
m := newTestMonitor(c, Target{Name: "Home_KVM", Type: "kvm", Checker: verdicts(up("nas1"))})
m.checkOnce(context.Background())
var list crawlv1alpha1.ProviderHealthList
if err := c.List(context.Background(), &list); err != nil {
t.Fatal(err)
}
if len(list.Items) != 0 {
t.Errorf("wrote %d objects, want none for an invalid name", len(list.Items))
}
snap := m.Snapshot()
if len(snap) != 1 || snap[0].Provider != "Home_KVM" || !snap[0].Targets[0].Ready {
t.Errorf("Snapshot() = %+v, want the result kept for metrics", snap)
}
}
func TestMonitor_logsOnlyTransitions(t *testing.T) {
t.Parallel()
var mu sync.Mutex
var lines []string
log := funcr.New(func(_, args string) {
mu.Lock()
defer mu.Unlock()
lines = append(lines, args)
}, funcr.Options{})
ctx := logr.NewContext(context.Background(), log)
rounds := [][]provider.TargetStatus{
{up("nas1")},
{up("nas1")},
{down("nas1", provider.ReasonUnreachable)},
{down("nas1", provider.ReasonUnreachable)},
{down("nas1", provider.ReasonTimeout)},
{up("nas1")},
}
round := 0
m := newTestMonitor(newFakeClient(t), Target{Name: "kvm-home", Type: "kvm", Checker: checkFunc(
func(context.Context) []provider.TargetStatus { return rounds[round] })})
for round = range rounds {
m.checkOnce(ctx)
}
mu.Lock()
defer mu.Unlock()
want := [][]string{
{`"msg"="WARNING: provider target not ready"`, `"reason"="Unreachable"`},
{`"msg"="WARNING: provider target not ready"`, `"reason"="Timeout"`},
{`"msg"="Provider target ready"`},
}
if len(lines) != len(want) {
t.Fatalf("logged %d lines, want %d:\n%s", len(lines), len(want), strings.Join(lines, "\n"))
}
for i, subs := range want {
for _, sub := range subs {
if !strings.Contains(lines[i], sub) {
t.Errorf("line %d missing %s:\n%s", i, sub, lines[i])
}
}
}
}
func TestMonitor_cleanup_deletesOnlyLabelledUnconfigured(t *testing.T) {
t.Parallel()
foreign := &crawlv1alpha1.ProviderHealth{ObjectMeta: metav1.ObjectMeta{Name: "foreign"}}
c := newFakeClient(t, managed("kvm-home"), managed("retired"), foreign)
m := newTestMonitor(c)
m.cleanup(context.Background(), map[string]bool{"kvm-home": true})
getHealth(t, c, "kvm-home")
getHealth(t, c, "foreign")
var gone crawlv1alpha1.ProviderHealth
if err := c.Get(context.Background(), client.ObjectKey{Name: "retired"}, &gone); !apierrors.IsNotFound(err) {
t.Errorf("Get retired = %v, want NotFound", err)
}
}
func TestMonitor_Start_offCleansUpAndReturns(t *testing.T) {
t.Parallel()
c := newFakeClient(t, managed("kvm-home"))
var checked atomic.Bool
m := newTestMonitor(c, Target{Name: "kvm-home", Type: "kvm", Checker: checkFunc(
func(context.Context) []provider.TargetStatus { checked.Store(true); return nil })})
m.Interval = 0
errc := make(chan error, 1)
go func() { errc <- m.Start(context.Background()) }()
select {
case err := <-errc:
if err != nil {
t.Fatalf("Start() = %v, want nil", err)
}
case <-time.After(5 * time.Second):
t.Fatal("Start did not return with checking off")
}
if checked.Load() {
t.Error("a check ran with checking off")
}
var gone crawlv1alpha1.ProviderHealth
if err := c.Get(context.Background(), client.ObjectKey{Name: "kvm-home"}, &gone); !apierrors.IsNotFound(err) {
t.Errorf("Get kvm-home = %v, want NotFound so no stale Ready is left behind", err)
}
}
func TestMonitor_Start_checksAtOnceAndStops(t *testing.T) {
t.Parallel()
c := newFakeClient(t)
m := newTestMonitor(c, Target{Name: "local", Type: "kubernetes", Checker: verdicts(up("apiserver"))})
m.Interval = time.Hour
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
errc := make(chan error, 1)
go func() { errc <- m.Start(ctx) }()
deadline := time.Now().Add(5 * time.Second)
for len(m.Snapshot()) == 0 {
if time.Now().After(deadline) {
t.Fatal("no check within 5s of Start, want one right away")
}
time.Sleep(10 * time.Millisecond)
}
cancel()
select {
case err := <-errc:
if err != nil {
t.Errorf("Start() = %v, want nil", err)
}
case <-time.After(5 * time.Second):
t.Fatal("Start did not return after its context ended")
}
}
func TestValidateSchedule(t *testing.T) {
t.Parallel()
tests := []struct {
name string
interval, timeout time.Duration
wantErr bool
}{
{"off", 0, 0, false},
{"defaults", time.Minute, 15 * time.Second, false},
{"shortest interval", 10 * time.Second, 10 * time.Second, false},
{"too frequent", 5 * time.Second, time.Second, true},
{"negative interval", -time.Minute, time.Second, true},
{"timeout left to derive, short interval", 12 * time.Second, 0, false},
{"negative timeout", time.Minute, -time.Second, true},
{"timeout longer than interval", time.Minute, 2 * time.Minute, true},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
if err := ValidateSchedule(tc.interval, tc.timeout); (err != nil) != tc.wantErr {
t.Errorf("ValidateSchedule(%s, %s) = %v, want error %v", tc.interval, tc.timeout, err, tc.wantErr)
}
})
}
}
func TestMonitor_setDefaults_timeout(t *testing.T) {
t.Parallel()
tests := []struct {
name string
interval, timeout, want time.Duration
}{
{"default", time.Minute, 0, 15 * time.Second},
{"interval shorter than the default", 12 * time.Second, 0, 12 * time.Second},
{"explicit", time.Minute, 20 * time.Second, 20 * time.Second},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
m := &Monitor{Interval: tc.interval, Timeout: tc.timeout}
m.setDefaults()
if m.Timeout != tc.want {
t.Errorf("Timeout = %s, want %s", m.Timeout, tc.want)
}
})
}
}
+98
View File
@@ -0,0 +1,98 @@
package providerhealth
import (
"fmt"
"slices"
"strings"
"unicode/utf8"
apimeta "k8s.io/apimachinery/pkg/api/meta"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
crawlv1alpha1 "gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/api/v1alpha1"
)
// Reasons on the Ready condition.
const (
reasonAllTargetsReady = "AllTargetsReady"
reasonTargetsNotReady = "TargetsNotReady"
reasonChecksNotImplemented = "ChecksNotImplemented"
reasonNoTargets = "NoTargets"
)
// maxMessageLen matches the CRD's limit on a target's message.
const maxMessageLen = 1024
// buildStatus derives a ProviderHealth status from the previous one and a
// fresh result. Every timestamp comes from r.CheckedAt, and prev is left
// untouched. A target keeps its lastTransitionTime while ready does not
// flip, which is how several hosts each show when they went down.
func buildStatus(prev crawlv1alpha1.ProviderHealthStatus, r Result) crawlv1alpha1.ProviderHealthStatus {
now := metav1.NewTime(r.CheckedAt)
out := crawlv1alpha1.ProviderHealthStatus{
Type: r.Type,
Conditions: slices.Clone(prev.Conditions),
LastCheckTime: &now,
}
before := make(map[string]crawlv1alpha1.ProviderTarget, len(prev.Targets))
for _, t := range prev.Targets {
before[t.Name] = t
}
ready := 0
var failing []string
for _, ts := range r.Targets {
t := crawlv1alpha1.ProviderTarget{
Name: ts.Name,
Address: ts.Address,
Ready: ts.Ready,
Reason: ts.Reason,
Message: truncate(ts.Message, maxMessageLen),
LatencyMillis: ts.Latency.Milliseconds(),
LastTransitionTime: now,
Details: ts.Details,
}
if b, ok := before[ts.Name]; ok && b.Ready == ts.Ready {
t.LastTransitionTime = b.LastTransitionTime
}
if ts.Ready {
ready++
} else {
failing = append(failing, ts.Name+": "+ts.Reason)
}
out.Targets = append(out.Targets, t)
}
if r.Supported {
out.TargetsReady = fmt.Sprintf("%d/%d", ready, len(r.Targets))
}
cond := metav1.Condition{Type: crawlv1alpha1.ConditionReady, LastTransitionTime: now}
switch {
case !r.Supported:
cond.Status = metav1.ConditionUnknown
cond.Reason = reasonChecksNotImplemented
cond.Message = fmt.Sprintf("%s providers have no health check", r.Type)
case len(r.Targets) == 0:
cond.Status = metav1.ConditionUnknown
cond.Reason = reasonNoTargets
cond.Message = "the provider has nothing to check"
case len(failing) == 0:
cond.Status = metav1.ConditionTrue
cond.Reason = reasonAllTargetsReady
cond.Message = fmt.Sprintf("%d of %d targets ready", ready, len(r.Targets))
default:
cond.Status = metav1.ConditionFalse
cond.Reason = reasonTargetsNotReady
cond.Message = truncate(strings.Join(failing, "; "), maxMessageLen)
}
apimeta.SetStatusCondition(&out.Conditions, cond)
return out
}
// truncate cuts s to at most n characters, which is how the API server
// counts maxLength.
func truncate(s string, n int) string {
if utf8.RuneCountInString(s) <= n {
return s
}
return string([]rune(s)[:n-1]) + "…"
}
+135
View File
@@ -0,0 +1,135 @@
package providerhealth
import (
"strings"
"testing"
"time"
"unicode/utf8"
apimeta "k8s.io/apimachinery/pkg/api/meta"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
crawlv1alpha1 "gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/api/v1alpha1"
"gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/internal/provider"
)
func readyAt(name string, at time.Time) crawlv1alpha1.ProviderTarget {
return crawlv1alpha1.ProviderTarget{Name: name, Ready: true, Reason: provider.ReasonReachable, LastTransitionTime: metav1.NewTime(at)}
}
func TestBuildStatus(t *testing.T) {
t.Parallel()
tests := []struct {
name string
prev crawlv1alpha1.ProviderHealthStatus
result Result
wantStatus metav1.ConditionStatus
wantReason string
wantMessage string
wantTargetsReady string
// wantTransitions is each target's lastTransitionTime, in order.
wantTransitions []time.Time
}{
{
name: "all ready",
result: Result{Type: "kvm", Supported: true, Targets: []provider.TargetStatus{up("nas1"), up("nas2")}, CheckedAt: t1},
wantStatus: metav1.ConditionTrue,
wantReason: reasonAllTargetsReady,
wantMessage: "2 of 2 targets ready",
wantTargetsReady: "2/2",
wantTransitions: []time.Time{t1, t1},
},
{
name: "one target down",
result: Result{Type: "kvm", Supported: true, Targets: []provider.TargetStatus{up("nas1"), down("nas2", provider.ReasonUnreachable)}, CheckedAt: t1},
wantStatus: metav1.ConditionFalse,
wantReason: reasonTargetsNotReady,
wantMessage: "nas2: Unreachable",
wantTargetsReady: "1/2",
wantTransitions: []time.Time{t1, t1},
},
{
name: "only a flip moves a target's transition time",
prev: crawlv1alpha1.ProviderHealthStatus{Targets: []crawlv1alpha1.ProviderTarget{readyAt("nas1", t0), readyAt("nas2", t0)}},
result: Result{Type: "kvm", Supported: true, CheckedAt: t1,
Targets: []provider.TargetStatus{up("nas1"), down("nas2", provider.ReasonTimeout)}},
wantStatus: metav1.ConditionFalse,
wantReason: reasonTargetsNotReady,
wantMessage: "nas2: Timeout",
wantTargetsReady: "1/2",
wantTransitions: []time.Time{t0, t1},
},
{
name: "no checker",
result: Result{Type: "gcp", CheckedAt: t1},
wantStatus: metav1.ConditionUnknown,
wantReason: reasonChecksNotImplemented,
},
{
name: "nothing to check",
result: Result{Type: "kvm", Supported: true, CheckedAt: t1},
wantStatus: metav1.ConditionUnknown,
wantReason: reasonNoTargets,
wantTargetsReady: "0/0",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
got := buildStatus(tc.prev, tc.result)
cond := apimeta.FindStatusCondition(got.Conditions, crawlv1alpha1.ConditionReady)
if cond == nil || cond.Status != tc.wantStatus || cond.Reason != tc.wantReason {
t.Fatalf("Ready = %+v, want %s/%s", cond, tc.wantStatus, tc.wantReason)
}
if tc.wantMessage != "" && cond.Message != tc.wantMessage {
t.Errorf("Ready message = %q, want %q", cond.Message, tc.wantMessage)
}
if got.TargetsReady != tc.wantTargetsReady {
t.Errorf("targetsReady = %q, want %q", got.TargetsReady, tc.wantTargetsReady)
}
if got.Type != tc.result.Type || got.LastCheckTime == nil || !got.LastCheckTime.Time.Equal(t1) {
t.Errorf("type/lastCheckTime = %q/%v, want %q/%s", got.Type, got.LastCheckTime, tc.result.Type, t1)
}
if len(got.Targets) != len(tc.wantTransitions) {
t.Fatalf("got %d targets, want %d", len(got.Targets), len(tc.wantTransitions))
}
for i, want := range tc.wantTransitions {
if lt := got.Targets[i].LastTransitionTime.Time; !lt.Equal(want) {
t.Errorf("target %s lastTransitionTime = %s, want %s", got.Targets[i].Name, lt, want)
}
}
})
}
}
func TestBuildStatus_conditionKeepsTransitionWhileUnchanged(t *testing.T) {
t.Parallel()
first := buildStatus(crawlv1alpha1.ProviderHealthStatus{},
Result{Type: "kvm", Supported: true, Targets: []provider.TargetStatus{up("nas1")}, CheckedAt: t0})
firstCond := apimeta.FindStatusCondition(first.Conditions, crawlv1alpha1.ConditionReady)
second := buildStatus(first, Result{Type: "kvm", Supported: true, Targets: []provider.TargetStatus{up("nas1")}, CheckedAt: t1})
cond := apimeta.FindStatusCondition(second.Conditions, crawlv1alpha1.ConditionReady)
if !cond.LastTransitionTime.Time.Equal(t0) {
t.Errorf("Ready lastTransitionTime = %s, want %s from the first round", cond.LastTransitionTime, t0)
}
if !firstCond.LastTransitionTime.Time.Equal(t0) || len(first.Conditions) != 1 {
t.Errorf("buildStatus modified the previous status: %+v", first.Conditions)
}
}
func TestBuildStatus_truncatesLongMessages(t *testing.T) {
t.Parallel()
long := strings.Repeat("é", 3000)
got := buildStatus(crawlv1alpha1.ProviderHealthStatus{}, Result{Type: "kvm", Supported: true, CheckedAt: t1,
Targets: []provider.TargetStatus{{Name: "nas1", Reason: provider.ReasonUnreachable, Message: long}}})
if n := utf8.RuneCountInString(got.Targets[0].Message); n != maxMessageLen {
t.Errorf("message has %d characters, want %d", n, maxMessageLen)
}
if !strings.HasSuffix(got.Targets[0].Message, "…") {
t.Error("truncated message does not end with an ellipsis")
}
}
+16
View File
@@ -173,6 +173,20 @@ var _ = Describe("Manager", Ordered, func() {
Eventually(verifyControllerUp).Should(Succeed())
})
// Runs before the metrics spec on purpose: once a ProviderHealth is
// Ready, the first round of checks is done and its gauges exist.
It("should publish provider health", func() {
By("waiting for the kubernetes provider's ProviderHealth to report Ready")
verifyProviderReady := func(g Gomega) {
cmd := exec.Command("kubectl", "get", "providerhealth", "kubernetes",
"-o", `jsonpath={.status.conditions[?(@.type=="Ready")].status}`)
output, err := utils.Run(cmd)
g.Expect(err).NotTo(HaveOccurred())
g.Expect(output).To(Equal("True"), "kubernetes provider not Ready")
}
Eventually(verifyProviderReady).Should(Succeed())
})
It("should ensure the metrics endpoint is serving metrics", func() {
By("creating a ClusterRoleBinding for the service account to allow access to metrics")
cmd := exec.Command("kubectl", "create", "clusterrolebinding", metricsRoleBindingName,
@@ -264,6 +278,8 @@ var _ = Describe("Manager", Ordered, func() {
g.Expect(err).NotTo(HaveOccurred(), "Failed to retrieve logs from curl pod")
g.Expect(metricsOutput).NotTo(BeEmpty())
g.Expect(metricsOutput).To(ContainSubstring("< HTTP/1.1 200 OK"))
g.Expect(metricsOutput).To(ContainSubstring(
`proxy_operator_provider_target_up{provider="kubernetes",target="apiserver"} 1`))
}
Eventually(verifyMetricsAvailable, 2*time.Minute).Should(Succeed())
})