feat: update to osixia/baseimage 2.0.0 and add integration tests

- 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>
This commit is contained in:
Bertrand Gouny
2026-10-16 20:37:20 +02:00
co-authored by Claude Opus 5.5
parent fa0eae90d5
commit 52230d6cd7
23 changed files with 1351 additions and 40 deletions
+21
View File
@@ -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:
- "*"
+71 -23
View File
@@ -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
+3 -2
View File
@@ -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
```
+8 -1
View File
@@ -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
+1 -1
View File
@@ -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"
+1 -1
View File
@@ -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` |
+19
View File
@@ -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`).
+6 -3
View File
@@ -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
+6 -4
View File
@@ -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=
+20 -3
View File
@@ -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()
+157
View File
@@ -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
+258
View File
@@ -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
+69
View File
@@ -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
}
+46
View File
@@ -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 <<LDIF
dn: ou=people,${TEST_SUFFIX}
objectClass: organizationalUnit
ou: people
dn: uid=alice,ou=people,${TEST_SUFFIX}
objectClass: inetOrgPerson
uid: alice
cn: Alice
sn: Liddell
mail: alice@example.org
userPassword: ${TEST_PASSWORD}
LDIF
ok "admin can write the database"
expect_rc 0 bind_search "${URI}" "uid=alice,ou=people,${TEST_SUFFIX}" -b "ou=people,${TEST_SUFFIX}" -s one cn mail
expect_output "^mail: alice@example.org$"
ok "authenticated users can read allowed attributes"
expect_rc 0 bind_search "${URI}" "${CONFIG_ADMIN_DN}" -b "cn=config" -s base dn
ok "config admin can read cn=config"
expect_rc 0 bind_search "${URI}" "${CONFIG_ADMIN_DN}" -b "cn=schema,cn=config" -s one cn
for schema in ${TEST_SCHEMAS}; do
expect_output "^cn: \{[0-9]+\}${schema%.ldif}$"
done
ok "schemas loaded: ${TEST_SCHEMAS}"
+224
View File
@@ -0,0 +1,224 @@
#!/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
read -ra NODES <<< "${TEST_NODES}"
PEOPLE_DN="ou=people,${TEST_SUFFIX}"
GROUPS_DN="ou=groups,${TEST_SUFFIX}"
ALICE_DN="uid=alice,${PEOPLE_DN}"
BOB_DN="uid=bob,${PEOPLE_DN}"
CAROL_DN="uid=carol,${PEOPLE_DN}"
TEAM_DN="cn=team,${GROUPS_DN}"
ldaps_uri() {
echo "ldaps://$1:6360"
}
ldap_uri() {
echo "ldap://$1:3890"
}
FIRST="$(ldaps_uri "${NODES[0]}")"
LAST="$(ldaps_uri "${NODES[-1]}")"
# nodes
for node in "${NODES[@]}"; do
eventually has_entry "$(ldaps_uri "${node}")" "${TEST_SUFFIX}"
done
ok "all nodes are up: ${NODES[*]}"
# tls
for node in "${NODES[@]}"; do
expect_rc 0 bind_search "$(ldap_uri "${node}")" "${ADMIN_DN}" -ZZ -b "${TEST_SUFFIX}" -s base dn
expect_rc 13 bind_search "$(ldap_uri "${node}")" "${ADMIN_DN}" -b "${TEST_SUFFIX}" -s base dn
done
ok "ldaps and StartTLS work, TLS is required"
expect_rc 32 ldapsearch -x -H "${FIRST}" -b "${TEST_SUFFIX}" -s base dn
ok "anonymous users cannot read the database"
# config
for node in "${NODES[@]}"; do
uri="$(ldaps_uri "${node}")"
expect_rc 0 bind_search "${uri}" "${CONFIG_ADMIN_DN}" -b "cn=module{0},cn=config" -s base olcModuleLoad
for module in ${TEST_MODULES}; do
expect_output "^olcModuleLoad: \{[0-9]+\}${module}$"
done
expect_rc 0 bind_search "${uri}" "${CONFIG_ADMIN_DN}" -b "cn=schema,cn=config" -s one cn
for schema in ${TEST_SCHEMAS}; do
expect_output "^cn: \{[0-9]+\}${schema%.ldif}$"
done
expect_rc 0 bind_search "${uri}" "${CONFIG_ADMIN_DN}" -b "olcDatabase={1}mdb,cn=config" -s one olcOverlay
for overlay in ppolicy unique refint memberof syncprov; do
expect_output "^olcOverlay: \{[0-9]+\}${overlay}$"
done
expect_output "^olcOverlay: \{4\}syncprov$"
expect_rc 0 bind_search "${uri}" "${CONFIG_ADMIN_DN}" -b "cn=config" -s base olcServerID
if [ "$(grep -c "^olcServerID: " "${OUTPUT_FILE}")" != "${#NODES[@]}" ]; then
cat "${OUTPUT_FILE}" >&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 <<LDIF
dn: ${PEOPLE_DN}
objectClass: organizationalUnit
ou: people
dn: ${GROUPS_DN}
objectClass: organizationalUnit
ou: groups
dn: ${ALICE_DN}
objectClass: inetOrgPerson
objectClass: posixAccount
uid: alice
cn: Alice
sn: Liddell
mail: alice@example.org
uidNumber: 10001
gidNumber: 10000
homeDirectory: /home/alice
userPassword: ${TEST_PASSWORD}
dn: ${BOB_DN}
objectClass: inetOrgPerson
uid: bob
cn: Bob
sn: Builder
mail: bob@example.org
userPassword: ${TEST_PASSWORD}
dn: ${CAROL_DN}
objectClass: inetOrgPerson
uid: carol
cn: Carol
sn: Danvers
mail: carol@example.org
userPassword: ${TEST_PASSWORD}
dn: ${TEAM_DN}
objectClass: groupOfNames
cn: team
member: ${ALICE_DN}
member: ${BOB_DN}
LDIF
ok "entries added on ${NODES[0]}"
# replication
for node in "${NODES[@]:1}"; do
eventually has_entry "$(ldaps_uri "${node}")" "${TEAM_DN}"
done
ok "entries replicated from ${NODES[0]} to ${NODES[*]:1}"
expect_rc 0 bind_modify "${LAST}" "${ADMIN_DN}" -a <<LDIF
dn: ou=from-last-node,${TEST_SUFFIX}
objectClass: organizationalUnit
ou: from-last-node
LDIF
for node in "${NODES[@]::${#NODES[@]}-1}"; do
eventually has_entry "$(ldaps_uri "${node}")" "ou=from-last-node,${TEST_SUFFIX}"
done
ok "entries replicated from ${NODES[-1]} to the other nodes (multi-provider)"
# password hashing
expect_rc 0 bind_search "${FIRST}" "${ADMIN_DN}" -b "${ALICE_DN}" -s base userPassword
expect_output "^userPassword:: $(printf '{ARGON2}' | base64 | cut -c1-10)"
expect_rc 0 bind_search "${FIRST}" "${ALICE_DN}" -b "${ALICE_DN}" -s base dn
ok "clear text passwords are hashed with {ARGON2} and users can bind"
# memberof
is_team_member() {
bind_search "$1" "${ADMIN_DN}" -b "$2" -s base memberOf | grep -qi "^memberOf: ${TEAM_DN}$"
}
for node in "${NODES[@]}"; do
eventually is_team_member "$(ldaps_uri "${node}")" "${ALICE_DN}"
done
ok "memberOf is maintained on all nodes"
# unique
expect_rc 19 bind_modify "${FIRST}" "${ADMIN_DN}" -a <<LDIF
dn: cn=alice-duplicate,${PEOPLE_DN}
objectClass: inetOrgPerson
uid: alice
cn: alice-duplicate
sn: Duplicate
LDIF
expect_rc 19 bind_modify "${FIRST}" "${ADMIN_DN}" -a <<LDIF
dn: uid=mail-duplicate,${PEOPLE_DN}
objectClass: inetOrgPerson
uid: mail-duplicate
cn: mail-duplicate
sn: Duplicate
mail: alice@example.org
LDIF
ok "uid and mail are unique"
# refint
expect_rc 0 ldapdelete -x -H "${FIRST}" -D "${ADMIN_DN}" -w "${TEST_PASSWORD}" "${BOB_DN}"
bob_removed_from_team() {
bind_search "$1" "${ADMIN_DN}" -b "${TEAM_DN}" -s base member > "${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}" <<LDIF
dn: ${ALICE_DN}
changetype: modify
delete: userPassword
userPassword: ${TEST_PASSWORD}
-
add: userPassword
userPassword: $(slappasswd -h '{SSHA}' -s "${NEW_PASSWORD}")
LDIF
expect_rc 0 ldapsearch -x -H "${FIRST}" -D "${ALICE_DN}" -w "${NEW_PASSWORD}" -b "${ALICE_DN}" -s base dn
ok "password policy accepts pre-hashed passwords (pwdCheckQuality: 1)"
for i in 1 2 3 4 5; do
expect_rc 49 ldapsearch -x -H "${FIRST}" -D "${CAROL_DN}" -w "wrong-password-${i}" -b "${CAROL_DN}" -s base dn
done
expect_rc 49 bind_search "${FIRST}" "${CAROL_DN}" -b "${CAROL_DN}" -s base dn
ok "password policy locks accounts after too many failures"
# readonly
READONLY_DN="cn=readonly,${TEST_SUFFIX}"
expect_rc 0 bind_search "${FIRST}" "${READONLY_DN}" -b "${ALICE_DN}" -s base cn userPassword
expect_output "^cn: Alice$"
expect_no_output "^userPassword"
expect_rc 50 bind_modify "${FIRST}" "${READONLY_DN}" <<LDIF
dn: ${ALICE_DN}
changetype: modify
replace: sn
sn: Changed
LDIF
ok "readonly user can read but not write nor read passwords"
# monitor
expect_rc 0 bind_search "${FIRST}" "cn=readonly-monitor,${TEST_SUFFIX}" -b "cn=monitor" -s base dn
expect_output "^dn: cn=Monitor$"
ok "readonly monitor user can read cn=monitor"
+92
View File
@@ -0,0 +1,92 @@
#!/bin/bash
# shellcheck disable=SC2034
# Helpers sourced by the test scripts.
ADMIN_DN="cn=admin,${TEST_SUFFIX}"
CONFIG_ADMIN_DN="cn=admin,cn=config"
OUTPUT_FILE="$(mktemp)"
ok() {
echo "ok: $*"
}
fail() {
echo "FAIL: $*" >&2
exit 1
}
# expect_rc <rc> <command...>: 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 <pattern>: 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 <pattern>: 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 <command...>: 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 <uri> <bind dn> <ldapsearch args...>: 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 <uri> <bind dn> <ldapmodify args...>: 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 <uri> <dn>: check an entry exists, as admin
has_entry() {
bind_search "$1" "${ADMIN_DN}" -b "$2" -s base dn | grep -qi "^dn: $2$"
}
# expect_fail <command...>: 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
}
+17
View File
@@ -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'
+13
View File
@@ -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"
+284
View File
@@ -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
+1 -1
View File
@@ -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
@@ -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^^}
+14
View File
@@ -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`.
+13
View File
@@ -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).