Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

test: Use Pebble as the ACME server in integration tests #339

Merged
merged 17 commits into from
Nov 8, 2024
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,8 @@ require (
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.17.9 // indirect
github.com/kubernetes-csi/external-snapshotter/client/v4 v4.2.0 // indirect
github.com/letsencrypt/challtestsrv v1.3.2 // indirect
github.com/letsencrypt/pebble/v2 v2.6.0 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
Expand Down
7 changes: 7 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,10 @@ github.com/kubernetes-csi/external-snapshotter/client/v4 v4.2.0 h1:nHHjmvjitIiyP
github.com/kubernetes-csi/external-snapshotter/client/v4 v4.2.0/go.mod h1:YBCo4DoEeDndqvAn6eeu0vWM7QdXmHEeI9cFWplmBys=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/letsencrypt/challtestsrv v1.3.2 h1:pIDLBCLXR3B1DLmOmkkqg29qVa7DDozBnsOpL9PxmAY=
github.com/letsencrypt/challtestsrv v1.3.2/go.mod h1:Ur4e4FvELUXLGhkMztHOsPIsvGxD/kzSJninOrkM+zc=
github.com/letsencrypt/pebble/v2 v2.6.0 h1:7xetaJ4YaesUnWWeRGSs3UHOwyfX4I4sfOfDrkvnhNw=
github.com/letsencrypt/pebble/v2 v2.6.0/go.mod h1:SID2E75Cx6sQ9AXFkdzhLdQ6S1zhRUbw08Cgu7GJLSk=
github.com/mailru/easyjson v0.0.0-20160728113105-d5b7844b561a/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
Expand All @@ -284,6 +288,7 @@ github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovk
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/miekg/dns v1.1.43/go.mod h1:+evo5L0630/F6ca/Z9+GAqzhjGyn8/c+TBaOyfEl0V4=
github.com/miekg/dns v1.1.62 h1:cN8OuEF1/x5Rq6Np+h1epln8OiyPWV+lROx9LxcGgIQ=
github.com/miekg/dns v1.1.62/go.mod h1:mvDlcItzm+br7MToIKqkglaGhlFMHJ9DTNNWONWXbNQ=
github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw=
Expand Down Expand Up @@ -483,6 +488,7 @@ golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJ
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
Expand All @@ -504,6 +510,7 @@ golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20200622214017-ed371f2e16b4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210303074136-134d130e1a04/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
Expand Down
31 changes: 23 additions & 8 deletions test/integration/controller/issuer/issuer_suite_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import (
certclient "github.com/gardener/cert-management/pkg/cert/client"
ctrl "github.com/gardener/cert-management/pkg/controller"
_ "github.com/gardener/cert-management/pkg/controller/issuer"

testutils "github.com/gardener/cert-management/test/utils"
"github.com/gardener/controller-manager-library/pkg/controllermanager"
"github.com/gardener/controller-manager-library/pkg/controllermanager/cluster"
"github.com/gardener/controller-manager-library/pkg/controllermanager/controller/mappings"
Expand Down Expand Up @@ -47,19 +47,34 @@ var (
ctx context.Context
log logr.Logger

restConfig *rest.Config
testEnv *envtest.Environment
testClient client.Client
kubeconfigFile string
restConfig *rest.Config
testEnv *envtest.Environment
testClient client.Client
acmeDirectoryAddress string
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

acmeDirectoryAddress has been added. This variable holds the directory address of the local Pebble ACME server that will be used to create Issuer resources in tests.
The test asserts that this variable has been set before the test starts.

kubeconfigFile string

scheme *runtime.Scheme
)

var _ = BeforeSuite(func() {

logf.SetLogger(logger.MustNewZapLogger(logger.DebugLevel, logger.FormatJSON, zap.WriteTo(GinkgoWriter)))
log = logf.Log.WithName(testID)

By("Start Pebble ACME server")
certificatePath, directoryAddress, err := testutils.RunPebble(log.WithName("pebble"))
Expect(err).NotTo(HaveOccurred())
acmeDirectoryAddress = directoryAddress
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sets the package scoped variable that tests will use to create Issuer resources.


// The go-acme/lego library needs to trust the TLS certificate of the Pebble ACME server.
// See: https://github.com/go-acme/lego/blob/f2f5550d3a55ec1118f73346cce7a984b4d530f6/lego/client_config.go#L19-L24
Expect(os.Setenv("LEGO_CA_CERTIFICATES", certificatePath)).To(Succeed())

// Starting the Pebble TLS server is a blocking function call that runs in a separate goroutine.
// As the ACME directory endpoint might not be available immediately, we wait until it is reachable.
Eventually(func() error {
return testutils.CheckPebbleAvailability(certificatePath, acmeDirectoryAddress)
}).Should(Succeed())

By("Start test environment")
testEnv = &envtest.Environment{
CRDInstallOptions: envtest.CRDInstallOptions{
Expand All @@ -73,8 +88,7 @@ var _ = BeforeSuite(func() {
ErrorIfCRDPathMissing: true,
}

var err error
restConfig, err = testEnv.Start()
restConfig, err := testEnv.Start()
Expect(err).NotTo(HaveOccurred())
Expect(restConfig).NotTo(BeNil())

Expand All @@ -86,6 +100,7 @@ var _ = BeforeSuite(func() {
DeferCleanup(func() {
By("Stop test environment")
Expect(testEnv.Stop()).To(Succeed())
_ = os.RemoveAll(filepath.Dir(certificatePath))
Copy link
Member Author

@marc1404 marc1404 Nov 7, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The code responsible for starting the Pebble server generates a certificate and private key on the fly in a temporary OS directory.
Instead of deleting the two files and their directory individually, we can clear them in one sweep.

_ = os.Remove(kubeconfigFile)
})

Expand Down
4 changes: 3 additions & 1 deletion test/integration/controller/issuer/issuer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ var _ = Describe("Issuer controller tests", func() {
)

BeforeEach(func() {
Expect(acmeDirectoryAddress).NotTo(BeEmpty())
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

During development, I messed up initializing the package level variable in the test suite. Tests should assert early on that the variable has been set.


ctxLocal := context.Background()
ctx0 := ctxutil.CancelContext(ctxutil.WaitGroupContext(context.Background(), "main"))
ctx = ctxutil.TickContext(ctx0, controllermanager.DeletionActivity)
Expand Down Expand Up @@ -78,7 +80,7 @@ var _ = Describe("Issuer controller tests", func() {
Spec: v1alpha1.IssuerSpec{
ACME: &v1alpha1.ACMESpec{
Email: "foo@somewhere-foo-123456.com",
Server: "https://acme-staging-v02.api.letsencrypt.org/directory",
Server: acmeDirectoryAddress,
AutoRegistration: true,
},
},
Expand Down
26 changes: 26 additions & 0 deletions test/utils/logbridge.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package testutils

import (
"github.com/go-logr/logr"
"log"
"strings"
)

type logBridge struct {
logr logr.Logger
}

func (logBridge *logBridge) Write(p []byte) (n int, err error) {
message := strings.TrimSpace(string(p))

logBridge.logr.Info(message)

return len(p), nil
}

// NewLogBridge creates a new log.Logger that forwards all log messages to the given logr.Logger.
func NewLogBridge(logr logr.Logger) *log.Logger {
writer := &logBridge{logr}

return log.New(writer, "", 0)
}
Copy link
Member Author

@marc1404 marc1404 Nov 7, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pebble's code expects a logger from the log package. (ref)
This utility creates a bridge that returns a log.Logger but redirects messages to the logr.Logger used by the test suite.

142 changes: 142 additions & 0 deletions test/utils/pebble.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
package testutils

import (
"crypto/tls"
"crypto/x509"
"fmt"
"net/http"
"os"
"os/exec"
"runtime"

"github.com/go-logr/logr"
"github.com/letsencrypt/pebble/v2/ca"
"github.com/letsencrypt/pebble/v2/cmd"
"github.com/letsencrypt/pebble/v2/db"
"github.com/letsencrypt/pebble/v2/va"
"github.com/letsencrypt/pebble/v2/wfe"
)

// The default values for the Pebble config have been taken from: https://github.com/letsencrypt/pebble/blob/main/test/config/pebble-config.json
const (
listenAddress = "localhost:14000"
ocspResponderURL = ""
alternateRoots = 0
chainLength = 1
certificateValidityPeriod = 0
httpPort = 5002
tlsPort = 5001
strict = true
customResolverAddr = ""
requireEAB = false
retryAfterAuthz = 3
retryAfterOrder = 5
)

// RunPebble runs a pebble server with the given configuration.
// The code is copied, shortened, and adapted from: https://github.com/letsencrypt/pebble/blob/main/cmd/pebble/main.go
func RunPebble(logr logr.Logger) (certificatePath, directoryAddress string, err error) {
// We don't want to go through DNS-01 challenges in the integration tests as we would have to spin up a local, authoritative DNS server.
// Setting the environment variable PEBBLE_VA_ALWAYS_VALID to 1 makes the Pebble server always return a valid response for the validation authority.
// Testing the DNS-01 challenge is covered by the functional E2E tests.
// See the Pebble documentation: https://github.com/letsencrypt/pebble#skipping-validation
err = os.Setenv("PEBBLE_VA_ALWAYS_VALID", "1")
if err != nil {
return "", "", fmt.Errorf("failed to set environment variable: %v", err)
}
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This could also be done in the test suite but as we never want to pass DNS-01 challenges in the integration tests I figured the responsibility could be kept in the Pebble utility code.


certificatePath, privateKeyPath, err := generateCertificate()
if err != nil {
return "", "", err
}

log := NewLogBridge(logr)

database := db.NewMemoryStore()
certificateAuthority := ca.New(log, database, ocspResponderURL, alternateRoots, chainLength, certificateValidityPeriod)
validationAuthority := va.New(log, httpPort, tlsPort, strict, customResolverAddr, database)

wfeImpl := wfe.New(log, database, validationAuthority, certificateAuthority, strict, requireEAB, retryAfterAuthz, retryAfterOrder)
muxHandler := wfeImpl.Handler()

directoryAddress = fmt.Sprintf("https://%s%s", listenAddress, wfe.DirectoryPath)

log.Printf("Listening on: %s", listenAddress)
log.Printf("ACME directory available at: %s",
directoryAddress)

go func() {
err := http.ListenAndServeTLS(
listenAddress,
certificatePath,
privateKeyPath,
muxHandler)
marc1404 marked this conversation as resolved.
Show resolved Hide resolved
cmd.FailOnError(err, "Calling ListenAndServeTLS()")
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

http.ListenAndServeTLS is a blocking function that only returns in case of an error (e.g. address already in use).

cmd.FailOnError is implemented by Pebble and effectively exits the process with the error.
It's useful as it will exit the integration test suite early with a descriptive error message in case the Pebble server can't be started.

}()

return certificatePath, directoryAddress, nil
}

// CheckPebbleAvailability checks if the Pebble ACME server is available at the given address.
func CheckPebbleAvailability(certificatePath string, listenAddress string) error {
rootCAs, err := loadCertPool(certificatePath)
if err != nil {
return err
}

customTransport := http.DefaultTransport.(*http.Transport).Clone()
customTransport.TLSClientConfig = &tls.Config{RootCAs: rootCAs}
marc1404 marked this conversation as resolved.
Show resolved Hide resolved
http.DefaultTransport = customTransport
client := &http.Client{Transport: customTransport}
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The ACME protocol enforces TLS and the utility code generates a temporary certificate on the fly.
We need to trust the certificate when performing the availability check. Otherwise, we'd have to skip the insecure validation.


response, err := client.Get(listenAddress)
if err != nil {
return err
}

defer response.Body.Close()

if response.StatusCode != http.StatusOK {
return fmt.Errorf("expected status 200 from %s, got %d", listenAddress, response.StatusCode)
}

return nil
}

// generateCertificate generates a certificate and private key for the Pebble server in a temporary OS directory.
// Inspired from: https://github.com/gardener/cert-management/blob/584014befb02a80a063184c9b765db7e12e66f52/hack/kind/pebble/pebble-up.sh#L15-L23
func generateCertificate() (certificatePath, privateKeyPath string, err error) {
tempDirectoryPath, err := os.MkdirTemp("", "pebble")
if err != nil {
return "", "", fmt.Errorf("failed to create temporary directory: %v", err)
}

goRoot := runtime.GOROOT()
goFile := fmt.Sprintf("%s/src/crypto/tls/generate_cert.go", goRoot)
command := exec.Command("go", "run", goFile, "--host=localhost", "--ecdsa-curve=P256")
marc1404 marked this conversation as resolved.
Show resolved Hide resolved
command.Dir = tempDirectoryPath
err = command.Run()
if err != nil {
return "", "", fmt.Errorf("failed to generate certificate: %v", err)
}

certificatePath = fmt.Sprintf("%s/cert.pem", tempDirectoryPath)
privateKeyPath = fmt.Sprintf("%s/key.pem", tempDirectoryPath)

return certificatePath, privateKeyPath, nil
}

func loadCertPool(certificatePath string) (*x509.CertPool, error) {
certData, err := os.ReadFile(certificatePath)
marc1404 marked this conversation as resolved.
Show resolved Hide resolved
if err != nil {
return nil, fmt.Errorf("failed to read certificate file: %v", err)
}

certPool := x509.NewCertPool()
ok := certPool.AppendCertsFromPEM(certData)
if !ok {
return nil, fmt.Errorf("failed to parse certificates from PEM")
}

return certPool, nil
}