mirror of
https://github.com/osixia/container-openldap.git
synced 2026-11-12 15:23:49 -04:00
openldap-ctl restore --force deleted existing config and data before loading the backup: a failing slapadd left OpenLDAP stopped with empty or partial databases. Existing data is now moved to a hidden .restore-previous directory in the same volume, removed when the restore succeeds, and put back otherwise. OpenLDAP is restarted in both cases if it was running. A leftover .restore-previous directory from an interrupted restore blocks the next restore. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
370 lines
12 KiB
Go
370 lines
12 KiB
Go
package tests
|
|
|
|
import (
|
|
"context"
|
|
_ "embed"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"dagger.io/dagger"
|
|
)
|
|
|
|
// olderVersion is lower than the image OpenLDAP version at the patch level and equal at the
|
|
// minor level, to simulate the N-1 image with the N image.
|
|
const olderVersion = "2.6.0-r0"
|
|
|
|
// previousRelease is a published image to upgrade from.
|
|
type previousRelease struct {
|
|
image string
|
|
|
|
// manualBackup takes the pre-upgrade backup with openldap-ctl: the openldap-pre-upgrade
|
|
// service of 2.6.10-alpha is not executable.
|
|
manualBackup bool
|
|
}
|
|
|
|
var previousReleases = []previousRelease{
|
|
{image: "osixia/openldap:2.6.10-alpha", manualBackup: true},
|
|
{image: "osixia/openldap:2.6.15-alpha"},
|
|
}
|
|
|
|
const peopleLDIF = `dn: ou=people,` + suffix + `
|
|
objectClass: organizationalUnit
|
|
ou: people
|
|
|
|
dn: uid=alice,ou=people,` + suffix + `
|
|
objectClass: inetOrgPerson
|
|
uid: alice
|
|
cn: Alice
|
|
sn: Liddell
|
|
`
|
|
|
|
// bootstrap runs the startup scripts of image on v, with the test root passwords, then adds
|
|
// peopleLDIF offline.
|
|
func bootstrap(ctx context.Context, image *dagger.Container, v volumes, passwords env) (volumes, error) {
|
|
|
|
r, err := expect(ctx, withEnv(withVolumes(image, v), passwords), true, []string{"Writing OpenLDAP version"}, "container", "--skip-process")
|
|
if err != nil {
|
|
return v, fmt.Errorf("bootstrap: %w", err)
|
|
}
|
|
|
|
r, err = expect(ctx, r.container.WithNewFile("/tmp/openldap-test-people.ldif", peopleLDIF), true, nil,
|
|
"container", "run", "--", "slapadd", "-n", "1", "-l", "/tmp/openldap-test-people.ldif")
|
|
if err != nil {
|
|
return v, fmt.Errorf("add test entries: %w", err)
|
|
}
|
|
|
|
return readVolumes(r.container), nil
|
|
}
|
|
|
|
// expectPeople checks peopleLDIF entries are in the database of v, offline.
|
|
func expectPeople(ctx context.Context, image *dagger.Container, v volumes) error {
|
|
_, err := expect(ctx, withVolumes(image, v), true, []string{"dn: uid=alice,ou=people," + suffix},
|
|
"container", "run", "--", "slapcat", "-n", "1")
|
|
return err
|
|
}
|
|
|
|
// expectVersion checks the version file of v.
|
|
func expectVersion(ctx context.Context, image *dagger.Container, v volumes, version string) error {
|
|
_, err := expect(ctx, withVolumes(image, v), true, []string{version}, "cat", confDir+"/.version")
|
|
return err
|
|
}
|
|
|
|
// expectServed starts image on v and checks peopleLDIF entries are served.
|
|
func expectServed(ctx context.Context, image *dagger.Container, v volumes) error {
|
|
|
|
const node = "ldap-upgraded"
|
|
|
|
service, err := startNode(ctx, image, node, nil, nil, &v)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer stopNodes(ctx, []*dagger.Service{service})
|
|
|
|
return check(ctx, image, nil, env{"TEST_NODES": node}, servedCheck)
|
|
}
|
|
|
|
// BackupRestore checks openldap-ctl backup and restore, offline.
|
|
func BackupRestore(ctx context.Context, client *dagger.Client, image *dagger.Container) error {
|
|
|
|
passwords, err := rootPasswords(ctx, image)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
v, err := bootstrap(ctx, image, emptyVolumes(client), passwords)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
r, err := expect(ctx, withVolumes(image, v), true, nil, "container", "run", "--", "openldap-ctl", "backup", "test-backup")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
v = readVolumes(r.container)
|
|
|
|
if _, err := expect(ctx, withVolumes(image, v), true, []string{"test-backup-config.gz", "test-backup-data.gz"}, "ls", backupDir); err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := clean(ctx, image, v); err != nil {
|
|
return err
|
|
}
|
|
|
|
// restore into empty config and data directories
|
|
restored := emptyVolumes(client)
|
|
restored.backup = v.backup
|
|
|
|
r, err = expect(ctx, withVolumes(image, restored), true, nil, "container", "run", "--", "openldap-ctl", "restore", "test-backup")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := expectPeople(ctx, image, readVolumes(r.container)); err != nil {
|
|
return fmt.Errorf("restore into empty directories: %w", err)
|
|
}
|
|
|
|
// restore over existing data requires --force
|
|
if _, err := expect(ctx, withVolumes(image, v), false, nil, "container", "run", "--", "openldap-ctl", "restore", "test-backup"); err != nil {
|
|
return fmt.Errorf("restore over existing data without --force: %w", err)
|
|
}
|
|
|
|
r, err = expect(ctx, withVolumes(image, v), true, nil, "container", "run", "--", "openldap-ctl", "restore", "test-backup", "--force")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := expectPeople(ctx, image, readVolumes(r.container)); err != nil {
|
|
return fmt.Errorf("forced restore: %w", err)
|
|
}
|
|
|
|
// an invalid backup is rejected before existing data is deleted
|
|
corrupted := v
|
|
corrupted.backup = v.backup.
|
|
WithNewFile("corrupted-config.gz", "not a gzip file").
|
|
WithNewFile("corrupted-data.gz", "not a gzip file")
|
|
|
|
r, err = expect(ctx, withVolumes(image, corrupted), false, nil, "container", "run", "--", "openldap-ctl", "restore", "corrupted", "--force")
|
|
if err != nil {
|
|
return fmt.Errorf("restore of a corrupted backup: %w", err)
|
|
}
|
|
if err := expectPeople(ctx, image, readVolumes(r.container)); err != nil {
|
|
return fmt.Errorf("data after the restore of a corrupted backup: %w", err)
|
|
}
|
|
|
|
// a backup that fails to load is rolled back to the previous data
|
|
invalid := withVolumes(image, v).
|
|
WithExec([]string{"cp", backupDir + "/test-backup-config.gz", backupDir + "/invalid-config.gz"}).
|
|
WithExec([]string{"sh", "-c", "printf 'dn: not a valid entry\\nfoo\\n' | gzip > " + backupDir + "/invalid-data.gz"})
|
|
|
|
r, err = expect(ctx, invalid, false, []string{"Restore failed, rolling back to the previous data"}, "container", "run", "--", "openldap-ctl", "restore", "invalid", "--force")
|
|
if err != nil {
|
|
return fmt.Errorf("restore of an invalid backup: %w", err)
|
|
}
|
|
rolledBack := readVolumes(r.container)
|
|
if err := expectPeople(ctx, image, rolledBack); err != nil {
|
|
return fmt.Errorf("data after the rollback: %w", err)
|
|
}
|
|
if _, err := expect(ctx, withVolumes(image, rolledBack), false, nil, "ls", "-d", confDir+"/.restore-previous", dataDir+"/.restore-previous"); err != nil {
|
|
return fmt.Errorf("previous data directories after the rollback: %w", err)
|
|
}
|
|
|
|
// an interrupted restore is detected
|
|
interrupted := withVolumes(image, v).WithExec([]string{"mkdir", dataDir + "/.restore-previous"})
|
|
if _, err := expect(ctx, interrupted, false, []string{"a previous restore was interrupted"}, "container", "run", "--", "openldap-ctl", "restore", "test-backup", "--force"); err != nil {
|
|
return fmt.Errorf("restore after an interrupted restore: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Upgrade simulates the N-1 image with olderVersion and checks the pre-upgrade backup on
|
|
// N-1 and the restore-based migration on N.
|
|
func Upgrade(ctx context.Context, client *dagger.Client, image *dagger.Container) error {
|
|
|
|
version, err := image.EnvVariable(ctx, "OPENLDAP_VERSION")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
passwords, err := rootPasswords(ctx, image)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
patch := env{"OPENLDAP_UPGRADE_MIGRATION_LEVEL": "patch"}
|
|
newer := withEnv(image, patch)
|
|
older := withEnv(image, merge(patch, env{"OPENLDAP_VERSION": olderVersion}))
|
|
|
|
v, err := bootstrap(ctx, older, emptyVolumes(client), passwords)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// N without pre-upgrade backup
|
|
if _, err := expect(ctx, withVolumes(newer, v), false, []string{"Missing"}, "container", "--skip-process"); err != nil {
|
|
return fmt.Errorf("upgrade without backup: %w", err)
|
|
}
|
|
|
|
// the default minor migration level ignores patch versions
|
|
if _, err := expect(ctx, withVolumes(image, v), true, []string{"No migration needed"}, "container", "--skip-process"); err != nil {
|
|
return fmt.Errorf("upgrade at the minor level: %w", err)
|
|
}
|
|
|
|
// N-1 pre-upgrade, as an init container
|
|
backupPrefix := "openldap-upgrade-" + strings.TrimSuffix(olderVersion, "-r0")
|
|
r, err := expect(ctx, withVolumes(older, v), true, nil, "container", "-x", "openldap-pre-upgrade", "--skip-process")
|
|
if err != nil {
|
|
return fmt.Errorf("pre-upgrade: %w", err)
|
|
}
|
|
v = readVolumes(r.container)
|
|
|
|
if _, err := expect(ctx, withVolumes(image, v), true, []string{backupPrefix + "-config.gz", backupPrefix + "-data.gz"}, "ls", backupDir); err != nil {
|
|
return fmt.Errorf("pre-upgrade backup: %w", err)
|
|
}
|
|
|
|
// N
|
|
r, err = expect(ctx, withVolumes(newer, v), true, []string{"Migration required"}, "container", "--skip-process")
|
|
if err != nil {
|
|
return fmt.Errorf("upgrade: %w", err)
|
|
}
|
|
v = readVolumes(r.container)
|
|
|
|
if err := expectVersion(ctx, image, v, version); err != nil {
|
|
return err
|
|
}
|
|
if err := expectPeople(ctx, image, v); err != nil {
|
|
return err
|
|
}
|
|
|
|
// N-1 again
|
|
if _, err := expect(ctx, withVolumes(older, v), false, []string{"Downgrade forbidden"}, "container", "--skip-process"); err != nil {
|
|
return fmt.Errorf("downgrade: %w", err)
|
|
}
|
|
|
|
return expectServed(ctx, image, v)
|
|
}
|
|
|
|
// UpgradeFromPreviousReleases upgrades data created by published images.
|
|
func UpgradeFromPreviousReleases(ctx context.Context, client *dagger.Client, image *dagger.Container) error {
|
|
|
|
version, err := image.EnvVariable(ctx, "OPENLDAP_VERSION")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
patch := env{"OPENLDAP_UPGRADE_MIGRATION_LEVEL": "patch"}
|
|
|
|
// 2.6.10-alpha expands "$" in hashed root passwords: use a {SSHA} hash, without "$"
|
|
hash, err := hashPasswordWith(ctx, image, "{SSHA}", env{"OPENLDAP_CTL_PASSWORD_HASH_CMD": "slappasswd -h {SSHA}"})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
passwords := env{
|
|
"OPENLDAP_BOOTSTRAP_CONFIG_ROOT_PASSWORD_HASHED": hash,
|
|
"OPENLDAP_BOOTSTRAP_DATA_ROOT_PASSWORD_HASHED": hash,
|
|
}
|
|
|
|
for _, p := range previousReleases {
|
|
|
|
previous := withEnv(client.Container().From(p.image), patch)
|
|
|
|
previousVersion, err := previous.EnvVariable(ctx, "OPENLDAP_VERSION")
|
|
if err != nil {
|
|
return fmt.Errorf("%s: %w", p.image, err)
|
|
}
|
|
|
|
v, err := bootstrap(ctx, previous, emptyVolumes(client), passwords)
|
|
if err != nil {
|
|
return fmt.Errorf("%s: %w", p.image, err)
|
|
}
|
|
|
|
preUpgrade := []string{"container", "-x", "openldap-pre-upgrade", "--skip-process"}
|
|
if p.manualBackup {
|
|
preUpgrade = []string{"container", "run", "--", "openldap-ctl", "backup", "openldap-upgrade-" + strings.Split(previousVersion, "-")[0]}
|
|
}
|
|
|
|
r, err := expect(ctx, withVolumes(previous, v), true, nil, preUpgrade...)
|
|
if err != nil {
|
|
return fmt.Errorf("%s pre-upgrade: %w", p.image, err)
|
|
}
|
|
v = readVolumes(r.container)
|
|
|
|
r, err = expect(ctx, withVolumes(withEnv(image, patch), v), true, nil, "container", "--skip-process")
|
|
if err != nil {
|
|
return fmt.Errorf("%s upgrade: %w", p.image, err)
|
|
}
|
|
v = readVolumes(r.container)
|
|
|
|
if err := expectVersion(ctx, image, v, version); err != nil {
|
|
return fmt.Errorf("%s: %w", p.image, err)
|
|
}
|
|
if err := expectPeople(ctx, image, v); err != nil {
|
|
return fmt.Errorf("%s: %w", p.image, err)
|
|
}
|
|
if err := expectServed(ctx, image, v); err != nil {
|
|
return fmt.Errorf("%s: %w", p.image, err)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
//go:embed testdata/served.sh
|
|
var servedCheck string
|
|
|
|
// clean checks openldap-ctl backup --clean only removes old backup files, starting with
|
|
// --clean-prefix if set.
|
|
func clean(ctx context.Context, image *dagger.Container, v volumes) error {
|
|
|
|
old := []string{
|
|
"openldap-cron-old-config.gz", "openldap-cron-old-data.gz",
|
|
"manual-old-config.gz", "manual-old-data.gz",
|
|
"openldap-upgrade-2.6-config.gz", "other-old-file.txt",
|
|
}
|
|
|
|
aged := withVolumes(image, v)
|
|
for _, f := range old {
|
|
aged = aged.WithExec([]string{"touch", "-d", "2000-01-01", backupDir + "/" + f})
|
|
}
|
|
|
|
ls := func(c *dagger.Container) (string, error) {
|
|
r, err := expect(ctx, c, true, nil, "ls", backupDir)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return r.output, nil
|
|
}
|
|
|
|
r, err := expect(ctx, aged, true, nil, "container", "run", "--", "openldap-ctl", "backup", "openldap-cron-new", "--clean", "1", "--clean-prefix", "openldap-cron-")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
files, err := ls(r.container)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, f := range []string{"openldap-cron-new-config.gz", "manual-old-config.gz", "openldap-upgrade-2.6-config.gz", "other-old-file.txt", "test-backup-config.gz"} {
|
|
if !strings.Contains(files, f) {
|
|
return fmt.Errorf("--clean-prefix removed %s:\n%s", f, files)
|
|
}
|
|
}
|
|
if strings.Contains(files, "openldap-cron-old-") {
|
|
return fmt.Errorf("--clean-prefix did not remove old cron backups:\n%s", files)
|
|
}
|
|
|
|
r, err = expect(ctx, r.container, true, nil, "container", "run", "--", "openldap-ctl", "backup", "manual-new", "--clean", "1")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
files, err = ls(r.container)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if strings.Contains(files, "manual-old-") || strings.Contains(files, "openldap-upgrade-2.6-") {
|
|
return fmt.Errorf("--clean did not remove old backups:\n%s", files)
|
|
}
|
|
if !strings.Contains(files, "other-old-file.txt") || !strings.Contains(files, "test-backup-config.gz") {
|
|
return fmt.Errorf("--clean removed a file that is not an old backup:\n%s", files)
|
|
}
|
|
|
|
return nil
|
|
}
|