mirror of
https://github.com/osixia/container-openldap.git
synced 2026-11-06 20:48:18 -04:00
- Update the base image to osixia/baseimage:alpine-2.0.0 and build the CI/CD tool with osixia/dagger-build (Go 1.26.9). - Add Dagger integration tests: default bootstrap, alternative schemas, all compatible modules and schemas with every bootstrap feature on a 3-node replicated cluster with TLS required, backup/restore, simulated upgrade and upgrade from previous releases. - Fix bootstrap failing with "Argument list too long" with large schemas: load them with LDIF include: lines, fail on missing schemas. - Default OPENLDAP_BOOTSTRAP_PPOLICY_DEFAULT_CHECK_QUALITY to 1 so pwdMinLength is enforced. - Align the CI workflow with osixia/container-baseimage and add Dependabot. - Document available schemas and modules and their incompatibilities. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
259 lines
7.9 KiB
Go
259 lines
7.9 KiB
Go
package tests
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
_ "embed"
|
|
"fmt"
|
|
"log"
|
|
"slices"
|
|
"strings"
|
|
|
|
"dagger.io/dagger"
|
|
)
|
|
|
|
const (
|
|
suffix = "dc=example,dc=org"
|
|
password = "Test-passw0rd-2026"
|
|
|
|
confDir = "/etc/openldap/slapd.d"
|
|
dataDir = "/var/lib/openldap/openldap-data"
|
|
backupDir = "/var/lib/openldap/openldap-backups"
|
|
certsDir = "/container/services/openldap/assets/certs"
|
|
|
|
ldapUser = "ldap"
|
|
)
|
|
|
|
// env is a set of environment variables, applied in a stable order to keep Dagger caching
|
|
// deterministic.
|
|
type env map[string]string
|
|
|
|
func withEnv(c *dagger.Container, e env) *dagger.Container {
|
|
keys := make([]string, 0, len(e))
|
|
for k := range e {
|
|
keys = append(keys, k)
|
|
}
|
|
slices.Sort(keys)
|
|
|
|
for _, k := range keys {
|
|
c = c.WithEnvVariable(k, e[k])
|
|
}
|
|
|
|
return c
|
|
}
|
|
|
|
// merge returns a new env with the values of all given envs, later ones win.
|
|
func merge(envs ...env) env {
|
|
r := env{}
|
|
for _, e := range envs {
|
|
for k, v := range e {
|
|
r[k] = v
|
|
}
|
|
}
|
|
return r
|
|
}
|
|
|
|
// volumes holds the OpenLDAP config, data and backup directories, shared between containers
|
|
// like Docker or Kubernetes volumes.
|
|
type volumes struct {
|
|
conf *dagger.Directory
|
|
data *dagger.Directory
|
|
backup *dagger.Directory
|
|
}
|
|
|
|
func emptyVolumes(client *dagger.Client) volumes {
|
|
return volumes{conf: client.Directory(), data: client.Directory(), backup: client.Directory()}
|
|
}
|
|
|
|
func withVolumes(c *dagger.Container, v volumes) *dagger.Container {
|
|
opts := dagger.ContainerWithDirectoryOpts{Owner: ldapUser}
|
|
return c.WithUser("0").
|
|
WithDirectory(confDir, v.conf, opts).
|
|
WithDirectory(dataDir, v.data, opts).
|
|
WithDirectory(backupDir, v.backup, opts).
|
|
WithUser(ldapUser)
|
|
}
|
|
|
|
func readVolumes(c *dagger.Container) volumes {
|
|
return volumes{conf: c.Directory(confDir), data: c.Directory(dataDir), backup: c.Directory(backupDir)}
|
|
}
|
|
|
|
// result is the outcome of a command that is allowed to fail.
|
|
type result struct {
|
|
container *dagger.Container
|
|
code int
|
|
output string
|
|
}
|
|
|
|
// run executes args in c, without failing on a non-zero exit code. A random value busts the
|
|
// Dagger cache so the command really runs on each test run.
|
|
func run(ctx context.Context, c *dagger.Container, args ...string) (*result, error) {
|
|
|
|
c = c.WithEnvVariable("OPENLDAP_TEST_RUN", rand.Text()).
|
|
WithExec(args, dagger.ContainerWithExecOpts{Expect: dagger.ReturnTypeAny})
|
|
|
|
code, err := c.ExitCode(ctx)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%v: %w", args, err)
|
|
}
|
|
stdout, err := c.Stdout(ctx)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%v stdout: %w", args, err)
|
|
}
|
|
stderr, err := c.Stderr(ctx)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%v stderr: %w", args, err)
|
|
}
|
|
|
|
return &result{container: c, code: code, output: stdout + stderr}, nil
|
|
}
|
|
|
|
// expect runs args in c and checks its exit status and that its output contains every
|
|
// wanted string.
|
|
func expect(ctx context.Context, c *dagger.Container, success bool, want []string, args ...string) (*result, error) {
|
|
|
|
r, err := run(ctx, c, args...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if success && r.code != 0 {
|
|
return nil, fmt.Errorf("%v: exit code %d, expected success\n%s", args, r.code, r.output)
|
|
}
|
|
if !success && r.code == 0 {
|
|
return nil, fmt.Errorf("%v: succeeded, expected failure\n%s", args, r.output)
|
|
}
|
|
|
|
for _, w := range want {
|
|
if !strings.Contains(r.output, w) {
|
|
return nil, fmt.Errorf("%v: output does not contain %q\n%s", args, w, r.output)
|
|
}
|
|
}
|
|
|
|
return r, nil
|
|
}
|
|
|
|
// hashPassword hashes password with the image's openldap-ctl and its default {ARGON2} scheme.
|
|
func hashPassword(ctx context.Context, image *dagger.Container) (string, error) {
|
|
return hashPasswordWith(ctx, image, "{ARGON2}", nil)
|
|
}
|
|
|
|
// hashPasswordWith hashes password with the image's openldap-ctl, e is applied to change
|
|
// the hash command.
|
|
func hashPasswordWith(ctx context.Context, image *dagger.Container, scheme string, e env) (string, error) {
|
|
|
|
out, err := withEnv(image, e).WithExec([]string{"container", "run", "--", "openldap-ctl", "password", "hash", password}).Stdout(ctx)
|
|
if err != nil {
|
|
return "", fmt.Errorf("hash password: %w", err)
|
|
}
|
|
|
|
hash := strings.TrimSpace(out)
|
|
if !strings.HasPrefix(hash, scheme) {
|
|
return "", fmt.Errorf("unexpected password hash: %q", hash)
|
|
}
|
|
|
|
return hash, nil
|
|
}
|
|
|
|
// certificates generates a test CA and a server certificate valid for hosts.
|
|
func certificates(client *dagger.Client, hosts []string) *dagger.Directory {
|
|
|
|
sans := make([]string, 0, len(hosts))
|
|
for _, h := range hosts {
|
|
sans = append(sans, "DNS:"+h)
|
|
}
|
|
|
|
// a random value busts the Dagger cache, so certificates never expire between runs
|
|
work := client.Container().From("alpine:3.24").
|
|
WithExec([]string{"apk", "add", "--no-cache", "openssl"}).
|
|
WithEnvVariable("OPENLDAP_TEST_RUN", rand.Text()).
|
|
WithWorkdir("/work").
|
|
WithNewFile("/work/san.ext", "subjectAltName="+strings.Join(sans, ",")+"\n").
|
|
WithExec([]string{"openssl", "req", "-x509", "-newkey", "rsa:2048", "-nodes", "-days", "2", "-subj", "/CN=osixia-openldap-test-ca", "-keyout", "ca.key", "-out", "ca.crt"}).
|
|
WithExec([]string{"openssl", "req", "-newkey", "rsa:2048", "-nodes", "-subj", "/CN=" + hosts[0], "-keyout", "tls.key", "-out", "tls.csr"}).
|
|
WithExec([]string{"openssl", "x509", "-req", "-days", "2", "-in", "tls.csr", "-CA", "ca.crt", "-CAkey", "ca.key", "-CAcreateserial", "-extfile", "san.ext", "-out", "tls.crt"})
|
|
|
|
return client.Directory().
|
|
WithFile("ca.crt", work.File("/work/ca.crt")).
|
|
WithFile("tls.crt", work.File("/work/tls.crt")).
|
|
WithFile("tls.key", work.File("/work/tls.key"))
|
|
}
|
|
|
|
// startNode starts the image as a service reachable at fqdn.
|
|
//
|
|
// fqdn must not contain dots: Dagger registers it under the session search domain, which
|
|
// musl only uses for names without dots.
|
|
//
|
|
// The node.sh fixture gives the container this hostname and makes it resolve locally, as
|
|
// Docker or Kubernetes do: the replication bootstrap looks for `hostname -f` in
|
|
// OPENLDAP_BOOTSTRAP_REPLICATION_HOSTS and slapd matches olcServerID URLs against its
|
|
// listeners. This needs root capabilities in this derived test container only, then
|
|
// the image runs as the ldap user.
|
|
func startNode(ctx context.Context, image *dagger.Container, fqdn string, e env, certs *dagger.Directory, v *volumes) (*dagger.Service, error) {
|
|
|
|
c := image
|
|
if v != nil {
|
|
c = withVolumes(c, *v)
|
|
}
|
|
if certs != nil {
|
|
c = c.WithUser("0").WithDirectory(certsDir, certs, dagger.ContainerWithDirectoryOpts{Owner: ldapUser})
|
|
}
|
|
|
|
service := withEnv(c, e).
|
|
WithUser("0").
|
|
WithNewFile("/tmp/openldap-test-node.sh", nodeScript, dagger.ContainerWithNewFileOpts{Permissions: 0755}).
|
|
WithExposedPort(3890).
|
|
WithExposedPort(6360).
|
|
AsService(dagger.ContainerAsServiceOpts{
|
|
Args: []string{"/tmp/openldap-test-node.sh", fqdn},
|
|
InsecureRootCapabilities: true,
|
|
}).
|
|
WithHostname(fqdn)
|
|
|
|
if _, err := service.Start(ctx); err != nil {
|
|
return nil, fmt.Errorf("start %s: %w", fqdn, err)
|
|
}
|
|
|
|
return service, nil
|
|
}
|
|
|
|
func stopNodes(ctx context.Context, services []*dagger.Service) {
|
|
for _, s := range services {
|
|
_, _ = s.Stop(ctx, dagger.ServiceStopOpts{Kill: true})
|
|
}
|
|
}
|
|
|
|
// check runs a testdata script in a client container built from the image, with the test
|
|
// CA trusted.
|
|
func check(ctx context.Context, image *dagger.Container, certs *dagger.Directory, e env, script string) error {
|
|
|
|
c := image
|
|
if certs != nil {
|
|
c = c.WithMountedDirectory("/tmp/openldap-test-certs", certs).
|
|
WithEnvVariable("LDAPTLS_CACERT", "/tmp/openldap-test-certs/ca.crt")
|
|
}
|
|
|
|
c = withEnv(c, merge(env{"TEST_PASSWORD": password, "TEST_SUFFIX": suffix}, e)).
|
|
WithNewFile("/tmp/openldap-test-lib.sh", libScript).
|
|
WithNewFile("/tmp/openldap-test.sh", script)
|
|
|
|
r, err := expect(ctx, c, true, nil, "bash", "/tmp/openldap-test.sh")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
for line := range strings.Lines(r.output) {
|
|
if strings.HasPrefix(line, "ok: ") {
|
|
log.Print(strings.TrimSpace(line))
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
//go:embed testdata/node.sh
|
|
var nodeScript string
|
|
|
|
//go:embed testdata/lib.sh
|
|
var libScript string
|