From c28f073da153e4c940d1755afd338d9407b204c3 Mon Sep 17 00:00:00 2001 From: Ananth Date: Fri, 3 Jul 2026 10:40:51 +0000 Subject: [PATCH] fix(certificatee): persist validated haproxy certificates --- README.md | 4 +- cmd/certificatee/main.go | 76 ++++++++++++++++---- cmd/certificatee/main_test.go | 49 +++++++++++++ cmd/certificator/main.go | 21 ++++++ pkg/certificate/certificate.go | 33 +++++++-- pkg/haproxy/client.go | 65 +++++++++++++---- pkg/haproxy/client_test.go | 126 +++++++++++++++++++++++++++------ pkg/vault/vault.go | 13 ++++ 8 files changed, 330 insertions(+), 57 deletions(-) diff --git a/README.md b/README.md index 3678e98..7c8a0fe 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ A daemon that synchronizes certificates from Vault to HAProxy using the HAProxy - The certificate is expiring within the configured threshold (default: 30 days) - The certificate serial number differs from the one stored in Vault -`certificatee` requires HAProxy Data Plane API v3. It reads expiry and serial metadata from HAProxy's live runtime certificate state and uses Vault only as the source for replacement certificate payloads. +`certificatee` requires HAProxy Data Plane API v3. It reads expiry and serial metadata from HAProxy's live runtime certificate state and uses Vault only as the source for replacement certificate payloads. Replacement certificates are written to HAProxy storage with `skip_reload=true`, then written to HAProxy runtime so updates both survive future reloads and take effect immediately. ## Configuration @@ -61,7 +61,7 @@ Certificatee uses the HAProxy Data Plane API to update certificates at runtime w - **Basic authentication**: Authenticate using username/password credentials - **Automatic retries**: Connections are retried with exponential backoff (default: 3 retries, 1-30s delays) - **Graceful degradation**: If one HAProxy instance is unreachable, the tool continues updating reachable instances -- **REST API**: Certificates are managed via the HAProxy Data Plane API v3 `/v3/services/haproxy/runtime/ssl_certs` endpoints +- **REST API**: Certificates are persisted via the HAProxy Data Plane API v3 `/v3/services/haproxy/storage/ssl_certificates` endpoints and activated via `/v3/services/haproxy/runtime/ssl_certs` - **Live certificate state**: Expiry checks use HAProxy's currently loaded certificate metadata, not Vault certificate expiry ### HAProxy Data Plane API Configuration diff --git a/cmd/certificatee/main.go b/cmd/certificatee/main.go index 47266f8..346c22f 100644 --- a/cmd/certificatee/main.go +++ b/cmd/certificatee/main.go @@ -150,21 +150,22 @@ func processHAProxyEndpoint(logger *logrus.Logger, cfg config.Config, vaultClien // Use HAProxy's live certificate metadata for expiry. Vault provides the // replacement payload and serial used to detect a newer issued certificate. shouldUpdate, reason, isExpiring, err := shouldUpdateCertificate(domain, vaultClient, haproxyCert, cfg.Certificatee.RenewBeforeDays) + + // Track expiring certificates even when Vault replacement material is invalid. + if isExpiring { + expiringCount++ + } + if err != nil { errs = append(errs, err) logger.Errorf("[%s] %v", endpoint, err) continue } - // Track expiring certificates - if isExpiring { - expiringCount++ - } - if shouldUpdate { logger.Infof("[%s] Certificate %s needs update: %s", endpoint, displayName, reason) - if err := updateCertificate(apiName, domain, vaultClient, haproxyClient); err != nil { + if err := updateCertificate(apiName, domain, vaultClient, haproxyClient, haproxyCert); err != nil { errs = append(errs, err) logger.Errorf("[%s] %v", endpoint, err) certmetrics.CertificatesUpdateFailures.WithLabelValues(endpoint, domain).Inc() @@ -195,17 +196,21 @@ func setDataPlaneAPIVersion(endpoint, version string) { func shouldUpdateCertificate(domain string, vaultClient *vault.VaultClient, haproxyCert *haproxy.CertificateDetail, renewBeforeDays int) (shouldUpdate bool, reason string, isExpiring bool, err error) { shouldUpdate, reason, isExpiring, err = shouldUpdateForLiveExpiry(domain, haproxyCert, renewBeforeDays) - if err != nil || shouldUpdate { + if err != nil { return shouldUpdate, reason, isExpiring, err } vaultCert, err := certificate.GetCertificate(domain, vaultClient) if err != nil { - return false, "", false, fmt.Errorf("failed to get certificate %s from vault: %w", domain, err) + return false, "", isExpiring, fmt.Errorf("failed to get certificate %s from vault: %w", domain, err) } - if vaultCert == nil { - return false, "", false, fmt.Errorf("certificate for %s does not exist in vault", domain) + if err := validateVaultCertificateForUpdate(domain, vaultCert, haproxyCert); err != nil { + return false, "", isExpiring, err + } + + if shouldUpdate { + return true, reason, true, nil } shouldUpdate, reason = shouldUpdateForSerialMismatch(vaultCert, haproxyCert) @@ -240,22 +245,67 @@ func shouldUpdateForSerialMismatch(vaultCert *x509.Certificate, haproxyCert *hap return false, "" } -func updateCertificate(certPath, domain string, vaultClient *vault.VaultClient, haproxyClient *haproxy.Client) error { +func validateVaultCertificateForUpdate(domain string, vaultCert *x509.Certificate, haproxyCert *haproxy.CertificateDetail) error { + return validateVaultCertificateForUpdateAt(domain, vaultCert, haproxyCert, time.Now()) +} + +func validateVaultCertificateForUpdateAt(domain string, vaultCert *x509.Certificate, haproxyCert *haproxy.CertificateDetail, now time.Time) error { + if vaultCert == nil { + return fmt.Errorf("certificate for %s does not exist in vault", domain) + } + + if vaultCert.IsCA { + return fmt.Errorf("refusing to update %s: Vault certificate bundle starts with a CA certificate", domain) + } + + if certificate.IsExpired(vaultCert, now) { + return fmt.Errorf("refusing to update %s: Vault certificate expired on %s", domain, vaultCert.NotAfter.Format(time.RFC3339)) + } + + if haproxyCert != nil && !haproxyCert.NotAfter.IsZero() && vaultCert.NotAfter.Before(haproxyCert.NotAfter) { + return fmt.Errorf("refusing to update %s: Vault certificate expires on %s before live HAProxy certificate expires on %s", domain, vaultCert.NotAfter.Format(time.RFC3339), haproxyCert.NotAfter.Format(time.RFC3339)) + } + + return nil +} + +func parseVaultLeafCertificate(secrets map[string]any) (*x509.Certificate, error) { + certPEM, ok := secrets["certificate"].(string) + if !ok || certPEM == "" { + return nil, fmt.Errorf("certificate not found in vault secrets") + } + + return certificate.ParsePEMCertificate(certPEM) +} + +func updateCertificate(certPath, domain string, vaultClient *vault.VaultClient, haproxyClient *haproxy.Client, haproxyCert *haproxy.CertificateDetail) error { // Read certificate data from Vault certificateSecrets, err := vaultClient.KVRead(certificate.VaultCertLocation(domain)) if err != nil { return fmt.Errorf("failed to read certificate data from vault for %s: %w", domain, err) } + vaultCert, err := parseVaultLeafCertificate(certificateSecrets) + if err != nil { + return fmt.Errorf("failed to parse Vault certificate for %s: %w", domain, err) + } + if err := validateVaultCertificateForUpdate(domain, vaultCert, haproxyCert); err != nil { + return err + } + // Build PEM bundle (certificate + private key) pemData, err := buildPEMBundle(certificateSecrets) if err != nil { return fmt.Errorf("failed to build PEM bundle for %s: %w", domain, err) } - // Update certificate in HAProxy + storageCertName := haproxy.StorageCertificateName(certPath) + if err := haproxyClient.EnsureStorageCertificate(storageCertName, pemData); err != nil { + return fmt.Errorf("failed to persist certificate %s in HAProxy storage: %w", storageCertName, err) + } + if err := haproxyClient.UpdateCertificate(certPath, pemData); err != nil { - return fmt.Errorf("failed to update certificate %s in HAProxy: %w", certPath, err) + return fmt.Errorf("failed to update certificate %s in HAProxy runtime: %w", certPath, err) } return nil diff --git a/cmd/certificatee/main_test.go b/cmd/certificatee/main_test.go index e943714..4f325c0 100644 --- a/cmd/certificatee/main_test.go +++ b/cmd/certificatee/main_test.go @@ -63,6 +63,55 @@ func TestShouldUpdateForLiveExpiry(t *testing.T) { }) } +func TestValidateVaultCertificateForUpdate(t *testing.T) { + now := time.Date(2026, time.July, 3, 12, 0, 0, 0, time.UTC) + + t.Run("rejects missing vault certificate", func(t *testing.T) { + err := validateVaultCertificateForUpdateAt("example.com", nil, &haproxy.CertificateDetail{}, now) + if err == nil || !strings.Contains(err.Error(), "does not exist in vault") { + t.Fatalf("expected missing vault certificate error, got %v", err) + } + }) + + t.Run("rejects expired vault certificate", func(t *testing.T) { + vaultCert := &x509.Certificate{ + NotAfter: now.Add(-time.Minute), + } + + err := validateVaultCertificateForUpdateAt("example.com", vaultCert, &haproxy.CertificateDetail{}, now) + if err == nil || !strings.Contains(err.Error(), "Vault certificate expired") { + t.Fatalf("expected expired vault certificate error, got %v", err) + } + }) + + t.Run("rejects certificate expiry downgrade", func(t *testing.T) { + vaultCert := &x509.Certificate{ + NotAfter: now.AddDate(0, 0, 30), + } + liveCert := &haproxy.CertificateDetail{ + NotAfter: now.AddDate(0, 0, 60), + } + + err := validateVaultCertificateForUpdateAt("example.com", vaultCert, liveCert, now) + if err == nil || !strings.Contains(err.Error(), "before live HAProxy certificate") { + t.Fatalf("expected downgrade rejection, got %v", err) + } + }) + + t.Run("accepts valid non-downgrade vault certificate", func(t *testing.T) { + vaultCert := &x509.Certificate{ + NotAfter: now.AddDate(0, 0, 90), + } + liveCert := &haproxy.CertificateDetail{ + NotAfter: now.AddDate(0, 0, 60), + } + + if err := validateVaultCertificateForUpdateAt("example.com", vaultCert, liveCert, now); err != nil { + t.Fatalf("validateVaultCertificateForUpdateAt() error = %v, want nil", err) + } + }) +} + func TestShouldUpdateForSerialMismatch(t *testing.T) { vaultCert := &x509.Certificate{SerialNumber: big.NewInt(0x1f5202e0)} diff --git a/cmd/certificator/main.go b/cmd/certificator/main.go index b5763db..2973b57 100644 --- a/cmd/certificator/main.go +++ b/cmd/certificator/main.go @@ -2,9 +2,14 @@ package main import ( "context" + "crypto/x509" + "errors" + "fmt" "strings" + "time" legoLog "github.com/go-acme/lego/v4/log" + "github.com/sirupsen/logrus" "github.com/sourcegraph/conc/pool" "github.com/vinted/certificator/pkg/acme" "github.com/vinted/certificator/pkg/certificate" @@ -78,6 +83,9 @@ func main() { cfg.DNSAddress, cfg.Acme.DNSChallengeProvider, cfg.Acme.DNSPropagationRequirement); err != nil { certmetrics.CertificatesRenewalFailures.WithLabelValues(mainDomain).Inc() certmetrics.CertificatesChecked.WithLabelValues(mainDomain, "failure").Inc() + if cleanupErr := deleteExpiredVaultCertificateAfterRenewalFailure(mainDomain, cert, vaultClient, logger); cleanupErr != nil { + return errors.Join(err, cleanupErr) + } return err } certmetrics.CertificatesRenewed.WithLabelValues(mainDomain).Inc() @@ -92,3 +100,16 @@ func main() { logger.Fatal(err) } } + +func deleteExpiredVaultCertificateAfterRenewalFailure(mainDomain string, cert *x509.Certificate, vaultClient *vault.VaultClient, logger *logrus.Logger) error { + if !certificate.IsExpired(cert, time.Now()) { + return nil + } + + logger.Warnf("renewal failed for %s and existing Vault certificate expired on %s; deleting expired Vault certificate", mainDomain, cert.NotAfter.Format(time.RFC3339)) + if err := certificate.DeleteCertificate(mainDomain, vaultClient); err != nil { + return fmt.Errorf("failed deleting expired Vault certificate for %s: %w", mainDomain, err) + } + + return nil +} diff --git a/pkg/certificate/certificate.go b/pkg/certificate/certificate.go index abfaa83..df9deb4 100644 --- a/pkg/certificate/certificate.go +++ b/pkg/certificate/certificate.go @@ -38,12 +38,12 @@ func ObtainCertificate(client *lego.Client, vault *vault.VaultClient, domains [] Domains: domains, Bundle: true, } - certificate, err := client.Certificate.Obtain(request) + certResource, err := client.Certificate.Obtain(request) if err != nil { return err } - return storeCertificateInVault(domains[0], certificate, vault) + return storeCertificateInVault(domains[0], certResource, vault) } // GetCertificate reads certificate from Vault KV store and parses it @@ -53,16 +53,35 @@ func GetCertificate(domain string, vault *vault.VaultClient) (*x509.Certificate, return nil, err } if cert, ok := secrets["certificate"].(string); ok { - parsedCert, err := certcrypto.ParsePEMBundle([]byte(cert)) - if err != nil { - return nil, err - } - return parsedCert[0], nil + return ParsePEMCertificate(cert) } return nil, nil } +// ParsePEMCertificate parses a PEM bundle and returns the first leaf certificate. +func ParsePEMCertificate(certPEM string) (*x509.Certificate, error) { + parsedCerts, err := certcrypto.ParsePEMBundle([]byte(certPEM)) + if err != nil { + return nil, err + } + if len(parsedCerts) == 0 { + return nil, fmt.Errorf("certificate bundle is empty") + } + + return parsedCerts[0], nil +} + +// DeleteCertificate removes certificate data from Vault KV storage. +func DeleteCertificate(domain string, vault *vault.VaultClient) error { + return vault.KVDelete(VaultCertLocation(domain)) +} + +// IsExpired reports whether a certificate is already expired at now. +func IsExpired(certificate *x509.Certificate, now time.Time) bool { + return certificate != nil && !certificate.NotAfter.After(now) +} + // NeedsReissuing checks if certificate domains and required domains match // and if certificate expiration date is earlier than configured in config.Cfg.RenewBeforeDays func NeedsReissuing(certificate *x509.Certificate, domains []string, days int, logger *logrus.Logger) (bool, error) { diff --git a/pkg/haproxy/client.go b/pkg/haproxy/client.go index 489dac3..87eef05 100644 --- a/pkg/haproxy/client.go +++ b/pkg/haproxy/client.go @@ -332,7 +332,8 @@ func (c *Client) GetCertificateDetail(certName string) (*CertificateDetail, erro } // UpdateCertificate uploads a replacement certificate to the live HAProxy -// runtime via Data Plane API v3. +// runtime via Data Plane API v3. Runtime updates take effect immediately but +// are not persisted across a full HAProxy restart. func (c *Client) UpdateCertificate(certName, pemData string) error { var buf bytes.Buffer writer := multipart.NewWriter(&buf) @@ -357,25 +358,39 @@ func (c *Client) UpdateCertificate(certName, pemData string) error { if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusAccepted { body, _ := io.ReadAll(resp.Body) - return errors.Errorf("failed to update certificate %s: status %d, body: %s", certName, resp.StatusCode, string(body)) + return unexpectedStatusError(fmt.Sprintf("failed to update runtime certificate %s", certName), resp.StatusCode, body) } - c.logger.Debugf("Updated certificate %s", certName) + c.logger.Debugf("Updated runtime certificate %s", certName) return nil } -// CreateCertificate creates a new certificate entry via Data Plane API -func (c *Client) CreateCertificate(certName, pemData string) error { - version, err := c.getConfigVersion() +// UpdateStorageCertificate replaces a certificate on disk without asking +// Data Plane API to reload HAProxy. Certificatee updates runtime separately +// after this write so the new certificate is both durable and active. +func (c *Client) UpdateStorageCertificate(certName, pemData string) error { + path := fmt.Sprintf("/v3/services/haproxy/storage/ssl_certificates/%s?skip_reload=true", url.PathEscape(certName)) + resp, err := c.doRequest("PUT", path, strings.NewReader(pemData), "text/plain") if err != nil { return err } + defer func() { _ = resp.Body.Close() }() - // Create multipart form data + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusAccepted { + body, _ := io.ReadAll(resp.Body) + return unexpectedStatusError(fmt.Sprintf("failed to update storage certificate %s", certName), resp.StatusCode, body) + } + + c.logger.Debugf("Updated storage certificate %s", certName) + return nil +} + +// CreateCertificate creates a new certificate entry on disk without asking +// Data Plane API to reload HAProxy. +func (c *Client) CreateCertificate(certName, pemData string) error { var buf bytes.Buffer writer := multipart.NewWriter(&buf) - // Add file part part, err := writer.CreateFormFile("file_upload", certName) if err != nil { return errors.Wrap(err, "failed to create form file") @@ -388,20 +403,37 @@ func (c *Client) CreateCertificate(certName, pemData string) error { return errors.Wrap(err, "failed to close multipart writer") } - // Send POST request to create certificate - path := fmt.Sprintf("/v3/services/haproxy/storage/ssl_certificates?version=%s", url.QueryEscape(version)) - resp, err := c.doRequest("POST", path, &buf, writer.FormDataContentType()) + resp, err := c.doRequest("POST", "/v3/services/haproxy/storage/ssl_certificates?skip_reload=true", &buf, writer.FormDataContentType()) if err != nil { return err } defer func() { _ = resp.Body.Close() }() - if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK { + if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusAccepted { body, _ := io.ReadAll(resp.Body) - return errors.Errorf("failed to create certificate %s: status %d, body: %s", certName, resp.StatusCode, string(body)) + return unexpectedStatusError(fmt.Sprintf("failed to create storage certificate %s", certName), resp.StatusCode, body) + } + + c.logger.Debugf("Created storage certificate %s", certName) + return nil +} + +// EnsureStorageCertificate writes a certificate to disk, creating it if it does +// not already exist. The write always uses skip_reload=true. +func (c *Client) EnsureStorageCertificate(certName, pemData string) error { + if err := c.UpdateStorageCertificate(certName, pemData); err != nil { + if !IsHTTPStatus(err, http.StatusNotFound) { + return err + } + + if err := c.CreateCertificate(certName, pemData); err != nil { + if IsHTTPStatus(err, http.StatusConflict) { + return c.UpdateStorageCertificate(certName, pemData) + } + return err + } } - c.logger.Debugf("Created certificate %s", certName) return nil } @@ -457,6 +489,11 @@ func NormalizeDomainForVault(domain string) string { return domain } +func StorageCertificateName(certPath string) string { + parts := strings.Split(certPath, "/") + return parts[len(parts)-1] +} + // IsExpiring checks if a certificate is expiring within the given number of days func IsExpiring(certInfo *CertInfo, renewBeforeDays int) bool { if certInfo == nil { diff --git a/pkg/haproxy/client_test.go b/pkg/haproxy/client_test.go index eaa1809..6393358 100644 --- a/pkg/haproxy/client_test.go +++ b/pkg/haproxy/client_test.go @@ -614,6 +614,70 @@ func TestGetCertificateDetail(t *testing.T) { } } +func TestUpdateStorageCertificate(t *testing.T) { + logger := logrus.New() + logger.SetLevel(logrus.PanicLevel) + + tests := []struct { + name string + certName string + pemData string + statusCode int + wantErr bool + }{ + { + name: "success - certificate replaced", + certName: "example.com.pem", + pemData: "-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----", + statusCode: http.StatusOK, + }, + { + name: "success - accepted", + certName: "example.com.pem", + pemData: "-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----", + statusCode: http.StatusAccepted, + }, + { + name: "error - not found", + certName: "missing.pem", + pemData: "-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----", + statusCode: http.StatusNotFound, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mock := newMockDataPlaneAPI(t) + defer mock.Close() + + mock.SetHandler("PUT", "/v3/services/haproxy/storage/ssl_certificates/"+tt.certName, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Get("skip_reload") != "true" { + t.Errorf("skip_reload query = %q, want true", r.URL.Query().Get("skip_reload")) + } + if got := r.Header.Get("Content-Type"); got != "text/plain" { + t.Errorf("Content-Type = %q, want text/plain", got) + } + data, _ := io.ReadAll(r.Body) + if string(data) != tt.pemData { + t.Errorf("body = %q, want %q", string(data), tt.pemData) + } + w.WriteHeader(tt.statusCode) + }) + + client, err := NewClient(ClientConfig{BaseURL: mock.URL()}, logger) + if err != nil { + t.Fatalf("NewClient() error = %v", err) + } + + err = client.UpdateStorageCertificate(tt.certName, tt.pemData) + if (err != nil) != tt.wantErr { + t.Errorf("UpdateStorageCertificate() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + func TestCreateCertificate(t *testing.T) { logger := logrus.New() logger.SetLevel(logrus.PanicLevel) @@ -630,14 +694,12 @@ func TestCreateCertificate(t *testing.T) { certName: "new.example.com.pem", pemData: "-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----", statusCode: http.StatusCreated, - wantErr: false, }, { name: "success - OK status", certName: "new.example.com.pem", pemData: "-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----", statusCode: http.StatusOK, - wantErr: false, }, { name: "error - conflict (already exists)", @@ -646,13 +708,6 @@ func TestCreateCertificate(t *testing.T) { statusCode: http.StatusConflict, wantErr: true, }, - { - name: "error - bad request", - certName: "bad.pem", - pemData: "invalid pem", - statusCode: http.StatusBadRequest, - wantErr: true, - }, } for _, tt := range tests { @@ -660,28 +715,19 @@ func TestCreateCertificate(t *testing.T) { mock := newMockDataPlaneAPI(t) defer mock.Close() - mock.SetHandler("GET", "/v3/services/haproxy/configuration/version", func(w http.ResponseWriter, r *http.Request) { - _, _ = w.Write([]byte("42")) - }) - mock.SetHandler("POST", "/v3/services/haproxy/storage/ssl_certificates", func(w http.ResponseWriter, r *http.Request) { - if r.URL.Query().Get("version") != "42" { - t.Errorf("version query = %q, want %q", r.URL.Query().Get("version"), "42") + if r.URL.Query().Get("skip_reload") != "true" { + t.Errorf("skip_reload query = %q, want true", r.URL.Query().Get("skip_reload")) } - - // Verify content type is multipart contentType := r.Header.Get("Content-Type") if !strings.Contains(contentType, "multipart/form-data") { t.Errorf("Expected multipart/form-data content type, got %s", contentType) } - // Read the multipart form - err := r.ParseMultipartForm(10 << 20) // 10 MB - if err != nil { + if err := r.ParseMultipartForm(10 << 20); err != nil { t.Errorf("Failed to parse multipart form: %v", err) } - // Verify file was uploaded file, header, err := r.FormFile("file_upload") if err != nil { t.Errorf("Failed to get file from form: %v", err) @@ -712,6 +758,44 @@ func TestCreateCertificate(t *testing.T) { } } +func TestEnsureStorageCertificateCreatesWhenMissing(t *testing.T) { + logger := logrus.New() + logger.SetLevel(logrus.PanicLevel) + + mock := newMockDataPlaneAPI(t) + defer mock.Close() + + var created bool + mock.SetHandler("PUT", "/v3/services/haproxy/storage/ssl_certificates/example.com.pem", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + }) + mock.SetHandler("POST", "/v3/services/haproxy/storage/ssl_certificates", func(w http.ResponseWriter, r *http.Request) { + created = true + if r.URL.Query().Get("skip_reload") != "true" { + t.Errorf("skip_reload query = %q, want true", r.URL.Query().Get("skip_reload")) + } + w.WriteHeader(http.StatusCreated) + }) + + client, err := NewClient(ClientConfig{BaseURL: mock.URL()}, logger) + if err != nil { + t.Fatalf("NewClient() error = %v", err) + } + + if err := client.EnsureStorageCertificate("example.com.pem", "pem-data"); err != nil { + t.Fatalf("EnsureStorageCertificate() error = %v", err) + } + if !created { + t.Fatal("expected missing storage cert to be created") + } +} + +func TestStorageCertificateName(t *testing.T) { + if got := StorageCertificateName("certs/_.devnet.rpcpool.com.pem"); got != "_.devnet.rpcpool.com.pem" { + t.Fatalf("StorageCertificateName() = %q, want _.devnet.rpcpool.com.pem", got) + } +} + func TestDeleteCertificate(t *testing.T) { logger := logrus.New() logger.SetLevel(logrus.PanicLevel) diff --git a/pkg/vault/vault.go b/pkg/vault/vault.go index 5d6c0d4..a6e8cf8 100644 --- a/pkg/vault/vault.go +++ b/pkg/vault/vault.go @@ -49,6 +49,19 @@ func (cl *VaultClient) KVWrite(path string, value map[string]string) error { return nil } +// KVDelete deletes the latest value from vault key value storage. +func (cl *VaultClient) KVDelete(path string) error { + fullPath := vaultFullPath(path, cl.kvPrefix) + cl.logger.Infof("deleting Vault path: %s", fullPath) + resp, err := cl.client.Logical().Delete(fullPath) + if err != nil { + return fmt.Errorf("failed deleting KV value from Vault at path: %s, got: %v, error: %s", + fullPath, resp, err) + } + + return nil +} + // KVRead reads data from vault key value storage func (cl *VaultClient) KVRead(path string) (map[string]interface{}, error) { fullPath := vaultFullPath(path, cl.kvPrefix)