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:
@@ -6,6 +6,7 @@ package proxmox
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
@@ -13,6 +14,7 @@ import (
|
||||
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/keyedlock"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -57,6 +59,11 @@ type Provider struct {
|
||||
api api
|
||||
now func() time.Time
|
||||
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)
|
||||
@@ -85,11 +92,12 @@ func New(ctx context.Context, pc provider.ProviderConfig) (provider.Provider, er
|
||||
// newProvider is the constructor tests use, with a in-memory api.
|
||||
func newProvider(name string, cfg provider.ProxmoxConfig, a api) *Provider {
|
||||
return &Provider{
|
||||
name: name,
|
||||
cfg: withDefaults(cfg),
|
||||
api: a,
|
||||
now: time.Now,
|
||||
budgets: defaultBudgets(),
|
||||
name: name,
|
||||
cfg: withDefaults(cfg),
|
||||
api: a,
|
||||
now: time.Now,
|
||||
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)
|
||||
}
|
||||
|
||||
// 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
|
||||
// (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.
|
||||
|
||||
@@ -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