diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..21f24bb --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,21 @@ +version: 2 +updates: + - package-ecosystem: github-actions + directory: / + target-branch: develop + schedule: + interval: monthly + groups: + github-actions: + patterns: + - "*" + + - package-ecosystem: gomod + directory: /build + target-branch: develop + schedule: + interval: monthly + groups: + go-modules: + patterns: + - "*" diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index e0fd422..e87a91e 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -1,6 +1,9 @@ name: osixia on: [push, pull_request] +permissions: + contents: read + concurrency: group: ${{ github.ref }} cancel-in-progress: true @@ -10,65 +13,110 @@ jobs: name: lint runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7.0.1 - - uses: actions/setup-go@v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - go-version: '1.26.1' + persist-credentials: false + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version: '1.26.9' cache: false - name: golangci-lint build - uses: golangci/golangci-lint-action@v9.3.0 + uses: golangci/golangci-lint-action@ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a # v9.3.0 with: + version: v2.14.0 working-directory: build + test: + name: test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version: '1.26.9' + - name: Run build tool tests + working-directory: build + run: go test -race ./... + shellcheck: name: shellcheck runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7.0.1 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Run ShellCheck - uses: ludeeus/action-shellcheck@2.0.0 + uses: ludeeus/action-shellcheck@00cae500b08a931fb5698e11e79bfbd38e612a38 # 2.0.0 govulncheck: name: govulncheck runs-on: ubuntu-latest steps: - name: govulncheck build - uses: golang/govulncheck-action@v1.1.0 + uses: golang/govulncheck-action@032d45514ae346b1db93c04b0c90b841c370344f # v1.1.0 with: + go-version-input: '1.26.9' work-dir: build build: name: build + if: "!startsWith(github.ref, 'refs/tags/')" runs-on: ubuntu-latest - needs: [lint, shellcheck, govulncheck] + needs: [lint, test, shellcheck, govulncheck] + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version: '1.26.9' + - name: Vendor build go dependencies + working-directory: build + run: go mod vendor + - name: Run tests + working-directory: build + run: go run main.go provider github ../Dockerfile --github-ref "${GITHUB_REF}" ${{ vars.OSX_GITHUB_FLAGS }} + + publish: + name: publish + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') + runs-on: ubuntu-latest + needs: [lint, test, shellcheck, govulncheck] permissions: contents: write id-token: write env: DIGESTS_FILE: ${{ github.workspace }}/digests.txt steps: - - uses: actions/checkout@v7.0.1 - - uses: actions/setup-go@v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - go-version: '1.26.1' + persist-credentials: false + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version: '1.26.9' + cache: false - name: Install cosign - uses: sigstore/cosign-installer@v4.1.2 + uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2 - name: Install syft - uses: anchore/sbom-action/download-syft@v0.24.3 + uses: anchore/sbom-action/download-syft@66cbf4bc1f1c0d2edc94016e65bc221b6bb0ad6c # v0.24.3 - name: Vendor build go dependencies - run: | - cd build - go mod vendor + working-directory: build + run: go mod vendor - name: Run pipeline - run: | - cd build - go run main.go provider github ../Dockerfile --github-ref "${GITHUB_REF}" --github-token "${{ secrets.GITHUB_TOKEN }}" --changelog ../CHANGELOG.md --username "${{ secrets.REGISTRY_USERNAME }}" --password "${{ secrets.REGISTRY_PASSWORD }}" --digests-file "${DIGESTS_FILE}" ${{ vars.OSX_GITHUB_FLAGS }} - - name: Sign images and generate SBOM - if: startsWith(github.ref, 'refs/tags/') + working-directory: build env: - COSIGN_EXPERIMENTAL: '1' + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }} + REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }} + run: go run main.go provider github ../Dockerfile --github-ref "${GITHUB_REF}" --github-token "${GITHUB_TOKEN}" --changelog ../CHANGELOG.md --username "${REGISTRY_USERNAME}" --password "${REGISTRY_PASSWORD}" --digests-file "${DIGESTS_FILE}" ${{ vars.OSX_GITHUB_FLAGS }} + - name: Sign images and generate SBOM + env: + REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }} + REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }} run: | - echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login -u "${{ secrets.REGISTRY_USERNAME }}" --password-stdin + printf '%s' "${REGISTRY_PASSWORD}" | docker login -u "${REGISTRY_USERNAME}" --password-stdin while IFS= read -r ref; do cosign sign --yes --registry-referrers-mode=oci-1-1 "${ref}" syft "${ref}" -o spdx-json > sbom.spdx.json diff --git a/AGENTS.md b/AGENTS.md index 372cb61..b906322 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,7 +14,7 @@ Guidance for coding agents working on `osixia/openldap` (v2), an OpenLDAP contai | `services/openldap-upgrade/` | Version check + restore-based migration on start. | | `services/openldap-pre-upgrade/` | Pre-upgrade backup, meant to run as an **init container on the N-1 image** (see below). | | `services/openldap-cron/` | `crond` running scheduled backups (as root, separate container). | -| `build/` | Go CI/CD tool wrapping `osixia/container-baseimage/build` (dagger). Not part of the image. | +| `build/` | Go CI/CD tool wrapping [osixia/dagger-build](https://github.com/osixia/dagger-build). Not part of the image. | | `docs/examples/` | Compose examples (quickstart, replication). | `.dockerignore` excludes `*.md`, `.gitkeep`, `build/`, `docs/`: none of them reach the image. @@ -96,10 +96,11 @@ Adding a feature: create `services/openldap-bootstrap/assets/scripts/NN-feature. ## Build, test, CI - CI (`.github/workflows/go.yml`): golangci-lint + govulncheck on `build/`, ShellCheck on the repo, then the build pipeline (multi-arch build, tests, push, cosign signing + SBOM on tags). +- Integration tests live in `build/tests/` (Go scenarios + bash checks in `testdata/`), see `build/README.md`. Add a scenario or a check there when adding a feature. Tested combinations: modules except `dyngroup.so` (conflicts with `dynlist.so`) and `mqtt.so` (conflicts with `lastbind.so`), schemas except `msuser.ldif` (not loadable) and `nis.ldif`/`virtualMail.ldif` (conflict with `rfc2307bis.ldif`/`postfix-book.ldif`, tested separately). - Local: ```bash shellcheck $(git ls-files '*.sh') - cd build && go mod vendor && go run main.go job test ../Dockerfile + cd build && go mod vendor && go run main.go job test ../Dockerfile --arches amd64 docker build -t osixia/openldap:dev . docker run --rm -it osixia/openldap:dev --log-level debug # --bash, --skip-process also useful ``` diff --git a/CHANGELOG.md b/CHANGELOG.md index c262094..0e4d944 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,8 +8,12 @@ Project-specific changes only. See upstream [OpenLDAP release notes](https://www - Add the Postfix Book schema in both OpenLDAP `.schema` and `cn=config` `.ldif` formats. - Improve debug logging for file moves and schema conversion, including a check that the generated LDIF file exists. -- Update the base image to `osixia/baseimage:alpine-2.0.0-rc3`. +- Update the base image to `osixia/baseimage:alpine-2.0.0`. +- Build the CI/CD tool with `osixia/dagger-build` (replaces `osixia/container-baseimage/build`) and Go 1.26.9. +- Add integration tests: default bootstrap, all compatible modules and schemas, every bootstrap feature on a 3-node replicated cluster with TLS required, backup/restore and upgrades (simulated and from previous releases). +- Harden the CI workflow: read-only default permissions, actions pinned by commit SHA, images only published from tags, Dependabot updates. - Allow authenticated users to read `entry`, `objectClass`, `ou`, `o` and `dc` in addition to `cn`, `sn`, `givenName`, `mail` and `uid` in the default database ACL (new bootstraps only). +- Set `OPENLDAP_BOOTSTRAP_PPOLICY_DEFAULT_CHECK_QUALITY` to `1` (was `0`) so `pwdMinLength` is enforced on clear text password changes; pre-hashed passwords are still accepted (new bootstraps only). - Fail bootstrap with an explicit error when `OPENLDAP_BOOTSTRAP_SUFFIX` does not start with `dc=`, instead of creating a root entry with an empty `dc`. - Fail bootstrap with an explicit error when `OPENLDAP_BOOTSTRAP_REPLICATION_HOSTS` contains more than 9 hosts. - Check that backup files exist and are valid gzip archives before `openldap-ctl restore` stops OpenLDAP or deletes existing data. @@ -21,12 +25,15 @@ Project-specific changes only. See upstream [OpenLDAP release notes](https://www - Fix `openldap-ctl password hash` for passwords containing spaces or shell special characters. - Fix the default database ACL so authenticated users can actually read the allowed attributes. - Remove bootstrap temporary files, including on failure. +- Fix bootstrap failing with `Argument list too long` when the schemas listed in `OPENLDAP_BOOTSTRAP_SCHEMAS` exceed 128 KiB: schemas are now loaded with LDIF `include:` lines instead of being copied into an environment variable. +- Fail bootstrap with an explicit error when a schema listed in `OPENLDAP_BOOTSTRAP_SCHEMAS` does not exist. - Strip leading and trailing empty lines from every bootstrap LDIF fragment, including the first file of each prefix group. ### Documentation - Document the Postfix Book schema and its incompatibility with the `virtualMail` schema. - Improve the schema conversion example for local files and non-root execution. +- List the schemas and modules available in the image, with their incompatibilities. ## 2.6.15-alpha diff --git a/Dockerfile b/Dockerfile index b4c8c84..07b5584 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -ARG BASE_IMAGE="osixia/baseimage:alpine-2.0.0-rc3" +ARG BASE_IMAGE="osixia/baseimage:alpine-2.0.0" FROM ${BASE_IMAGE} ARG IMAGE="osixia/openldap:develop" diff --git a/README.md b/README.md index 4f7c1a8..d929af2 100644 --- a/README.md +++ b/README.md @@ -385,7 +385,7 @@ docker run --rm --user $UID -v $(pwd)/schema:/data \ | `OPENLDAP_BOOTSTRAP_PPOLICY_GROUP_DN` | DN of the policy container created in the main database | `ou=policies,${OPENLDAP_BOOTSTRAP_SUFFIX}` | | `OPENLDAP_BOOTSTRAP_PPOLICY_DEFAULT_POLICY_DN` | DN of the default password policy | `cn=default,${OPENLDAP_BOOTSTRAP_PPOLICY_GROUP_DN}` | | `OPENLDAP_BOOTSTRAP_PPOLICY_DEFAULT_MIN_LENGTH` | Value for `pwdMinLength` | `12` | -| `OPENLDAP_BOOTSTRAP_PPOLICY_DEFAULT_CHECK_QUALITY` | Value for `pwdCheckQuality` | `0` | +| `OPENLDAP_BOOTSTRAP_PPOLICY_DEFAULT_CHECK_QUALITY` | Value for `pwdCheckQuality` | `1` | | `OPENLDAP_BOOTSTRAP_PPOLICY_DEFAULT_IN_HISTORY` | Value for `pwdInHistory` | `12` | | `OPENLDAP_BOOTSTRAP_PPOLICY_DEFAULT_ALLOW_USER_CHANGE` | Value for `pwdAllowUserChange` | `true` | | `OPENLDAP_BOOTSTRAP_PPOLICY_DEFAULT_SAFE_MODIFY` | Value for `pwdSafeModify` | `true` | diff --git a/build/README.md b/build/README.md index d7fc2a3..4ff0899 100644 --- a/build/README.md +++ b/build/README.md @@ -1,5 +1,7 @@ # CI/CD tool +Built with [osixia/dagger-build](https://github.com/osixia/dagger-build). + ## Get Help ``` go run main.go --help @@ -10,3 +12,20 @@ go run main.go --help go mod vendor go run main.go job test ../Dockerfile ``` + +Limit a local run with `--arches amd64`. + +After the shared version check, `tests/` runs integration scenarios on the image built for +the engine platform: + +- default bootstrap; +- alternative schemas (`nis`, `virtualMail`); +- all features with 3 replicated nodes: all compatible modules and schemas, TLS required, + readonly users, monitor, ppolicy, unique, refint, memberof and multi-provider replication; +- `openldap-ctl` backup and restore; +- upgrade: pre-upgrade backup on a simulated N-1 image, restore on N, downgrade refused; +- upgrade from previous published releases (`previousReleases` in `tests/upgrade.go`). + +Scenarios are written in Go and run their LDAP checks with the scripts in +`tests/testdata/`. Replication nodes run with root capabilities in derived test containers, +to get their hostname (see `tests/testdata/node.sh`). diff --git a/build/go.mod b/build/go.mod index e04fbe1..7c125dd 100644 --- a/build/go.mod +++ b/build/go.mod @@ -1,15 +1,18 @@ module github.com/osixia/container-openldap/build -go 1.25.0 +go 1.26.9 -require github.com/osixia/container-baseimage/build v0.0.0-20261007183720-dc9d7714269f +require ( + dagger.io/dagger v0.21.10 + github.com/osixia/dagger-build v0.0.0-20261015194426-55e8bdd25dbb +) require ( - dagger.io/dagger v0.20.6 // indirect github.com/99designs/gqlgen v0.17.90 // indirect github.com/Khan/genqlient v0.8.1 // indirect github.com/adrg/xdg v0.5.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/dagger/querybuilder v0.0.0-20260402040506-574a5e81cb59 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/google/go-github/v69 v69.2.0 // indirect diff --git a/build/go.sum b/build/go.sum index 4627301..eb2d6cb 100644 --- a/build/go.sum +++ b/build/go.sum @@ -1,5 +1,5 @@ -dagger.io/dagger v0.20.6 h1:Uac8tdqieoq+psVe5f+rhNk1sk2W8YK5t4OzpGyHuTI= -dagger.io/dagger v0.20.6/go.mod h1:ZXg8+pQZaZUC8rAw4V/gPP8aKvKARIJZ+pfcV+RC1es= +dagger.io/dagger v0.21.10 h1:TNg1nvHZWOIt5JkTZjfF4SCGW/Y31y1qeUq6vKQ/NXU= +dagger.io/dagger v0.21.10/go.mod h1:8hztpM9rKNjSmOa2nzd8ot6aw1RZFDWCawMfc5V9tXA= github.com/99designs/gqlgen v0.17.90 h1:wSv6blm/PoplU6QoNw83EcQpNtC0HX3/+44vITJOzpk= github.com/99designs/gqlgen v0.17.90/go.mod h1:GqYrEwYsqCG8VaOsq2kJUCUKwAE1T+u2i+Nj7NtXiVI= github.com/Khan/genqlient v0.8.1 h1:wtOCc8N9rNynRLXN3k3CnfzheCUNKBcvXmVv5zt6WCs= @@ -13,6 +13,8 @@ github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/dagger/querybuilder v0.0.0-20260402040506-574a5e81cb59 h1:g6vfdGRyz6fAjfHz5FyYPZgHy8qcQ31fHrBl1iCOzxw= +github.com/dagger/querybuilder v0.0.0-20260402040506-574a5e81cb59/go.mod h1:jsdUJeYzcbyK1j/EqMGPrQgNYxl/Zfg06vvM9C/xXxs= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= @@ -35,8 +37,8 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= -github.com/osixia/container-baseimage/build v0.0.0-20261007183720-dc9d7714269f h1:nxKAccHKyXoa5Sj15DVK+yEiuhN006YulVNAqP35L20= -github.com/osixia/container-baseimage/build v0.0.0-20261007183720-dc9d7714269f/go.mod h1:gLTe03yDFd9kIL22dHdPad6G0f/u/NI+VB4+3cOHQbY= +github.com/osixia/dagger-build v0.0.0-20261015194426-55e8bdd25dbb h1:V8FLn00GbtrHvOTII63DaCSZDoi2zQvmIztr20FmOw4= +github.com/osixia/dagger-build v0.0.0-20261015194426-55e8bdd25dbb/go.mod h1:gxxc7tVjlUVYPeBpDBdWs1MFHuP48AR6zes7F1NIMKI= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= diff --git a/build/main.go b/build/main.go index a8a0f68..75d022d 100644 --- a/build/main.go +++ b/build/main.go @@ -4,8 +4,12 @@ import ( "context" "os" - "github.com/osixia/container-baseimage/build/cmd" - "github.com/osixia/container-baseimage/build/config" + "dagger.io/dagger" + + "github.com/osixia/container-openldap/build/tests" + "github.com/osixia/dagger-build/cmd" + "github.com/osixia/dagger-build/config" + "github.com/osixia/dagger-build/job" ) func main() { @@ -13,7 +17,7 @@ func main() { // image var OpenLDAPImage = &config.Image{ - BaseImage: "osixia/baseimage:alpine-2.0.0-rc3", + BaseImage: "osixia/baseimage:alpine-2.0.0", Distribution: config.Alpine, Name: "osixia/openldap", @@ -42,6 +46,19 @@ func main() { Project: "container-openldap", } + // tests + + job.Test = func(ctx context.Context, client *dagger.Client, options any) ([]*job.BuildResult, error) { + builds, err := job.DefaultTest(ctx, client, options) + if err != nil { + return nil, err + } + if err := tests.Run(ctx, client, builds); err != nil { + return nil, err + } + return builds, nil + } + // execute cmd mainCtx := context.Background() diff --git a/build/tests/bootstrap.go b/build/tests/bootstrap.go new file mode 100644 index 0000000..3ed010a --- /dev/null +++ b/build/tests/bootstrap.go @@ -0,0 +1,157 @@ +package tests + +import ( + "context" + _ "embed" + "fmt" + "strings" + + "dagger.io/dagger" +) + +const defaultSchemas = "core.ldif cosine.ldif inetorgperson.ldif rfc2307bis.ldif" + +// allSchemas can be loaded together. Excluded: +// - nis.ldif and virtualMail.ldif conflict with rfc2307bis.ldif and postfix-book.ldif, +// they are tested by AlternativeSchemas; +// - msuser.ldif conflicts with inetorgperson.ldif and contains duplicated attribute types +// (OpenLDAP ships it "for informational purposes only"). +var allSchemas = []string{ + "core.ldif", "cosine.ldif", "inetorgperson.ldif", "rfc2307bis.ldif", + // OpenLDAP + "collective.ldif", "corba.ldif", "dsee.ldif", "duaconf.ldif", "dyngroup.ldif", "java.ldif", + "misc.ldif", "namedobject.ldif", "openldap.ldif", "pmi.ldif", + // osixia/openldap + "eduperson.ldif", "kerberos.ldif", "openssh-lpk.ldif", "postfix-book.ldif", "samba.ldif", + "sudo.ldif", "totp.ldif", +} + +// allModules can be loaded together. Excluded: dyngroup.so conflicts with dynlist.so and +// mqtt.so conflicts with lastbind.so. +var allModules = []string{ + "back_mdb.so", "argon2.so", "ppolicy.so", "unique.so", "refint.so", "memberof.so", "syncprov.so", + "accesslog.so", "auditlog.so", "autoca.so", "back_asyncmeta.so", "back_dnssrv.so", "back_ldap.so", + "back_meta.so", "back_null.so", "back_passwd.so", "back_relay.so", "back_sock.so", "back_sql.so", + "collect.so", "constraint.so", "dds.so", "deref.so", "dynlist.so", "homedir.so", "lastbind.so", + "lloadd.so", "nestgroup.so", "otp.so", "pcache.so", "pw-pbkdf2.so", "pw-sha2.so", "remoteauth.so", + "retcode.so", "rwm.so", "seqmod.so", "sssvlv.so", "translucent.so", "valsort.so", +} + +// rootPasswords sets the config and database root passwords to the test password. +func rootPasswords(ctx context.Context, image *dagger.Container) (env, error) { + + hash, err := hashPassword(ctx, image) + if err != nil { + return nil, err + } + + return env{ + "OPENLDAP_BOOTSTRAP_CONFIG_ROOT_PASSWORD_HASHED": hash, + "OPENLDAP_BOOTSTRAP_DATA_ROOT_PASSWORD_HASHED": hash, + }, nil +} + +// Default starts the image with its default configuration. +func Default(ctx context.Context, client *dagger.Client, image *dagger.Container) error { + + passwords, err := rootPasswords(ctx, image) + if err != nil { + return err + } + + const node = "ldap" + + service, err := startNode(ctx, image, node, passwords, nil, nil) + if err != nil { + return err + } + defer stopNodes(ctx, []*dagger.Service{service}) + + return check(ctx, image, nil, env{"TEST_NODES": node, "TEST_SCHEMAS": defaultSchemas}, defaultCheck) +} + +// AlternativeSchemas bootstraps with the schemas that conflict with the default ones. +func AlternativeSchemas(ctx context.Context, client *dagger.Client, image *dagger.Container) error { + + schemas := "core.ldif cosine.ldif inetorgperson.ldif nis.ldif virtualMail.ldif" + + r, err := expect(ctx, withEnv(image, env{"OPENLDAP_BOOTSTRAP_SCHEMAS": schemas}), true, + []string{"config file testing succeeded"}, "container", "--skip-process") + if err != nil { + return err + } + + _, err = expect(ctx, r.container, true, []string{"cn: {3}nis", "cn: {4}virtualMail"}, + "container", "run", "--", "slapcat", "-n", "0", "-a", "(objectClass=olcSchemaConfig)") + return err +} + +// AllFeatures enables every bootstrap feature, all compatible modules and schemas, on a +// 3 nodes multi-provider cluster with TLS required. +func AllFeatures(ctx context.Context, client *dagger.Client, image *dagger.Container) error { + + hash, err := hashPassword(ctx, image) + if err != nil { + return err + } + + nodes := []string{"ldap1", "ldap2", "ldap3"} + certs := certificates(client, nodes) + + hosts := make([]string, 0, len(nodes)) + for _, n := range nodes { + hosts = append(hosts, fmt.Sprintf("ldap://%s:3890", n)) + } + + features := env{ + "OPENLDAP_BOOTSTRAP_MODULES": strings.Join(allModules, " "), + "OPENLDAP_BOOTSTRAP_SCHEMAS": strings.Join(allSchemas, " "), + + "OPENLDAP_BOOTSTRAP_CONFIG_ROOT_PASSWORD_HASHED": hash, + "OPENLDAP_BOOTSTRAP_DATA_ROOT_PASSWORD_HASHED": hash, + + "OPENLDAP_BOOTSTRAP_DATA_READONLY": "true", + "OPENLDAP_BOOTSTRAP_DATA_READONLY_PASSWORD_HASHED": hash, + + "OPENLDAP_BOOTSTRAP_MONITOR_ENABLED": "true", + "OPENLDAP_BOOTSTRAP_MONITOR_READONLY": "true", + "OPENLDAP_BOOTSTRAP_MONITOR_READONLY_PASSWORD_HASHED": hash, + + "OPENLDAP_BOOTSTRAP_TLS": "true", + "OPENLDAP_BOOTSTRAP_TLS_REQUIRED": "true", + + "OPENLDAP_BOOTSTRAP_REFINT": "true", + "OPENLDAP_BOOTSTRAP_PPOLICY": "true", + "OPENLDAP_BOOTSTRAP_UNIQUE": "true", + "OPENLDAP_BOOTSTRAP_MEMBEROF": "true", + + "OPENLDAP_BOOTSTRAP_REPLICATION": "true", + "OPENLDAP_BOOTSTRAP_REPLICATION_HOSTS": strings.Join(hosts, " "), + "OPENLDAP_BOOTSTRAP_REPLICATION_DATA_READONLY_PASSWORD": password, + } + + services := make([]*dagger.Service, 0, len(nodes)) + defer func() { stopNodes(ctx, services) }() + + for _, n := range nodes { + urls := env{"OPENLDAP_URLS": fmt.Sprintf("ldap://%s:3890 ldaps://%s:6360 ldapi:///", n, n)} + + service, err := startNode(ctx, image, n, merge(features, urls), certs, nil) + if err != nil { + return err + } + services = append(services, service) + } + + return check(ctx, image, certs, env{ + "TEST_NODES": strings.Join(nodes, " "), + "TEST_MODULES": strings.Join(allModules, " "), + "TEST_SCHEMAS": strings.Join(allSchemas, " "), + }, fullCheck) +} + +//go:embed testdata/default.sh +var defaultCheck string + +//go:embed testdata/full.sh +var fullCheck string diff --git a/build/tests/helpers.go b/build/tests/helpers.go new file mode 100644 index 0000000..909f9bc --- /dev/null +++ b/build/tests/helpers.go @@ -0,0 +1,258 @@ +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 diff --git a/build/tests/test.go b/build/tests/test.go new file mode 100644 index 0000000..c54a6dc --- /dev/null +++ b/build/tests/test.go @@ -0,0 +1,69 @@ +package tests + +import ( + "context" + "fmt" + "log" + "time" + + "dagger.io/dagger" + + "github.com/osixia/dagger-build/job" +) + +// scenario is an integration test run against a built image. +type scenario struct { + name string + run func(ctx context.Context, client *dagger.Client, image *dagger.Container) error +} + +var scenarios = []scenario{ + {"default bootstrap", Default}, + {"alternative schemas", AlternativeSchemas}, + {"all features with 3 replicated nodes", AllFeatures}, + {"backup and restore", BackupRestore}, + {"upgrade", Upgrade}, + {"upgrade from previous releases", UpgradeFromPreviousReleases}, +} + +// Run checks OpenLDAP behavior after the reusable job's version check, only on the engine +// platform: emulated images are slow and fail as Dagger services. +func Run(ctx context.Context, client *dagger.Client, builds []*job.BuildResult) error { + + enginePlatform, err := client.DefaultPlatform(ctx) + if err != nil { + return fmt.Errorf("engine platform: %w", err) + } + + tested := 0 + for _, build := range builds { + for _, image := range build.Containers { + + platform, err := image.Platform(ctx) + if err != nil { + return fmt.Errorf("image platform: %w", err) + } + if platform != enginePlatform { + continue + } + tested++ + + for _, s := range scenarios { + log.Printf("scenario: %s ...", s.name) + sctx, cancel := context.WithTimeout(ctx, 10*time.Minute) + err := s.run(sctx, client, image) + cancel() + if err != nil { + return fmt.Errorf("%s: %w", s.name, err) + } + log.Printf("scenario: %s passed", s.name) + } + } + } + + if tested == 0 && len(builds) > 0 { + return fmt.Errorf("no image built for the engine platform %v", enginePlatform) + } + + return nil +} diff --git a/build/tests/testdata/default.sh b/build/tests/testdata/default.sh new file mode 100644 index 0000000..256559e --- /dev/null +++ b/build/tests/testdata/default.sh @@ -0,0 +1,46 @@ +#!/bin/bash + +set -euo pipefail + +# if container log level is trace: +# print commands and their arguments as they are executed +container log level eq trace && set -x + +# shellcheck disable=SC1091 +source /tmp/openldap-test-lib.sh + +URI="ldap://${TEST_NODES}:3890" + +eventually has_entry "${URI}" "${TEST_SUFFIX}" +ok "admin can read the database" + +expect_rc 32 ldapsearch -x -H "${URI}" -b "${TEST_SUFFIX}" -s base dn +ok "anonymous users cannot read the database" + +expect_rc 0 bind_modify "${URI}" "${ADMIN_DN}" -a <&2 + fail "expected ${#NODES[@]} server IDs on ${node}" + fi +done +ok "config: modules, schemas, overlays and server IDs loaded on all nodes" + +# data +expect_rc 0 bind_modify "${FIRST}" "${ADMIN_DN}" -a < "${OUTPUT_FILE}.member" + grep -qi "^member: ${ALICE_DN}$" "${OUTPUT_FILE}.member" && ! grep -qi "^member: ${BOB_DN}$" "${OUTPUT_FILE}.member" +} + +for node in "${NODES[@]}"; do + eventually bob_removed_from_team "$(ldaps_uri "${node}")" +done +ok "deleted users are removed from groups on all nodes" + +# ppolicy +expect_fail ldappasswd -x -H "${FIRST}" -D "${ALICE_DN}" -w "${TEST_PASSWORD}" -a "${TEST_PASSWORD}" -s "short" +expect_output "quality|too short|Constraint violation" +ok "password policy rejects short clear text passwords" + +NEW_PASSWORD="Another-passw0rd-2026" +expect_rc 0 bind_modify "${FIRST}" "${ALICE_DN}" <&2 + exit 1 +} + +# expect_rc : run command and check its exit code, output is kept in +# OUTPUT_FILE +expect_rc() { + local want="$1" + shift + + local rc=0 + "$@" > "${OUTPUT_FILE}" 2>&1 || rc=$? + + if [ "${rc}" != "${want}" ]; then + cat "${OUTPUT_FILE}" >&2 + fail "expected exit code ${want}, got ${rc}: $*" + fi +} + +# expect_output : check the last command output matches an extended regex +expect_output() { + if ! grep -qE "$1" "${OUTPUT_FILE}"; then + cat "${OUTPUT_FILE}" >&2 + fail "output does not match '$1'" + fi +} + +# expect_no_output : check the last command output does not match an extended regex +expect_no_output() { + if grep -qE "$1" "${OUTPUT_FILE}"; then + cat "${OUTPUT_FILE}" >&2 + fail "output matches '$1'" + fi +} + +# eventually : retry command for up to 60 seconds +eventually() { + local i + for i in $(seq 1 60); do + if "$@" > "${OUTPUT_FILE}" 2>&1; then + return 0 + fi + sleep 1 + done + + cat "${OUTPUT_FILE}" >&2 + fail "timeout after ${i} attempts: $*" +} + +# bind_search : simple bind with the test password +bind_search() { + local uri="$1" + local dn="$2" + shift 2 + ldapsearch -LLL -o ldif-wrap=no -x -H "${uri}" -D "${dn}" -w "${TEST_PASSWORD}" "$@" +} + +# bind_modify : simple bind with the test password, LDIF on stdin +bind_modify() { + local uri="$1" + local dn="$2" + shift 2 + ldapmodify -x -H "${uri}" -D "${dn}" -w "${TEST_PASSWORD}" "$@" +} + +# has_entry : check an entry exists, as admin +has_entry() { + bind_search "$1" "${ADMIN_DN}" -b "$2" -s base dn | grep -qi "^dn: $2$" +} + +# expect_fail : run command and check it fails, output is kept in OUTPUT_FILE +expect_fail() { + if "$@" > "${OUTPUT_FILE}" 2>&1; then + cat "${OUTPUT_FILE}" >&2 + fail "expected failure: $*" + fi +} diff --git a/build/tests/testdata/node.sh b/build/tests/testdata/node.sh new file mode 100644 index 0000000..d8e0f5a --- /dev/null +++ b/build/tests/testdata/node.sh @@ -0,0 +1,17 @@ +#!/bin/bash + +set -euo pipefail + +# Test fixture, run as root in a derived container with root capabilities: +# give the node its FQDN and make it resolve locally (0.0.0.0, so slapd listens on all +# interfaces), then run the image entrypoint as the ldap user. + +fqdn="$1" + +hostname "${fqdn}" + +cp /etc/hosts /tmp/openldap-test-hosts +echo "0.0.0.0 ${fqdn}" >> /tmp/openldap-test-hosts +mount --bind /tmp/openldap-test-hosts /etc/hosts + +exec su -p -s /bin/sh ldap -c 'exec /usr/sbin/container' diff --git a/build/tests/testdata/served.sh b/build/tests/testdata/served.sh new file mode 100644 index 0000000..f0aac32 --- /dev/null +++ b/build/tests/testdata/served.sh @@ -0,0 +1,13 @@ +#!/bin/bash + +set -euo pipefail + +# if container log level is trace: +# print commands and their arguments as they are executed +container log level eq trace && set -x + +# shellcheck disable=SC1091 +source /tmp/openldap-test-lib.sh + +eventually has_entry "ldap://${TEST_NODES}:3890" "uid=alice,ou=people,${TEST_SUFFIX}" +ok "upgraded data is served" diff --git a/build/tests/upgrade.go b/build/tests/upgrade.go new file mode 100644 index 0000000..eab8b8a --- /dev/null +++ b/build/tests/upgrade.go @@ -0,0 +1,284 @@ +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 + } + + // 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) + } + + 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 diff --git a/environment/.env.bootstrap b/environment/.env.bootstrap index f80fed2..c8f2f00 100644 --- a/environment/.env.bootstrap +++ b/environment/.env.bootstrap @@ -47,7 +47,7 @@ OPENLDAP_BOOTSTRAP_PPOLICY_CHECK_MODULE="" OPENLDAP_BOOTSTRAP_PPOLICY_GROUP_DN="ou=policies,${OPENLDAP_BOOTSTRAP_SUFFIX}" OPENLDAP_BOOTSTRAP_PPOLICY_DEFAULT_POLICY_DN="cn=default,${OPENLDAP_BOOTSTRAP_PPOLICY_GROUP_DN}" OPENLDAP_BOOTSTRAP_PPOLICY_DEFAULT_MIN_LENGTH=12 -OPENLDAP_BOOTSTRAP_PPOLICY_DEFAULT_CHECK_QUALITY=0 +OPENLDAP_BOOTSTRAP_PPOLICY_DEFAULT_CHECK_QUALITY=1 OPENLDAP_BOOTSTRAP_PPOLICY_DEFAULT_IN_HISTORY=12 OPENLDAP_BOOTSTRAP_PPOLICY_DEFAULT_ALLOW_USER_CHANGE=true OPENLDAP_BOOTSTRAP_PPOLICY_DEFAULT_SAFE_MODIFY=true diff --git a/services/openldap-bootstrap/assets/scripts/00-base.sh b/services/openldap-bootstrap/assets/scripts/00-base.sh index ef045c8..097a244 100755 --- a/services/openldap-bootstrap/assets/scripts/00-base.sh +++ b/services/openldap-bootstrap/assets/scripts/00-base.sh @@ -29,8 +29,14 @@ if [ "${TYPE}" = "env" ]; then OPENLDAP_BOOTSTRAP_MODULES_LDIF+="olcModuleload: ${module}"$'\n' done + # schemas are included by path instead of by content: large schemas (e.g. msuser.ldif) + # exceed the kernel size limit of a single environment variable and make every + # following command fail with "Argument list too long" for schema in ${OPENLDAP_BOOTSTRAP_SCHEMAS}; do - OPENLDAP_BOOTSTRAP_SCHEMAS_LDIF+="$(cat "${OPENLDAP_SCHEMAS_DIR}/${schema}")"$'\n\n' + if [ ! -f "${OPENLDAP_SCHEMAS_DIR}/${schema}" ]; then + container log fatal "Schema ${schema} not found in ${OPENLDAP_SCHEMAS_DIR}" + fi + OPENLDAP_BOOTSTRAP_SCHEMAS_LDIF+="include: file://${OPENLDAP_SCHEMAS_DIR}/${schema}"$'\n' done OPENLDAP_BOOTSTRAP_MONITOR_ENABLED=${OPENLDAP_BOOTSTRAP_MONITOR_ENABLED^^} diff --git a/services/openldap/assets/module/README.md b/services/openldap/assets/module/README.md new file mode 100644 index 0000000..ea3a718 --- /dev/null +++ b/services/openldap/assets/module/README.md @@ -0,0 +1,14 @@ +# OpenLDAP Modules + +This directory contains optional modules that can be loaded during bootstrap with `OPENLDAP_BOOTSTRAP_MODULES`. + +## Available modules + +All OpenLDAP backends and overlays from the Alpine packages are in `/usr/lib/openldap`: + +`accesslog.so`, `argon2.so`, `auditlog.so`, `autoca.so`, `back_asyncmeta.so`, `back_dnssrv.so`, `back_ldap.so`, `back_mdb.so`, `back_meta.so`, `back_null.so`, `back_passwd.so`, `back_relay.so`, `back_sock.so`, `back_sql.so`, `collect.so`, `constraint.so`, `dds.so`, `deref.so`, `dyngroup.so`, `dynlist.so`, `homedir.so`, `lastbind.so`, `lloadd.so`, `memberof.so`, `mqtt.so`, `nestgroup.so`, `otp.so`, `pcache.so`, `ppolicy.so`, `pw-pbkdf2.so`, `pw-sha2.so`, `refint.so`, `remoteauth.so`, `retcode.so`, `rwm.so`, `seqmod.so`, `sssvlv.so`, `syncprov.so`, `translucent.so`, `unique.so`, `valsort.so` + +Incompatibilities, all other modules can be loaded together: + +- `dyngroup.so` and `dynlist.so`; +- `lastbind.so` and `mqtt.so`. diff --git a/services/openldap/assets/schema/README.md b/services/openldap/assets/schema/README.md index 44832e4..9305b4d 100644 --- a/services/openldap/assets/schema/README.md +++ b/services/openldap/assets/schema/README.md @@ -2,6 +2,19 @@ This directory contains optional schemas that can be loaded during bootstrap with `OPENLDAP_BOOTSTRAP_SCHEMAS`. +## Available schemas + +All schemas are copied to `/etc/openldap/schema`: + +- OpenLDAP (Alpine package): `collective.ldif`, `corba.ldif`, `core.ldif`, `cosine.ldif`, `dsee.ldif`, `duaconf.ldif`, `dyngroup.ldif`, `inetorgperson.ldif`, `java.ldif`, `misc.ldif`, `msuser.ldif`, `namedobject.ldif`, `nis.ldif`, `openldap.ldif`, `pmi.ldif` +- this directory: `eduperson.ldif`, `kerberos.ldif`, `openssh-lpk.ldif`, `postfix-book.ldif`, `rfc2307bis.ldif`, `samba.ldif`, `sudo.ldif`, `totp.ldif`, `virtualMail.ldif` + +Incompatibilities, all other schemas can be loaded together: + +- `nis.ldif` and `rfc2307bis.ldif` (default); +- `postfix-book.ldif` and `virtualMail.ldif`; +- `msuser.ldif` is provided by OpenLDAP for reference only and cannot be loaded. + ## eduperson Academic identity schema used in higher-education federations (students, staff, affiliations, scoped identifiers).