provider: extract nocloud seed builder and keyedlock from kvm2
kvm2's NoCloud seed builder moves to internal/provider/nocloud (exported, with an ErrEmptyName sentinel instead of permanentf) and its keyed locks to internal/provider/keyedlock, tests moved under their existing names. kvm2 behaviour is unchanged. proxmox gains the per-name lock and the pure seed helpers (seedISOName, seedVolID, buildSeed); unlike kvm2 (review finding S5.1), a seed staging failure there is Transient, not Permanent. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -11,6 +11,7 @@
|
|||||||
| gcp provider | `internal/provider/gcp` | library | — | Compute Engine VMs, four API calls, fire-and-forget ops |
|
| 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 |
|
| kvm provider | `internal/provider/kvm` | library | — | VMs on libvirt hosts over SSH-forwarded sockets; image cache, overlay disks, NoCloud seeds |
|
||||||
| kvm2 provider | `internal/provider/kvm2` | library | — | the same backend, built clean-room from the provider contract as a second type: per-RPC child spans, pool-space quota, per-call budgets, its own metadata namespace, credentials re-read on every dial |
|
| kvm2 provider | `internal/provider/kvm2` | library | — | the same backend, built clean-room from the provider contract as a second type: per-RPC child spans, pool-space quota, per-call budgets, its own metadata namespace, credentials re-read on every dial |
|
||||||
|
| Shared provider helpers | `internal/provider/nocloud`, `internal/provider/keyedlock` | library | — | the NoCloud seed ISO and vendor-data builder, and one-slot semaphores by key; used by kvm2 and proxmox |
|
||||||
| Health engine | `internal/health` | Runnable | yes | through-the-proxy probes, thresholds, transition events |
|
| Health engine | `internal/health` | Runnable | yes | through-the-proxy probes, thresholds, transition events |
|
||||||
| Lease store | `internal/lease` | Runnable (expiry sweep) | no | in-memory leases + cooldowns, single mutex |
|
| 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` |
|
| Discovery API | `internal/discovery` | Runnable | no | HTTP list/lease/release/report on `:8090` |
|
||||||
|
|||||||
@@ -511,7 +511,7 @@ every 15 s) marks the connection dead on failure.
|
|||||||
per host, dialled lazily, under a mutex with a per-host dial mutex so
|
per host, dialled lazily, under a mutex with a per-host dial mutex so
|
||||||
concurrent callers dial once and share the result), the per-`(host, volume)`
|
concurrent callers dial once and share the result), the per-`(host, volume)`
|
||||||
download locks, and a lock per instance name. Both lock sets are one instance
|
download locks, and a lock per instance name. Both lock sets are one instance
|
||||||
each of `keyedLocks`: a one-slot semaphore per key, so a waiter gives up when
|
each of `keyedlock.Locks` (`internal/provider/keyedlock`): a one-slot semaphore per key, so a waiter gives up when
|
||||||
its ctx ends, reference-counted so an entry is removed from the map once no
|
its ctx ends, reference-counted so an entry is removed from the map once no
|
||||||
caller holds or waits for it and the map does not grow with every name ever
|
caller holds or waits for it and the map does not grow with every name ever
|
||||||
seen, while two waiters on one key always share one semaphore. Create and
|
seen, while two waiters on one key always share one semaphore. Create and
|
||||||
|
|||||||
@@ -252,7 +252,8 @@ UIDs, and a marked domain with a matching or empty `uid` is adopted. The
|
|||||||
namespace is kvm2's own, so the `kvm` provider's domains are invisible to
|
namespace is kvm2's own, so the `kvm` provider's domains are invisible to
|
||||||
it (see *Migrating from kvm*).
|
it (see *Migrating from kvm*).
|
||||||
|
|
||||||
The seed (`buildSeedISO` in `seed.go`) is an ISO 9660 image labelled
|
The seed (`nocloud.BuildSeedISO`, shared with the proxmox provider in
|
||||||
|
`internal/provider/nocloud`) is an ISO 9660 image labelled
|
||||||
`cidata` with three files. `user-data` is the Proxy's `cloudInit`, **byte
|
`cidata` with three files. `user-data` is the Proxy's `cloudInit`, **byte
|
||||||
for byte**: any format cloud-init accepts works, including a Jinja
|
for byte**: any format cloud-init accepts works, including a Jinja
|
||||||
template, a MIME archive, a shell script or a gzipped payload; the
|
template, a MIME archive, a shell script or a gzipped payload; the
|
||||||
@@ -267,7 +268,7 @@ previous attempt may have been cut off mid-upload and a truncated seed
|
|||||||
would boot a VM without cloud-init.
|
would boot a VM without cloud-init.
|
||||||
|
|
||||||
The guest agent rides along in `vendor-data` as a **shell script**
|
The guest agent rides along in `vendor-data` as a **shell script**
|
||||||
(`guestAgentScript` in `seed.go`), not a cloud-config, on purpose:
|
(`nocloud.GuestAgentScript`), not a cloud-config, on purpose:
|
||||||
cloud-init merges a vendor-data cloud-config under the user-data with
|
cloud-init merges a vendor-data cloud-config under the user-data with
|
||||||
mergers that keep the user-data's value for every top-level key both
|
mergers that keep the user-data's value for every top-level key both
|
||||||
define and never append lists, so a Proxy carrying its own `packages:`
|
define and never append lists, so a Proxy carrying its own `packages:`
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ Pairs with [docs/plans/2026-09-18-1502-proxmox-provider.md](../plans/2026-09-18-
|
|||||||
- [x] Phase 0 — Plan file, branch, host recon, fixtures
|
- [x] Phase 0 — Plan file, branch, host recon, fixtures
|
||||||
- [x] Phase 1 — The spec (`docs/proxmox-provider-spec.md`)
|
- [x] Phase 1 — The spec (`docs/proxmox-provider-spec.md`)
|
||||||
- [x] Phase 2 — Config block, registration, package skeleton
|
- [x] Phase 2 — Config block, registration, package skeleton
|
||||||
- [ ] Phase 3 — Extract `nocloud` and `keyedlock` from kvm2
|
- [x] Phase 3 — Extract `nocloud` and `keyedlock` from kvm2
|
||||||
- [ ] Phase 4 — Seam, HTTP client, decorator, fake
|
- [ ] Phase 4 — Seam, HTTP client, decorator, fake
|
||||||
- [ ] Phase 5 — Get, ListByTag, task helpers
|
- [ ] Phase 5 — Get, ListByTag, task helpers
|
||||||
- [ ] Phase 6 — Create
|
- [ ] Phase 6 — Create
|
||||||
@@ -381,3 +381,69 @@ go test -count=1 -v ./internal/provider/proxmox/ | grep -c '^=== RUN' # 152
|
|||||||
Worth noting: the `validProxmoxYAML` constant is the shape Phase 9's commented
|
Worth noting: the `validProxmoxYAML` constant is the shape Phase 9's commented
|
||||||
sample entry should take, with the user's values (`192.168.0.8`, `pv1-1`,
|
sample entry should take, with the user's values (`192.168.0.8`, `pv1-1`,
|
||||||
`proxy-seeds`, `vlanTag: 4`, template 9001).
|
`proxy-seeds`, `vlanTag: 4`, template 9001).
|
||||||
|
|
||||||
|
## Phase 3 — Extract `nocloud` and `keyedlock` from kvm2
|
||||||
|
|
||||||
|
kvm2's `seed.go` and `seed_test.go` moved to `internal/provider/nocloud`
|
||||||
|
with `git mv`, so history follows them. The package exports `BuildSeedISO`,
|
||||||
|
`BuildVendorData`, `GuestAgentScript`, `SeedLabel` and a new `ErrEmptyName`
|
||||||
|
sentinel in place of kvm2's `permanentf`. kvm2's `keyedLocks` became
|
||||||
|
`internal/provider/keyedlock` (`Locks`, `New`, `Acquire`), and its three
|
||||||
|
`TestKeyedLocks_*` tests moved with it under their old names. kvm2 now
|
||||||
|
imports both packages, and its `create.go` still wraps every seed-build error
|
||||||
|
with `permanentf`, so kvm2's behaviour is unchanged. proxmox gained the
|
||||||
|
`nameLocks` field and `lockName`.
|
||||||
|
|
||||||
|
The test counts match the plan's Verify clause: kvm2 dropped from 465 to 447
|
||||||
|
`=== RUN` entries, which is the 12 moved tests plus the 6 subtests of
|
||||||
|
`TestSeedISO_userDataByteForByte`. `nocloud` (15) and `keyedlock` (3) carry
|
||||||
|
exactly those 18.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git mv internal/provider/kvm2/seed.go internal/provider/nocloud/seed.go
|
||||||
|
git mv internal/provider/kvm2/seed_test.go internal/provider/nocloud/seed_test.go
|
||||||
|
go test -count=1 -v ./internal/provider/kvm2/ | grep -c '^=== RUN' # 465 before, 447 after
|
||||||
|
grep -rn 'iso9660' internal/provider/kvm2/ # nothing
|
||||||
|
go build ./... && go vet ./... && go vet -tags=integration ./...
|
||||||
|
go test -race -count=1 ./internal/provider/... # all ok, run three times
|
||||||
|
go test -short -count=1 ./... # all ok
|
||||||
|
```
|
||||||
|
|
||||||
|
**Pulled forward from Phase 6 so proxmox imports `nocloud` now**, as the
|
||||||
|
"Done when" line requires: `seed.go` with `seedISOName`, `seedVolID` and
|
||||||
|
`buildSeed`, the pure part of the seed step. `uploadSeed` still belongs to
|
||||||
|
Phase 6, which calls `buildSeed`. The new tests are `TestSeedNames_table`,
|
||||||
|
`TestBuildSeed_table` (4 rows),
|
||||||
|
`TestBuildSeed_emptyNameIsPermanentWithoutPayload`,
|
||||||
|
`TestBuildSeed_stagingFailureIsTransient` and
|
||||||
|
`TestLockName_givesUpWithContextAsTransient`. proxmox went from 152 to 161
|
||||||
|
test entries.
|
||||||
|
|
||||||
|
**proxmox does not copy kvm2's open review finding S5.1**
|
||||||
|
(`docs/reviews/2026-09-17-kvm2-provider-review/sweep.md`). In kvm2, a
|
||||||
|
staging failure such as a missing `/tmp` or a full emptyDir becomes
|
||||||
|
Permanent, and that latches the Proxy. `buildSeed` makes only
|
||||||
|
`ErrEmptyName` Permanent and wraps any other error with `%w`, so
|
||||||
|
`classify` reads it as Transient. The sentinel makes the same three-line
|
||||||
|
fix possible in kvm2, but this phase leaves kvm2's behaviour unchanged.
|
||||||
|
|
||||||
|
Worth noting:
|
||||||
|
|
||||||
|
- The older `kvm` provider has its own `seed.go` with a different
|
||||||
|
signature (`buildSeedISO` returns `[]byte`). The plan scopes the
|
||||||
|
extraction to kvm2, so `kvm` is untouched.
|
||||||
|
- `GuestAgentScript` now says the operator reads the address "from the
|
||||||
|
hypervisor" instead of "through libvirt", in both the Go comment and the
|
||||||
|
script's comment line inside the guest. The script's commands are
|
||||||
|
unchanged.
|
||||||
|
- The docs now name the new packages: `kvm2-provider.md`,
|
||||||
|
`kvm2-provider-spec.md` entry 19, the kvm2 columns and section of
|
||||||
|
`providers-existing.md`, plus a "Shared provider helpers" row in the
|
||||||
|
`architecture.md` component table. `docs/reviews/` keeps the old names,
|
||||||
|
because it records the code as it was reviewed.
|
||||||
|
- `TestCheck_hostsCheckedConcurrently` in kvm2 failed once under the full
|
||||||
|
`-race` suite (latency 199.96 ms against a 200 ms floor). It passed 20
|
||||||
|
runs out of 20 in isolation, with and without this phase's changes. The
|
||||||
|
test starts its 200 ms release timers before `Check` starts its clock, so
|
||||||
|
under load the measured latency can come in just under 200 ms. The flake
|
||||||
|
predates this phase, and the test is not touched here.
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ against.
|
|||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| Create verifies the adopted instance's UID | no | no | no | **yes**: a domain without the kvm2 mark or with a different non-empty UID is Permanent; a marked one with a matching or empty UID is adopted (`create`) |
|
| Create verifies the adopted instance's UID | no | no | no | **yes**: a domain without the kvm2 mark or with a different non-empty UID is Permanent; a marked one with a matching or empty UID is adopted (`create`) |
|
||||||
| Placement used | none | Zone, MachineType, Image | Zone (host), MachineType, Image | Zone (host), MachineType, Image |
|
| Placement used | none | Zone, MachineType, Image | Zone (host), MachineType, Image | Zone (host), MachineType, Image |
|
||||||
| CloudInit delivered | **no**: ignored | yes: `user-data` metadata | yes: NoCloud seed ISO built in-process (`buildSeedISO`) | yes: NoCloud seed ISO built in-process (`buildSeedISO`) |
|
| CloudInit delivered | **no**: ignored | yes: `user-data` metadata | yes: NoCloud seed ISO built in-process (`buildSeedISO`) | yes: NoCloud seed ISO built in-process (`nocloud.BuildSeedISO`) |
|
||||||
| Port used | yes: Squid config and containerPort (`squidConf`) | no: firewall is manual | no | no |
|
| Port used | yes: Squid config and containerPort (`squidConf`) | no: firewall is manual | no | no |
|
||||||
| States produced | Provisioning, Running, Terminated | all four | Provisioning, Running, Stopped | Provisioning, Running, Stopped |
|
| States produced | Provisioning, Running, Terminated | all four | Provisioning, Running, Stopped | Provisioning, Running, Stopped |
|
||||||
| Deleting instance still reports its state | yes: a Pod being deleted reports its phase | n/a | n/a | n/a: Delete waits, and a concurrent Get sees the domain until it is undefined |
|
| Deleting instance still reports its state | yes: a Pod being deleted reports its phase | n/a | n/a | n/a: Delete waits, and a concurrent Get sees the domain until it is undefined |
|
||||||
@@ -68,7 +68,7 @@ against.
|
|||||||
| Credential rotation without restart | n/a | via ADC | no: cached after first successful load | yes: both files read on every dial (`loadCredentials`) |
|
| Credential rotation without restart | n/a | via ADC | no: cached after first successful load | yes: both files read on every dial (`loadCredentials`) |
|
||||||
| Connection handling | client-go | SDK client, never closed | one pooled SSH plus libvirt connection per host, 15s keepalive (`hostPool`) | one pooled SSH plus libvirt connection per host, 15s keepalive (`hostTable`); dedicated connections for the image upload and the check (`dialFresh`) |
|
| Connection handling | client-go | SDK client, never closed | one pooled SSH plus libvirt connection per host, 15s keepalive (`hostPool`) | one pooled SSH plus libvirt connection per host, 15s keepalive (`hostTable`); dedicated connections for the image upload and the check (`dialFresh`) |
|
||||||
| Unit-test double | controller-runtime fake client | `fakeAPI` behind `instancesAPI`, plus `httptest` for the real SDK client in `check_test.go` | `fakeAPI` behind `libvirtAPI`, plus an in-process `x/crypto/ssh` server in `conn_test.go` | `fakeBackend` behind `backend` (`fake_test.go`), plus an in-process `x/crypto/ssh` server (`sshserver_test.go`) and go-libvirt's own mock for the adapter |
|
| Unit-test double | controller-runtime fake client | `fakeAPI` behind `instancesAPI`, plus `httptest` for the real SDK client in `check_test.go` | `fakeAPI` behind `libvirtAPI`, plus an in-process `x/crypto/ssh` server in `conn_test.go` | `fakeBackend` behind `backend` (`fake_test.go`), plus an in-process `x/crypto/ssh` server (`sshserver_test.go`) and go-libvirt's own mock for the adapter |
|
||||||
| Proxy software | generated Squid config (`pod.go`) | from `cloudInit`; the image must run cloud-init | from `cloudInit`, plus a vendor-data script that installs the guest agent (`seed.go`) | from `cloudInit`, plus a vendor-data script that installs the guest agent (`seed.go`) |
|
| Proxy software | generated Squid config (`pod.go`) | from `cloudInit`; the image must run cloud-init | from `cloudInit`, plus a vendor-data script that installs the guest agent (`seed.go`) | from `cloudInit`, plus a vendor-data script that installs the guest agent (`nocloud.GuestAgentScript`) |
|
||||||
| IP reachable from the operator | Pod network, so in-cluster only (not from `make run-dev` on a laptop) | external IP, once a firewall rule allows the cluster's egress IP | LAN address on the host's bridge; the operator needs a route to that LAN | LAN address on the host's bridge; the operator needs a route to that LAN |
|
| IP reachable from the operator | Pod network, so in-cluster only (not from `make run-dev` on a laptop) | external IP, once a firewall rule allows the cluster's egress IP | LAN address on the host's bridge; the operator needs a route to that LAN | LAN address on the host's bridge; the operator needs a route to that LAN |
|
||||||
| Distinct egress IP per proxy | no: the cluster's egress | yes: ephemeral external IP | whatever the host's LAN routes through; not per VM by itself | whatever the host's LAN routes through; not per VM by itself |
|
| Distinct egress IP per proxy | no: the cluster's egress | yes: ephemeral external IP | whatever the host's LAN routes through; not per VM by itself | whatever the host's LAN routes through; not per VM by itself |
|
||||||
|
|
||||||
@@ -347,8 +347,10 @@ same libvirt.
|
|||||||
production adapter and the budgets), `ssh.go` (credentials, the forwarded
|
production adapter and the budgets), `ssh.go` (credentials, the forwarded
|
||||||
socket, keepalive), `hosts.go` (the pooled table and the production
|
socket, keepalive), `hosts.go` (the pooled table and the production
|
||||||
dialer), `observe.go` (the span and log decorator), `image.go` (cache,
|
dialer), `observe.go` (the span and log decorator), `image.go` (cache,
|
||||||
download, quota precheck), `seed.go`, `xml.go`, `volume.go`,
|
download, quota precheck), `xml.go`, `volume.go`, `metadata.go`,
|
||||||
`metadata.go`, `id.go`, `state.go`, `errors.go`, `check.go`. The seam is
|
`id.go`, `state.go`, `errors.go`, `check.go`; the seed ISO comes from
|
||||||
|
the shared `internal/provider/nocloud` and both lock sets are
|
||||||
|
`internal/provider/keyedlock`. The seam is
|
||||||
`backend`: the twenty go-libvirt calls the provider uses, each with `ctx`
|
`backend`: the twenty go-libvirt calls the provider uses, each with `ctx`
|
||||||
first (go-libvirt's own methods take none and block), plus `Close`.
|
first (go-libvirt's own methods take none and block), plus `Close`.
|
||||||
`libvirtBackend` adapts `*libvirt.Libvirt` and runs every call through
|
`libvirtBackend` adapts `*libvirt.Libvirt` and runs every call through
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
// Package keyedlock provides one-slot semaphores by key, shared by the
|
||||||
|
// providers that serialise work per instance name or per cached image
|
||||||
|
// (kvm2, proxmox).
|
||||||
|
package keyedlock
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Locks is a set of one-slot semaphores by key: a semaphore rather than a
|
||||||
|
// mutex so a waiter can give up with its ctx. Each entry counts the callers
|
||||||
|
// holding or waiting for it and is dropped from the map at zero, so the map
|
||||||
|
// does not grow with every key ever seen while two waiters on one key still
|
||||||
|
// always share one semaphore. The zero value is not usable; call New.
|
||||||
|
type Locks struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
locks map[string]*lock
|
||||||
|
}
|
||||||
|
|
||||||
|
type lock struct {
|
||||||
|
sem chan struct{}
|
||||||
|
refs int
|
||||||
|
}
|
||||||
|
|
||||||
|
func New() *Locks {
|
||||||
|
return &Locks{locks: make(map[string]*lock)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Acquire takes the lock for key, or gives up with ctx's error. The
|
||||||
|
// returned func releases it and must be called exactly once.
|
||||||
|
func (k *Locks) Acquire(ctx context.Context, key string) (release func(), err error) {
|
||||||
|
k.mu.Lock()
|
||||||
|
l, ok := k.locks[key]
|
||||||
|
if !ok {
|
||||||
|
l = &lock{sem: make(chan struct{}, 1)}
|
||||||
|
k.locks[key] = l
|
||||||
|
}
|
||||||
|
l.refs++
|
||||||
|
k.mu.Unlock()
|
||||||
|
select {
|
||||||
|
case l.sem <- struct{}{}:
|
||||||
|
return func() {
|
||||||
|
<-l.sem
|
||||||
|
k.unref(key, l)
|
||||||
|
}, nil
|
||||||
|
case <-ctx.Done():
|
||||||
|
k.unref(key, l)
|
||||||
|
return nil, ctx.Err()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (k *Locks) unref(key string, l *lock) {
|
||||||
|
k.mu.Lock()
|
||||||
|
defer k.mu.Unlock()
|
||||||
|
l.refs--
|
||||||
|
if l.refs == 0 {
|
||||||
|
delete(k.locks, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
package keyedlock
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func lockCount(k *Locks) int {
|
||||||
|
k.mu.Lock()
|
||||||
|
defer k.mu.Unlock()
|
||||||
|
return len(k.locks)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKeyedLocks_entryRemovedWhenUncontended(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
k := New()
|
||||||
|
release, err := k.Acquire(context.Background(), "a")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("acquire error = %v", err)
|
||||||
|
}
|
||||||
|
if lockCount(k) != 1 {
|
||||||
|
t.Errorf("entries while held = %d, want 1", lockCount(k))
|
||||||
|
}
|
||||||
|
release()
|
||||||
|
if lockCount(k) != 0 {
|
||||||
|
t.Errorf("entries after release = %d, want 0", lockCount(k))
|
||||||
|
}
|
||||||
|
if _, err := k.Acquire(context.Background(), "a"); err != nil {
|
||||||
|
t.Fatalf("acquire after release error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKeyedLocks_waitersShareOneLock(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
k := New()
|
||||||
|
const n = 20
|
||||||
|
var inside, overlaps atomic.Int32
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for range n {
|
||||||
|
wg.Go(func() {
|
||||||
|
release, err := k.Acquire(context.Background(), "shared")
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("acquire error = %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if inside.Add(1) > 1 {
|
||||||
|
overlaps.Add(1)
|
||||||
|
}
|
||||||
|
time.Sleep(time.Millisecond)
|
||||||
|
inside.Add(-1)
|
||||||
|
release()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
if overlaps.Load() != 0 {
|
||||||
|
t.Errorf("%d holders overlapped on one key", overlaps.Load())
|
||||||
|
}
|
||||||
|
if lockCount(k) != 0 {
|
||||||
|
t.Errorf("entries after every holder released = %d, want 0", lockCount(k))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKeyedLocks_waiterGivesUpWithContext(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
k := New()
|
||||||
|
release, err := k.Acquire(context.Background(), "a")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("acquire error = %v", err)
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
|
||||||
|
defer cancel()
|
||||||
|
start := time.Now()
|
||||||
|
if _, err := k.Acquire(ctx, "a"); !errors.Is(err, context.DeadlineExceeded) {
|
||||||
|
t.Fatalf("waiter error = %v, want DeadlineExceeded", err)
|
||||||
|
}
|
||||||
|
if time.Since(start) > time.Second {
|
||||||
|
t.Errorf("waiter took %v, want to give up with its ctx", time.Since(start))
|
||||||
|
}
|
||||||
|
if lockCount(k) != 1 {
|
||||||
|
t.Errorf("entries with the holder still in = %d, want 1: the waiter's exit must not drop the holder's entry", lockCount(k))
|
||||||
|
}
|
||||||
|
release()
|
||||||
|
if lockCount(k) != 0 {
|
||||||
|
t.Errorf("entries after the holder released = %d, want 0", lockCount(k))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"github.com/go-logr/logr"
|
"github.com/go-logr/logr"
|
||||||
|
|
||||||
"gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/internal/provider"
|
"gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/internal/provider"
|
||||||
|
"gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/internal/provider/nocloud"
|
||||||
)
|
)
|
||||||
|
|
||||||
// The wait after DomainCreate (spec entry 18): Create returns only once the
|
// The wait after DomainCreate (spec entry 18): Create returns only once the
|
||||||
@@ -191,12 +192,12 @@ func (p *Provider) writeSeed(ctx context.Context, log logr.Logger, be backend, p
|
|||||||
return libvirt.StorageVol{}, err
|
return libvirt.StorageVol{}, err
|
||||||
}
|
}
|
||||||
installAgent := p.cfg.InstallGuestAgent == nil || *p.cfg.InstallGuestAgent
|
installAgent := p.cfg.InstallGuestAgent == nil || *p.cfg.InstallGuestAgent
|
||||||
vendorData, err := buildVendorData(p.cfg.VendorData, installAgent)
|
vendorData, err := nocloud.BuildVendorData(p.cfg.VendorData, installAgent)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return libvirt.StorageVol{}, permanentf("building vendor-data for %s: %v", req.Name, err)
|
return libvirt.StorageVol{}, permanentf("building vendor-data for %s: %v", req.Name, err)
|
||||||
}
|
}
|
||||||
var iso bytes.Buffer
|
var iso bytes.Buffer
|
||||||
if err := buildSeedISO(req.Name, req.CloudInit, vendorData, &iso); err != nil {
|
if err := nocloud.BuildSeedISO(req.Name, req.CloudInit, vendorData, &iso); err != nil {
|
||||||
return libvirt.StorageVol{}, permanentf("building seed ISO for %s: %v", req.Name, err)
|
return libvirt.StorageVol{}, permanentf("building seed ISO for %s: %v", req.Name, err)
|
||||||
}
|
}
|
||||||
xmlDoc, err := buildVolumeXML(volumeSpec{Name: name, CapacityBytes: int64(iso.Len()), Format: provider.KVMFormatRaw})
|
xmlDoc, err := buildVolumeXML(volumeSpec{Name: name, CapacityBytes: int64(iso.Len()), Format: provider.KVMFormatRaw})
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"github.com/digitalocean/go-libvirt"
|
"github.com/digitalocean/go-libvirt"
|
||||||
|
|
||||||
"gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/internal/provider"
|
"gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/internal/provider"
|
||||||
|
"gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/internal/provider/nocloud"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestCreate_repeatReturnsSameIDAndCreatesNothing(t *testing.T) {
|
func TestCreate_repeatReturnsSameIDAndCreatesNothing(t *testing.T) {
|
||||||
@@ -95,7 +96,7 @@ func TestCreate_resumesAfterEachPartialStep(t *testing.T) {
|
|||||||
},
|
},
|
||||||
check: func(t *testing.T, fx *fixture) {
|
check: func(t *testing.T, fx *fixture) {
|
||||||
got := fx.fake.vol(seed)
|
got := fx.fake.vol(seed)
|
||||||
if got == nil || len(got.content) <= 3 || !bytes.Contains(got.content, []byte(seedLabel)) {
|
if got == nil || len(got.content) <= 3 || !bytes.Contains(got.content, []byte(nocloud.SeedLabel)) {
|
||||||
t.Errorf("seed volume not rebuilt")
|
t.Errorf("seed volume not rebuilt")
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -461,7 +462,7 @@ func TestCreate_seedAlwaysRebuilt(t *testing.T) {
|
|||||||
if got := fx.fake.callCount("StorageVolUpload"); got != uploads+1 {
|
if got := fx.fake.callCount("StorageVolUpload"); got != uploads+1 {
|
||||||
t.Errorf("StorageVolUpload calls = %d, want %d", got, uploads+1)
|
t.Errorf("StorageVolUpload calls = %d, want %d", got, uploads+1)
|
||||||
}
|
}
|
||||||
if len(second.content) != len(first.content) || !bytes.Contains(second.content, []byte(seedLabel)) {
|
if len(second.content) != len(first.content) || !bytes.Contains(second.content, []byte(nocloud.SeedLabel)) {
|
||||||
t.Errorf("rebuilt seed is %d bytes, first was %d; want a complete seed of the same size", len(second.content), len(first.content))
|
t.Errorf("rebuilt seed is %d bytes, first was %d; want a complete seed of the same size", len(second.content), len(first.content))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ func resolveImage(entry string, cfg provider.KVMConfig, image string) (imageSour
|
|||||||
// returns promptly (contract §1.5) instead of waiting out a 20-minute
|
// returns promptly (contract §1.5) instead of waiting out a 20-minute
|
||||||
// transfer.
|
// transfer.
|
||||||
func (p *Provider) lockImage(ctx context.Context, hostName, volume string) (func(), error) {
|
func (p *Provider) lockImage(ctx context.Context, hostName, volume string) (func(), error) {
|
||||||
release, err := p.imageLocks.acquire(ctx, hostName+"/"+volume)
|
release, err := p.imageLocks.Acquire(ctx, hostName+"/"+volume)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("%w: waiting for image %s on host %s: %w", provider.ErrTransient, volume, hostName, err)
|
return nil, fmt.Errorf("%w: waiting for image %s on host %s: %w", provider.ErrTransient, volume, hostName, err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,13 +9,13 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"sync"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/go-logr/logr"
|
"github.com/go-logr/logr"
|
||||||
logf "sigs.k8s.io/controller-runtime/pkg/log"
|
logf "sigs.k8s.io/controller-runtime/pkg/log"
|
||||||
|
|
||||||
"gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/internal/provider"
|
"gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/internal/provider"
|
||||||
|
"gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/internal/provider/keyedlock"
|
||||||
"gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/internal/tracing"
|
"gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/internal/tracing"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -41,12 +41,12 @@ type Provider struct {
|
|||||||
runningPoll time.Duration
|
runningPoll time.Duration
|
||||||
|
|
||||||
// imageLocks serialises image fetches per (host, volume), spec entry 19.
|
// imageLocks serialises image fetches per (host, volume), spec entry 19.
|
||||||
imageLocks *keyedLocks
|
imageLocks *keyedlock.Locks
|
||||||
|
|
||||||
// nameLocks serialises Create and Delete per instance name, spec entry
|
// nameLocks serialises Create and Delete per instance name, spec entry
|
||||||
// 19, so two Deletes never race on destroy and undefine and a Delete
|
// 19, so two Deletes never race on destroy and undefine and a Delete
|
||||||
// cannot interleave with a Create's steps.
|
// cannot interleave with a Create's steps.
|
||||||
nameLocks *keyedLocks
|
nameLocks *keyedlock.Locks
|
||||||
}
|
}
|
||||||
|
|
||||||
// providerOption adjusts what newProvider builds; tests inject an HTTP
|
// providerOption adjusts what newProvider builds; tests inject an HTTP
|
||||||
@@ -111,8 +111,8 @@ func newProvider(name string, cfg provider.KVMConfig, d dialer, opts ...provider
|
|||||||
http: http.DefaultClient,
|
http: http.DefaultClient,
|
||||||
runningWait: defaultRunningWait,
|
runningWait: defaultRunningWait,
|
||||||
runningPoll: defaultRunningPoll,
|
runningPoll: defaultRunningPoll,
|
||||||
imageLocks: newKeyedLocks(),
|
imageLocks: keyedlock.New(),
|
||||||
nameLocks: newKeyedLocks(),
|
nameLocks: keyedlock.New(),
|
||||||
}
|
}
|
||||||
for _, opt := range opts {
|
for _, opt := range opts {
|
||||||
opt(p)
|
opt(p)
|
||||||
@@ -187,60 +187,9 @@ func (p *Provider) wrapErr(op, id string, err error) error {
|
|||||||
// so a caller whose ctx ends while another caller's Create is downloading
|
// so a caller whose ctx ends while another caller's Create is downloading
|
||||||
// an image returns promptly.
|
// an image returns promptly.
|
||||||
func (p *Provider) lockName(ctx context.Context, name string) (func(), error) {
|
func (p *Provider) lockName(ctx context.Context, name string) (func(), error) {
|
||||||
release, err := p.nameLocks.acquire(ctx, name)
|
release, err := p.nameLocks.Acquire(ctx, name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("%w: waiting for the lock on %s: %w", provider.ErrTransient, name, err)
|
return nil, fmt.Errorf("%w: waiting for the lock on %s: %w", provider.ErrTransient, name, err)
|
||||||
}
|
}
|
||||||
return release, nil
|
return release, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// keyedLocks is a set of one-slot semaphores by key (spec entry 19): a
|
|
||||||
// semaphore rather than a mutex so a waiter can give up with its ctx. Each
|
|
||||||
// entry counts the callers holding or waiting for it and is dropped from
|
|
||||||
// the map at zero, so the map does not grow with every name ever seen while
|
|
||||||
// two waiters on one key still always share one semaphore.
|
|
||||||
type keyedLocks struct {
|
|
||||||
mu sync.Mutex
|
|
||||||
locks map[string]*keyedLock
|
|
||||||
}
|
|
||||||
|
|
||||||
type keyedLock struct {
|
|
||||||
sem chan struct{}
|
|
||||||
refs int
|
|
||||||
}
|
|
||||||
|
|
||||||
func newKeyedLocks() *keyedLocks {
|
|
||||||
return &keyedLocks{locks: make(map[string]*keyedLock)}
|
|
||||||
}
|
|
||||||
|
|
||||||
// acquire takes the lock for key, or gives up with ctx's error. The
|
|
||||||
// returned func releases it and must be called exactly once.
|
|
||||||
func (k *keyedLocks) acquire(ctx context.Context, key string) (func(), error) {
|
|
||||||
k.mu.Lock()
|
|
||||||
l, ok := k.locks[key]
|
|
||||||
if !ok {
|
|
||||||
l = &keyedLock{sem: make(chan struct{}, 1)}
|
|
||||||
k.locks[key] = l
|
|
||||||
}
|
|
||||||
l.refs++
|
|
||||||
k.mu.Unlock()
|
|
||||||
select {
|
|
||||||
case l.sem <- struct{}{}:
|
|
||||||
return func() {
|
|
||||||
<-l.sem
|
|
||||||
k.unref(key, l)
|
|
||||||
}, nil
|
|
||||||
case <-ctx.Done():
|
|
||||||
k.unref(key, l)
|
|
||||||
return nil, ctx.Err()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (k *keyedLocks) unref(key string, l *keyedLock) {
|
|
||||||
k.mu.Lock()
|
|
||||||
defer k.mu.Unlock()
|
|
||||||
l.refs--
|
|
||||||
if l.refs == 0 {
|
|
||||||
delete(k.locks, key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -267,86 +266,6 @@ func TestWithDefaults_doesNotAliasInput(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func lockCount(k *keyedLocks) int {
|
|
||||||
k.mu.Lock()
|
|
||||||
defer k.mu.Unlock()
|
|
||||||
return len(k.locks)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestKeyedLocks_entryRemovedWhenUncontended(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
k := newKeyedLocks()
|
|
||||||
release, err := k.acquire(context.Background(), "a")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("acquire error = %v", err)
|
|
||||||
}
|
|
||||||
if lockCount(k) != 1 {
|
|
||||||
t.Errorf("entries while held = %d, want 1", lockCount(k))
|
|
||||||
}
|
|
||||||
release()
|
|
||||||
if lockCount(k) != 0 {
|
|
||||||
t.Errorf("entries after release = %d, want 0", lockCount(k))
|
|
||||||
}
|
|
||||||
if _, err := k.acquire(context.Background(), "a"); err != nil {
|
|
||||||
t.Fatalf("acquire after release error = %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestKeyedLocks_waitersShareOneLock(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
k := newKeyedLocks()
|
|
||||||
const n = 20
|
|
||||||
var inside, overlaps atomic.Int32
|
|
||||||
var wg sync.WaitGroup
|
|
||||||
for range n {
|
|
||||||
wg.Go(func() {
|
|
||||||
release, err := k.acquire(context.Background(), "shared")
|
|
||||||
if err != nil {
|
|
||||||
t.Errorf("acquire error = %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if inside.Add(1) > 1 {
|
|
||||||
overlaps.Add(1)
|
|
||||||
}
|
|
||||||
time.Sleep(time.Millisecond)
|
|
||||||
inside.Add(-1)
|
|
||||||
release()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
wg.Wait()
|
|
||||||
if overlaps.Load() != 0 {
|
|
||||||
t.Errorf("%d holders overlapped on one key", overlaps.Load())
|
|
||||||
}
|
|
||||||
if lockCount(k) != 0 {
|
|
||||||
t.Errorf("entries after every holder released = %d, want 0", lockCount(k))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestKeyedLocks_waiterGivesUpWithContext(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
k := newKeyedLocks()
|
|
||||||
release, err := k.acquire(context.Background(), "a")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("acquire error = %v", err)
|
|
||||||
}
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
|
|
||||||
defer cancel()
|
|
||||||
start := time.Now()
|
|
||||||
if _, err := k.acquire(ctx, "a"); !errors.Is(err, context.DeadlineExceeded) {
|
|
||||||
t.Fatalf("waiter error = %v, want DeadlineExceeded", err)
|
|
||||||
}
|
|
||||||
if time.Since(start) > time.Second {
|
|
||||||
t.Errorf("waiter took %v, want to give up with its ctx", time.Since(start))
|
|
||||||
}
|
|
||||||
if lockCount(k) != 1 {
|
|
||||||
t.Errorf("entries with the holder still in = %d, want 1: the waiter's exit must not drop the holder's entry", lockCount(k))
|
|
||||||
}
|
|
||||||
release()
|
|
||||||
if lockCount(k) != 0 {
|
|
||||||
t.Errorf("entries after the holder released = %d, want 0", lockCount(k))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWrapErr_carriesProviderNameOpAndID(t *testing.T) {
|
func TestWrapErr_carriesProviderNameOpAndID(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
p := newProvider("kvm2-home", twoHosts(), &stubDialer{})
|
p := newProvider("kvm2-home", twoHosts(), &stubDialer{})
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
package kvm2
|
// Package nocloud builds cloud-init NoCloud seed ISOs in process, shared by
|
||||||
|
// the providers that attach a seed as a CD-ROM (kvm2, proxmox). It never
|
||||||
|
// parses or logs the payload.
|
||||||
|
package nocloud
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
@@ -12,19 +15,22 @@ import (
|
|||||||
"github.com/kdomanski/iso9660"
|
"github.com/kdomanski/iso9660"
|
||||||
)
|
)
|
||||||
|
|
||||||
// seedLabel is the volume label cloud-init's NoCloud datasource looks for.
|
// SeedLabel is the volume label cloud-init's NoCloud datasource looks for.
|
||||||
const seedLabel = "cidata"
|
const SeedLabel = "cidata"
|
||||||
|
|
||||||
// guestAgentScript is the vendor-data payload that gets qemu-guest-agent
|
// ErrEmptyName is returned by BuildSeedISO for an empty instance name.
|
||||||
// running so the provider can read the VM's address through libvirt. It is
|
var ErrEmptyName = errors.New("seed ISO: instance name is empty")
|
||||||
|
|
||||||
|
// GuestAgentScript is the vendor-data payload that gets qemu-guest-agent
|
||||||
|
// running so the provider can read the VM's address from the hypervisor. It is
|
||||||
// a shell script and not a cloud-config on purpose: cloud-init merges a
|
// a shell script and not a cloud-config on purpose: cloud-init merges a
|
||||||
// vendor-data cloud-config under the user-data with mergers that keep the
|
// vendor-data cloud-config under the user-data with mergers that keep the
|
||||||
// user-data's value for every top-level key and never append lists, so a
|
// user-data's value for every top-level key and never append lists, so a
|
||||||
// Proxy with its own packages: would silently lose the agent. Scripts
|
// Proxy with its own packages: would silently lose the agent. Scripts
|
||||||
// bypass the merge.
|
// bypass the merge.
|
||||||
const guestAgentScript = `#!/bin/sh
|
const GuestAgentScript = `#!/bin/sh
|
||||||
# Added by egress-proxies-operator: installs and starts qemu-guest-agent so
|
# Added by egress-proxies-operator: installs and starts qemu-guest-agent so
|
||||||
# the operator can learn this VM's IP address through libvirt.
|
# the operator can learn this VM's IP address from the hypervisor.
|
||||||
# To leave this out, set installGuestAgent: false in the provider's config.
|
# To leave this out, set installGuestAgent: false in the provider's config.
|
||||||
set -e
|
set -e
|
||||||
if command -v qemu-ga >/dev/null 2>&1 || [ -x /usr/sbin/qemu-ga ]; then
|
if command -v qemu-ga >/dev/null 2>&1 || [ -x /usr/sbin/qemu-ga ]; then
|
||||||
@@ -53,15 +59,15 @@ else
|
|||||||
fi
|
fi
|
||||||
`
|
`
|
||||||
|
|
||||||
// buildSeedISO writes a NoCloud seed to w: user-data byte for byte,
|
// BuildSeedISO writes a NoCloud seed to w: user-data byte for byte,
|
||||||
// meta-data naming the instance, and vendor-data (an empty file when there
|
// meta-data naming the instance, and vendor-data (an empty file when there
|
||||||
// is none, so cloud-init does not look elsewhere). iso9660.NewWriter stages
|
// is none, so cloud-init does not look elsewhere). iso9660.NewWriter stages
|
||||||
// the files in os.MkdirTemp("", ""), that is under os.TempDir(): $TMPDIR
|
// the files in os.MkdirTemp("", ""), that is under os.TempDir(): $TMPDIR
|
||||||
// when set, else /tmp, the manager's 64Mi emptyDir. The staging dir is
|
// when set, else /tmp, the manager's 64Mi emptyDir. The staging dir is
|
||||||
// removed before returning. Nothing about the payload is logged here.
|
// removed before returning. Nothing about the payload is logged here.
|
||||||
func buildSeedISO(name, userData, vendorData string, w io.Writer) (err error) {
|
func BuildSeedISO(name, userData, vendorData string, w io.Writer) (err error) {
|
||||||
if name == "" {
|
if name == "" {
|
||||||
return permanentf("seed ISO: instance name is empty")
|
return ErrEmptyName
|
||||||
}
|
}
|
||||||
iw, err := iso9660.NewWriter()
|
iw, err := iso9660.NewWriter()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -83,30 +89,30 @@ func buildSeedISO(name, userData, vendorData string, w io.Writer) (err error) {
|
|||||||
return fmt.Errorf("adding %s to seed ISO: %w", f.name, err)
|
return fmt.Errorf("adding %s to seed ISO: %w", f.name, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err := iw.WriteTo(w, seedLabel); err != nil {
|
if err := iw.WriteTo(w, SeedLabel); err != nil {
|
||||||
return fmt.Errorf("writing seed ISO: %w", err)
|
return fmt.Errorf("writing seed ISO: %w", err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildVendorData composes the seed's vendor-data from the configured
|
// BuildVendorData composes the seed's vendor-data from the configured
|
||||||
// document and the guest agent script. With both, they travel as separate
|
// document and the guest agent script. With both, they travel as separate
|
||||||
// parts of a MIME multipart, so cloud-init handles each by its own type; the
|
// parts of a MIME multipart, so cloud-init handles each by its own type; the
|
||||||
// MIME-Version header comes first because cloud-init detects multipart by
|
// MIME-Version header comes first because cloud-init detects multipart by
|
||||||
// finding "mime-version:" within the first 4096 bytes.
|
// finding "mime-version:" within the first 4096 bytes.
|
||||||
func buildVendorData(configured string, installAgent bool) (string, error) {
|
func BuildVendorData(configured string, installAgent bool) (string, error) {
|
||||||
switch {
|
switch {
|
||||||
case !installAgent:
|
case !installAgent:
|
||||||
return configured, nil
|
return configured, nil
|
||||||
case strings.TrimSpace(configured) == "":
|
case strings.TrimSpace(configured) == "":
|
||||||
return guestAgentScript, nil
|
return GuestAgentScript, nil
|
||||||
}
|
}
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
mw := multipart.NewWriter(&buf)
|
mw := multipart.NewWriter(&buf)
|
||||||
fmt.Fprintf(&buf, "MIME-Version: 1.0\r\nContent-Type: multipart/mixed; boundary=%q\r\n\r\n", mw.Boundary())
|
fmt.Fprintf(&buf, "MIME-Version: 1.0\r\nContent-Type: multipart/mixed; boundary=%q\r\n\r\n", mw.Boundary())
|
||||||
parts := []struct{ contentType, body string }{
|
parts := []struct{ contentType, body string }{
|
||||||
{"text/plain", configured},
|
{"text/plain", configured},
|
||||||
{"text/x-shellscript", guestAgentScript},
|
{"text/x-shellscript", GuestAgentScript},
|
||||||
}
|
}
|
||||||
for _, p := range parts {
|
for _, p := range parts {
|
||||||
pw, err := mw.CreatePart(textproto.MIMEHeader{"Content-Type": {p.contentType}})
|
pw, err := mw.CreatePart(textproto.MIMEHeader{"Content-Type": {p.contentType}})
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
package kvm2
|
package nocloud
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
"mime"
|
"mime"
|
||||||
"mime/multipart"
|
"mime/multipart"
|
||||||
@@ -18,8 +19,8 @@ import (
|
|||||||
func readSeed(t *testing.T, name, userData, vendorData string) (map[string]string, string) {
|
func readSeed(t *testing.T, name, userData, vendorData string) (map[string]string, string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
if err := buildSeedISO(name, userData, vendorData, &buf); err != nil {
|
if err := BuildSeedISO(name, userData, vendorData, &buf); err != nil {
|
||||||
t.Fatalf("buildSeedISO: %v", err)
|
t.Fatalf("BuildSeedISO: %v", err)
|
||||||
}
|
}
|
||||||
img, err := iso9660.OpenImage(bytes.NewReader(buf.Bytes()))
|
img, err := iso9660.OpenImage(bytes.NewReader(buf.Bytes()))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -57,8 +58,8 @@ func TestSeedISO_filesAndLabel(t *testing.T) {
|
|||||||
userData := "#cloud-config\npackages: [squid]\n"
|
userData := "#cloud-config\npackages: [squid]\n"
|
||||||
vendorData := "#!/bin/sh\necho hi\n"
|
vendorData := "#!/bin/sh\necho hi\n"
|
||||||
files, label := readSeed(t, "proxy-abc", userData, vendorData)
|
files, label := readSeed(t, "proxy-abc", userData, vendorData)
|
||||||
if label != seedLabel {
|
if label != SeedLabel {
|
||||||
t.Errorf("label = %q, want %q", label, seedLabel)
|
t.Errorf("label = %q, want %q", label, SeedLabel)
|
||||||
}
|
}
|
||||||
want := map[string]string{
|
want := map[string]string{
|
||||||
"user-data": userData,
|
"user-data": userData,
|
||||||
@@ -117,8 +118,8 @@ func TestSeedISO_tempDirRemoved(t *testing.T) {
|
|||||||
t.Fatalf("os.TempDir() = %q, want %q", got, tmp)
|
t.Fatalf("os.TempDir() = %q, want %q", got, tmp)
|
||||||
}
|
}
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
if err := buildSeedISO("proxy-abc", "#cloud-config\n", guestAgentScript, &buf); err != nil {
|
if err := BuildSeedISO("proxy-abc", "#cloud-config\n", GuestAgentScript, &buf); err != nil {
|
||||||
t.Fatalf("buildSeedISO: %v", err)
|
t.Fatalf("BuildSeedISO: %v", err)
|
||||||
}
|
}
|
||||||
if buf.Len() == 0 {
|
if buf.Len() == 0 {
|
||||||
t.Fatal("seed ISO is empty")
|
t.Fatal("seed ISO is empty")
|
||||||
@@ -151,8 +152,8 @@ func TestSeedISO_emptyVendorDataStillHasFile(t *testing.T) {
|
|||||||
func TestSeedISO_rejectsEmptyName(t *testing.T) {
|
func TestSeedISO_rejectsEmptyName(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
if err := buildSeedISO("", "#cloud-config\n", "", &buf); err == nil {
|
if err := BuildSeedISO("", "#cloud-config\n", "", &buf); !errors.Is(err, ErrEmptyName) {
|
||||||
t.Fatal("buildSeedISO with empty name succeeded")
|
t.Fatalf("BuildSeedISO with empty name error = %v, want ErrEmptyName", err)
|
||||||
}
|
}
|
||||||
if buf.Len() != 0 {
|
if buf.Len() != 0 {
|
||||||
t.Errorf("wrote %d bytes alongside the error", buf.Len())
|
t.Errorf("wrote %d bytes alongside the error", buf.Len())
|
||||||
@@ -161,7 +162,7 @@ func TestSeedISO_rejectsEmptyName(t *testing.T) {
|
|||||||
|
|
||||||
func TestVendorData_isShellScriptNotCloudConfig(t *testing.T) {
|
func TestVendorData_isShellScriptNotCloudConfig(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
got, err := buildVendorData("", true)
|
got, err := BuildVendorData("", true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -193,7 +194,7 @@ func bufioReader(s string) *bufio.Reader {
|
|||||||
func TestVendorData_multipartWhenExtraProvided(t *testing.T) {
|
func TestVendorData_multipartWhenExtraProvided(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
configured := "#cloud-config\nntp:\n enabled: true\n"
|
configured := "#cloud-config\nntp:\n enabled: true\n"
|
||||||
got, err := buildVendorData(configured, true)
|
got, err := BuildVendorData(configured, true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -229,7 +230,7 @@ func TestVendorData_multipartWhenExtraProvided(t *testing.T) {
|
|||||||
}
|
}
|
||||||
parts = append(parts, part{p.Header.Get("Content-Type"), string(body)})
|
parts = append(parts, part{p.Header.Get("Content-Type"), string(body)})
|
||||||
}
|
}
|
||||||
want := []part{{"text/plain", configured}, {"text/x-shellscript", guestAgentScript}}
|
want := []part{{"text/plain", configured}, {"text/x-shellscript", GuestAgentScript}}
|
||||||
if len(parts) != len(want) {
|
if len(parts) != len(want) {
|
||||||
t.Fatalf("got %d parts, want %d", len(parts), len(want))
|
t.Fatalf("got %d parts, want %d", len(parts), len(want))
|
||||||
}
|
}
|
||||||
@@ -242,7 +243,7 @@ func TestVendorData_multipartWhenExtraProvided(t *testing.T) {
|
|||||||
|
|
||||||
func TestVendorData_offWithoutConfiguredIsEmpty(t *testing.T) {
|
func TestVendorData_offWithoutConfiguredIsEmpty(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
got, err := buildVendorData("", false)
|
got, err := BuildVendorData("", false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -254,7 +255,7 @@ func TestVendorData_offWithoutConfiguredIsEmpty(t *testing.T) {
|
|||||||
func TestVendorData_offWithConfiguredPassesThrough(t *testing.T) {
|
func TestVendorData_offWithConfiguredPassesThrough(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
configured := "#cloud-config\nntp:\n enabled: true"
|
configured := "#cloud-config\nntp:\n enabled: true"
|
||||||
got, err := buildVendorData(configured, false)
|
got, err := BuildVendorData(configured, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -6,6 +6,7 @@ package proxmox
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -13,6 +14,7 @@ import (
|
|||||||
logf "sigs.k8s.io/controller-runtime/pkg/log"
|
logf "sigs.k8s.io/controller-runtime/pkg/log"
|
||||||
|
|
||||||
"gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/internal/provider"
|
"gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/internal/provider"
|
||||||
|
"gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/internal/provider/keyedlock"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -57,6 +59,11 @@ type Provider struct {
|
|||||||
api api
|
api api
|
||||||
now func() time.Time
|
now func() time.Time
|
||||||
budgets budgets
|
budgets budgets
|
||||||
|
|
||||||
|
// nameLocks serialises Create and Delete per instance name (spec entry
|
||||||
|
// 19), so the reconciler's Delete and GC's Delete never race on stop and
|
||||||
|
// destroy, and a Delete cannot interleave with a Create's steps.
|
||||||
|
nameLocks *keyedlock.Locks
|
||||||
}
|
}
|
||||||
|
|
||||||
var _ provider.Provider = (*Provider)(nil)
|
var _ provider.Provider = (*Provider)(nil)
|
||||||
@@ -90,6 +97,7 @@ func newProvider(name string, cfg provider.ProxmoxConfig, a api) *Provider {
|
|||||||
api: a,
|
api: a,
|
||||||
now: time.Now,
|
now: time.Now,
|
||||||
budgets: defaultBudgets(),
|
budgets: defaultBudgets(),
|
||||||
|
nameLocks: keyedlock.New(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,6 +160,17 @@ func (p *Provider) wrapErr(op, id string, err error) error {
|
|||||||
return provider.Wrap(classify(op, err), op, p.name, id, err)
|
return provider.Wrap(classify(op, err), op, p.name, id, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// lockName takes the per-name lock of spec entry 19, or gives up with ctx,
|
||||||
|
// so a caller whose ctx ends while another caller's Create waits on a clone
|
||||||
|
// returns promptly.
|
||||||
|
func (p *Provider) lockName(ctx context.Context, name string) (func(), error) {
|
||||||
|
release, err := p.nameLocks.Acquire(ctx, name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%w: waiting for the lock on %s: %w", provider.ErrTransient, name, err)
|
||||||
|
}
|
||||||
|
return release, nil
|
||||||
|
}
|
||||||
|
|
||||||
// The four contract methods are stubs until the phases that implement them
|
// The four contract methods are stubs until the phases that implement them
|
||||||
// (Get and ListByTag in Phase 5, Create in 6, Delete in 7). Each answers
|
// (Get and ListByTag in Phase 5, Create in 6, Delete in 7). Each answers
|
||||||
// Transient, so a Proxy on this provider retries rather than latching.
|
// Transient, so a Proxy on this provider retries rather than latching.
|
||||||
|
|||||||
@@ -206,3 +206,24 @@ func TestStubs_answerTransient(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLockName_givesUpWithContextAsTransient(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
p := newProvider("proxmox-home", testConfig(), nil)
|
||||||
|
release, err := p.lockName(context.Background(), "proxy-abc")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("lockName error = %v", err)
|
||||||
|
}
|
||||||
|
defer release()
|
||||||
|
other, err := p.lockName(context.Background(), "proxy-def")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("lockName on another name error = %v, want it free", err)
|
||||||
|
}
|
||||||
|
other()
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
|
||||||
|
defer cancel()
|
||||||
|
_, err = p.lockName(ctx, "proxy-abc")
|
||||||
|
if !errors.Is(err, provider.ErrTransient) || !errors.Is(err, context.DeadlineExceeded) {
|
||||||
|
t.Errorf("lockName on a held name error = %v, want ErrTransient wrapping DeadlineExceeded", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package proxmox
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/internal/provider"
|
||||||
|
"gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/internal/provider/nocloud"
|
||||||
|
)
|
||||||
|
|
||||||
|
// seedISOName is the seed's file name on the ISO storage. Upload refuses a
|
||||||
|
// name without the .iso suffix (F12), and GC's blind spot is documented by
|
||||||
|
// this pattern (spec entry 11).
|
||||||
|
func seedISOName(name string) string {
|
||||||
|
return name + "-seed.iso"
|
||||||
|
}
|
||||||
|
|
||||||
|
// seedVolID is the seed's volume id, as ide2 references it and as the
|
||||||
|
// storage content delete takes it.
|
||||||
|
func seedVolID(storage, name string) string {
|
||||||
|
return storage + ":iso/" + seedISOName(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildSeed renders the NoCloud seed for req in memory (spec entry 5): a
|
||||||
|
// seed is tens of KiB. Nothing about its content is logged or put in an
|
||||||
|
// error. Only an empty name is Permanent; a staging failure (no /tmp, a
|
||||||
|
// full emptyDir) is left unclassified, so classify makes it Transient and
|
||||||
|
// the reconciler retries instead of latching the Proxy.
|
||||||
|
func (p *Provider) buildSeed(req provider.CreateRequest) (*bytes.Buffer, error) {
|
||||||
|
vendorData, err := nocloud.BuildVendorData(p.cfg.VendorData, *p.cfg.InstallGuestAgent)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("building vendor-data for %s: %w", req.Name, err)
|
||||||
|
}
|
||||||
|
var iso bytes.Buffer
|
||||||
|
if err := nocloud.BuildSeedISO(req.Name, req.CloudInit, vendorData, &iso); err != nil {
|
||||||
|
if errors.Is(err, nocloud.ErrEmptyName) {
|
||||||
|
return nil, permanentf("building seed ISO: %v", err)
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("building seed ISO for %s: %w", req.Name, err)
|
||||||
|
}
|
||||||
|
return &iso, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
package proxmox
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/kdomanski/iso9660"
|
||||||
|
|
||||||
|
"gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/internal/provider"
|
||||||
|
"gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/internal/provider/nocloud"
|
||||||
|
)
|
||||||
|
|
||||||
|
// seedFiles opens a built seed and returns its label and files by name.
|
||||||
|
func seedFiles(t *testing.T, iso *bytes.Buffer) (string, map[string]string) {
|
||||||
|
t.Helper()
|
||||||
|
img, err := iso9660.OpenImage(bytes.NewReader(iso.Bytes()))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("OpenImage: %v", err)
|
||||||
|
}
|
||||||
|
label, err := img.Label()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Label: %v", err)
|
||||||
|
}
|
||||||
|
root, err := img.RootDir()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("RootDir: %v", err)
|
||||||
|
}
|
||||||
|
children, err := root.GetChildren()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetChildren: %v", err)
|
||||||
|
}
|
||||||
|
files := make(map[string]string, len(children))
|
||||||
|
for _, c := range children {
|
||||||
|
data, err := io.ReadAll(c.Reader())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reading %s: %v", c.Name(), err)
|
||||||
|
}
|
||||||
|
files[c.Name()] = string(data)
|
||||||
|
}
|
||||||
|
return label, files
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSeedNames_table(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
if got, want := seedISOName("proxy-abc"), "proxy-abc-seed.iso"; got != want {
|
||||||
|
t.Errorf("seedISOName = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
if got, want := seedVolID("proxy-seeds", "proxy-abc"), "proxy-seeds:iso/proxy-abc-seed.iso"; got != want {
|
||||||
|
t.Errorf("seedVolID = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildSeed_table(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
off := false
|
||||||
|
userData := "#cloud-config\r\npackages: [squid]\r\nwrite_files:\n - content: \"caf\xc3\xa9\"\n"
|
||||||
|
configured := "#cloud-config\nntp:\n enabled: true\n"
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
vendorData string
|
||||||
|
install *bool
|
||||||
|
want func(t *testing.T, got string)
|
||||||
|
}{
|
||||||
|
{name: "agent script by default", want: func(t *testing.T, got string) {
|
||||||
|
if got != nocloud.GuestAgentScript {
|
||||||
|
t.Errorf("vendor-data is not the guest agent script: %q", got)
|
||||||
|
}
|
||||||
|
}},
|
||||||
|
{name: "configured joined with the agent script", vendorData: configured, want: func(t *testing.T, got string) {
|
||||||
|
if !strings.HasPrefix(got, "MIME-Version: 1.0\r\n") || !strings.Contains(got, configured) || !strings.Contains(got, nocloud.GuestAgentScript) {
|
||||||
|
t.Errorf("vendor-data is not a multipart of both documents: %q", got)
|
||||||
|
}
|
||||||
|
}},
|
||||||
|
{name: "agent off passes configured through", vendorData: configured, install: &off, want: func(t *testing.T, got string) {
|
||||||
|
if got != configured {
|
||||||
|
t.Errorf("vendor-data = %q, want the configured document", got)
|
||||||
|
}
|
||||||
|
}},
|
||||||
|
{name: "agent off with nothing configured is empty", install: &off, want: func(t *testing.T, got string) {
|
||||||
|
if got != "" {
|
||||||
|
t.Errorf("vendor-data = %q, want empty", got)
|
||||||
|
}
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
cfg := testConfig()
|
||||||
|
cfg.VendorData = tc.vendorData
|
||||||
|
cfg.InstallGuestAgent = tc.install
|
||||||
|
p := newProvider("proxmox-home", cfg, nil)
|
||||||
|
iso, err := p.buildSeed(provider.CreateRequest{Name: "proxy-abc", CloudInit: userData})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("buildSeed error = %v", err)
|
||||||
|
}
|
||||||
|
label, files := seedFiles(t, iso)
|
||||||
|
if label != nocloud.SeedLabel {
|
||||||
|
t.Errorf("label = %q, want %q", label, nocloud.SeedLabel)
|
||||||
|
}
|
||||||
|
if files["user-data"] != userData {
|
||||||
|
t.Errorf("user-data changed:\n got %q\nwant %q", files["user-data"], userData)
|
||||||
|
}
|
||||||
|
if want := "instance-id: proxy-abc\nlocal-hostname: proxy-abc\n"; files["meta-data"] != want {
|
||||||
|
t.Errorf("meta-data = %q, want %q", files["meta-data"], want)
|
||||||
|
}
|
||||||
|
tc.want(t, files["vendor-data"])
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildSeed_emptyNameIsPermanentWithoutPayload(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
const sentinel = "SENTINEL-cloud-init-payload"
|
||||||
|
p := newProvider("proxmox-home", testConfig(), nil)
|
||||||
|
_, err := p.buildSeed(provider.CreateRequest{CloudInit: sentinel})
|
||||||
|
if !errors.Is(err, provider.ErrPermanent) {
|
||||||
|
t.Fatalf("buildSeed error = %v, want ErrPermanent", err)
|
||||||
|
}
|
||||||
|
if strings.Contains(err.Error(), sentinel) {
|
||||||
|
t.Errorf("error carries the cloud-init payload: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not parallel: it points TMPDIR at a path that cannot exist for the
|
||||||
|
// process.
|
||||||
|
func TestBuildSeed_stagingFailureIsTransient(t *testing.T) {
|
||||||
|
t.Setenv("TMPDIR", t.TempDir()+"/missing")
|
||||||
|
p := newProvider("proxmox-home", testConfig(), nil)
|
||||||
|
_, err := p.buildSeed(provider.CreateRequest{Name: "proxy-abc", CloudInit: "#cloud-config\n"})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("buildSeed with no staging dir succeeded")
|
||||||
|
}
|
||||||
|
if got := classify("create", err); got != provider.ErrTransient {
|
||||||
|
t.Errorf("classify(buildSeed error) = %v, want ErrTransient: %v", got, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user