diff --git a/scripts/images/rds-postgres/rds-datadir b/scripts/images/common/rds-datadir similarity index 88% rename from scripts/images/rds-postgres/rds-datadir rename to scripts/images/common/rds-datadir index b3d120475..2cbab6ec8 100644 --- a/scripts/images/rds-postgres/rds-datadir +++ b/scripts/images/common/rds-datadir @@ -1,10 +1,14 @@ #!/bin/sh # rds-datadir — select, authorize, prepare, and mount the exact data volume -# assigned by the control plane before rds-init touches the PostgreSQL datadir. +# assigned by the control plane before rds-init touches the engine's datadir. +# +# Shared by every RDS image preset. The mount point and the OS user that owns it +# are the one engine-specific pair here, and both are supplied by the preset's +# own service rather than defaulted, so no image can inherit another's layout. set -eu -DATA_MOUNT="${RDS_DATA_MOUNT:-/var/lib/postgresql}" -PG_USER="${RDS_PG_USER:-postgres}" +DATA_MOUNT="${RDS_DATA_MOUNT:-}" +ENGINE_USER="${RDS_ENGINE_USER:-}" SYS_BLOCK="${RDS_SYS_BLOCK:-/sys/block}" DEV_DIR="${RDS_DEV_DIR:-/dev}" MOUNTS_FILE="${RDS_MOUNTS_FILE:-/proc/mounts}" @@ -103,6 +107,14 @@ consume_format_authorization() { } main() { + # An unset layout is a broken image, not something to guess at: mounting the + # customer's volume where the engine is not looking would read as an empty + # datadir and bootstrap a second, empty database beside their data. + [ -n "${DATA_MOUNT}" ] || + fail "RDS_DATA_MOUNT is not set; refusing to guess where this engine's datadir belongs" + [ -n "${ENGINE_USER}" ] || + fail "RDS_ENGINE_USER is not set; refusing to guess which user owns this engine's datadir" + load_handoff command -v blkid >/dev/null 2>&1 || @@ -184,8 +196,8 @@ main() { mount -t "${_fstype}" "${_dev}" "${DATA_MOUNT}" || fail "could not mount ${_dev} at ${DATA_MOUNT}" - chown "${PG_USER}:${PG_USER}" "${DATA_MOUNT}" || - fail "could not set ${PG_USER} ownership on ${DATA_MOUNT}" + chown "${ENGINE_USER}:${ENGINE_USER}" "${DATA_MOUNT}" || + fail "could not set ${ENGINE_USER} ownership on ${DATA_MOUNT}" chmod 0750 "${DATA_MOUNT}" log "${DATA_MOUNT} ready on ${_dev}" diff --git a/scripts/images/common/rds-datadir_initd_test.sh b/scripts/images/common/rds-datadir_initd_test.sh new file mode 100755 index 000000000..14acf02aa --- /dev/null +++ b/scripts/images/common/rds-datadir_initd_test.sh @@ -0,0 +1,63 @@ +#!/bin/sh +set -eu + +# rds-datadir is shared by every engine preset and refuses to guess a layout, so +# each preset's service is the only statement of where its volume mounts and who +# owns it. A preset that stopped exporting them would fail closed at boot; a +# preset that exported another engine's would mount the customer's volume where +# its engine is not looking, which reads as an empty datadir. + +SCRIPT_DIR=$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd) +IMAGES=$(CDPATH='' cd -- "${SCRIPT_DIR}/.." && pwd) +WORK=$(mktemp -d) +trap 'rm -rf "${WORK}"' EXIT + +FAILS=0 +fail() { echo "FAIL: $*" >&2; FAILS=$((FAILS + 1)); } +pass() { echo "ok: $*"; } + +# Sourced rather than executed: start_pre is what OpenRC runs, and the values it +# exports are what reach the mount script. +layout() { + ( + # shellcheck disable=SC1090 + . "${IMAGES}/$1/rds-datadir.initd" + # Read by the sourced start_pre, which shellcheck cannot see. + export AGENT_ENV="$2" + start_pre + printf '%s %s\n' "${RDS_DATA_MOUNT:-}" "${RDS_ENGINE_USER:-}" + ) +} + +check_preset() { + _preset=$1 + _mount=$2 + _user=$3 + + _got=$(layout "${_preset}" "${WORK}/absent.env") + if [ "${_got}" = "${_mount} ${_user}" ]; then + pass "${_preset}: exports ${_mount} owned by ${_user}" + else + fail "${_preset}: exports '${_got}', want '${_mount} ${_user}'" + fi + + # The control plane can move the mount point without a rebuild, so a + # delivered value has to win over the image's own default. + printf "RDS_DATA_MOUNT=%s\n" "${WORK}/delivered" > "${WORK}/agent.env" + _got=$(layout "${_preset}" "${WORK}/agent.env") + if [ "${_got}" = "${WORK}/delivered ${_user}" ]; then + pass "${_preset}: a delivered mount point wins" + else + fail "${_preset}: delivered mount point ignored, got '${_got}'" + fi +} + +check_preset rds-postgres /var/lib/postgresql postgres +check_preset rds-mariadb /var/lib/mysql mysql + +if [ "${FAILS}" -eq 0 ]; then + echo "rds-datadir.initd: all presets state their layout" + exit 0 +fi +echo "FAILED: ${FAILS} case(s)" +exit 1 diff --git a/scripts/images/rds-postgres/rds-datadir_test.sh b/scripts/images/common/rds-datadir_test.sh similarity index 91% rename from scripts/images/rds-postgres/rds-datadir_test.sh rename to scripts/images/common/rds-datadir_test.sh index 98ad08d2b..0463ba4fc 100644 --- a/scripts/images/rds-postgres/rds-datadir_test.sh +++ b/scripts/images/common/rds-datadir_test.sh @@ -56,6 +56,7 @@ fail() { echo "FAIL: $*"; FAILS=$((FAILS + 1)); } pass() { echo "ok: $*"; } DATA_MOUNT="${WORK}/data" +ENGINE_USER="dbengine" SYS_BLOCK="${WORK}/sys/block" DEV_DIR="${WORK}/dev" MOUNTS="${WORK}/mounts" @@ -91,6 +92,8 @@ EOF } reset_state() { + DATA_MOUNT="${WORK}/data" + ENGINE_USER="dbengine" rm -rf "${WORK}/sys" "${DEV_DIR}" "${DATA_MOUNT}" "$(dirname "${HANDOFF}")" mkdir -p "${SYS_BLOCK}" "${DEV_DIR}" "${DATA_MOUNT}" printf '/dev/vda1 / ext4 rw,relatime 0 0\n' > "${MOUNTS}" @@ -104,6 +107,7 @@ reset_state() { run() { env RDS_DATA_MOUNT="${DATA_MOUNT}" \ + RDS_ENGINE_USER="${ENGINE_USER}" \ RDS_SYS_BLOCK="${SYS_BLOCK}" \ RDS_DEV_DIR="${DEV_DIR}" \ RDS_MOUNTS_FILE="${MOUNTS}" \ @@ -152,6 +156,9 @@ if run_ok "authorized blank exact volume"; then grep -q "mkfs.ext4 .*${DEV_DIR}/vdc" "${MKFS_CALLS}" && pass "authorized: exact disk formatted" || fail "authorized: exact disk not formatted" grep -q -- '-m 0' "${MKFS_CALLS}" && pass "authorized: no reserved blocks" || fail "authorized: reserved blocks left on" grep -q "mount -t ext4 ${DEV_DIR}/vdc ${DATA_MOUNT}" "${MOUNT_CALLS}" && pass "authorized: mounted" || fail "authorized: not mounted" + # The engine reads its own datadir, so the mount is handed to the preset's + # user rather than to whichever engine this script was first written for. + grep -q "chown ${ENGINE_USER}:${ENGINE_USER} ${DATA_MOUNT}" "${CHOWN_CALLS}" && pass "authorized: handed to the engine user" || fail "authorized: not handed to ${ENGINE_USER}" grep -q '^RDS_MASTER_PASSWORD=' "${HANDOFF}" && pass "authorized: bootstrap material preserved" || fail "authorized: bootstrap material removed" if grep -q '^RDS_FORMAT_AUTHORIZED=' "${HANDOFF}"; then fail "authorized: grant retained"; else pass "authorized: grant consumed"; fi fi @@ -309,6 +316,24 @@ printf '%s %s ext4 rw 0 0\n' "${DEV_DIR}/wrong" "${DATA_MOUNT}" >> "${MOUNTS}" run_fails "already mounted wrong device" "not expected device" nothing_formatted "already mounted wrong device" +# An image whose service states no layout must not fall back to another engine's +# paths: a volume mounted where the engine is not looking reads as an empty +# datadir, and the bootstrap that follows serves an empty database beside the +# customer's data. Both refusals come before the handoff is even read. +reset_state +add_disk vdb volexpected +write_handoff vol-expected volexpected 1 true +DATA_MOUNT="" +run_fails "no mount point" "RDS_DATA_MOUNT is not set" +nothing_formatted "no mount point" + +reset_state +add_disk vdb volexpected +write_handoff vol-expected volexpected 1 true +ENGINE_USER="" +run_fails "no engine user" "RDS_ENGINE_USER is not set" +nothing_formatted "no engine user" + if [ "${FAILS}" -eq 0 ]; then echo "PASS: all rds-datadir cases" exit 0 diff --git a/scripts/images/rds-mariadb/manifest.conf b/scripts/images/rds-mariadb/manifest.conf new file mode 100644 index 000000000..c7ca26a22 --- /dev/null +++ b/scripts/images/rds-mariadb/manifest.conf @@ -0,0 +1,55 @@ +IMAGE_NAME="rds-mariadb" +IMAGE_DESCRIPTION="Spinifex RDS MariaDB instance — Alpine + MariaDB 11.8" +DISTRO="alpine" +ALPINE_VERSION="3.24.1" + +# Engine binaries and per-boot diagnostics only. The datadir lives on the data +# volume, so the boot volume stays a disposable COW clone. +IMAGE_SIZE="2G" + +# Alpine's mariadb package name carries no version, unlike postgresql18, so the +# fuzzy pin is what keeps a rebuild on this series: =~11.8 admits a patch release +# and refuses a series bump, which would otherwise produce an image tagged +# engine-version=11.8 running 11.9. mariadb-common carries my.cnf and the charset +# and error-message data; the client package carries the binaries the agent +# probes and rotates passwords with. +# blkid is a hard requirement — without it rds-datadir refuses to run rather +# than risk formatting a customer's datadir. +APK_PACKAGES="mariadb=~11.8 mariadb-client=~11.8 mariadb-common=~11.8 mariadb-openrc=~11.8 ca-certificates cloud-utils-growpart e2fsprogs-extra xfsprogs blkid openrc" + +# rds-agent is the only built binary (static, FIPS boot like the host build). +BUILD_COMMANDS="CGO_ENABLED=0 GOFIPS140=v1.0.0 go build -ldflags \"-s -w\" -o bin/rds-agent ./cmd/rds-agent" + +INSTALL_BINARIES="bin/rds-agent:/usr/local/bin/rds-agent" + +# mulga-mgmt-net is shared with eks-node: this is a three-NIC system VM (system +# ENI, customer ENI, mgmt0), so the host's fw_cfg netcfg blob needs a consumer in +# the guest, or mgmt0 never gets its static address and rds-agent cannot reach +# the gateway, and the customer ENI never comes up so nothing reaches the DB. +INSTALL_FILES="\ +scripts/images/common/rds-datadir:/usr/local/sbin/rds-datadir \ +scripts/images/rds-mariadb/rds-datadir.initd:/etc/init.d/rds-datadir \ +scripts/images/rds-mariadb/rds-agent.initd:/etc/init.d/rds-agent \ +scripts/images/common/mulga-mgmt-net.initd:/etc/init.d/mulga-mgmt-net \ +scripts/images/common/mulga-mgmt-net-routes.initd:/etc/init.d/mulga-mgmt-net-routes \ +scripts/images/common/mulga-mgmt-net.sh:/usr/local/sbin/mulga-mgmt-net \ +scripts/images/rds-mariadb/zz-rds-include.cnf:/etc/my.cnf.d/zz-rds-include.cnf" + +# Each service's own depend() asserts the ordering; this lists it to match. +# mulga-mgmt-net is NOT here: setup.sh adds it to the boot runlevel, since a +# default-runlevel entry would run after cloud-init-local and be too late. Its +# route-enforcement second pass is the opposite — it has to follow cloud-init, so +# the default runlevel is exactly where it belongs. +# +# mariadb is deliberately absent while this image has no bootstrap: the packaged +# service only warns about an empty datadir instead of refusing, so an enabled +# engine would launch mysqld_safe against an unbootstrapped data volume. +ENABLE_SERVICES="rds-datadir rds-agent mulga-mgmt-net-routes" + +SETUP_SCRIPT="scripts/images/rds-mariadb/setup.sh" + +AMI_NAME="spinifex-rds-mariadb" + +# The contract tag prevents the control plane from issuing a format grant to an +# older image that still discovers generic vol* devices. +SYSTEM_TAG="spinifex:managed-by=rds engine=mariadb engine-version=11.8 rds-data-volume-contract=format-auth-v1" diff --git a/scripts/images/rds-mariadb/rds-agent.initd b/scripts/images/rds-mariadb/rds-agent.initd new file mode 100644 index 000000000..a01ff9d41 --- /dev/null +++ b/scripts/images/rds-mariadb/rds-agent.initd @@ -0,0 +1,77 @@ +#!/sbin/openrc-run +# rds-agent — Spinifex RDS in-guest agent. Reaches the control plane over +# TLS+SigV4 (never NATS), signing with IMDS instance-role credentials. Static +# config comes from /etc/spinifex-rds/agent.env, which carries no secrets. + +description="Spinifex RDS instance agent" +command="/usr/local/bin/rds-agent" +command_background="yes" +pidfile="/run/rds-agent.pid" +output_log="/var/log/rds-agent.log" +error_log="/var/log/rds-agent.log" + +start_stop_daemon_args="--wait 2000" + +AGENT_ENV="/etc/spinifex-rds/agent.env" + +# The agent retries indefinitely, so this only bounds how long the boot waits +# before letting rds-init fail visibly rather than hanging forever. +handoff_timeout="${RDS_HANDOFF_TIMEOUT:-120}" + +# How much of the agent log to mirror to the console when the handoff times out. +# Enough to carry a few backed-off retries with their errors, not the whole boot. +handoff_tail="${RDS_HANDOFF_TAIL:-20}" + +depend() { + need net + after localmount + before rds-datadir rds-init mariadb +} + +start_pre() { + # An absent file is tolerated, so the agent still starts and logs why it + # cannot reach the gateway. + if [ -f "${AGENT_ENV}" ]; then + # shellcheck disable=SC1090 + . "${AGENT_ENV}" + export RDS_GATEWAY_URL RDS_GATEWAY_CA RDS_REGION RDS_DB_INSTANCE_IDENTIFIER \ + RDS_ENGINE RDS_ENGINE_VERSION RDS_HANDOFF_DIR RDS_ENGINE_HOST RDS_ENGINE_PORT \ + RDS_POLL_WAIT RDS_DATA_MOUNT + fi +} + +start_post() { + # Resolve this after start_pre loads agent.env so custom handoff directories + # are shared by the agent, this wait, and rds-init. + HANDOFF_ENV="${RDS_HANDOFF_DIR:-/run/spinifex-rds}/bootstrap.env" + + # `before rds-init` only orders the starts; rds-init needs the fetched config + # itself. The agent writes bootstrap.env last and renames it into place, so + # its appearance means the whole handoff is there. + ebegin "Waiting for the RDS bootstrap handoff" + _waited=0 + while [ ! -f "${HANDOFF_ENV}" ]; do + if [ "${_waited}" -ge "${handoff_timeout}" ]; then + eend 1 "no bootstrap handoff after ${handoff_timeout}s" + # Mirror the agent's own tail onto the console. The log lives inside a + # guest nothing can reach when this fails — an unreachable gateway is + # the usual cause — so the host-side console capture is the only way + # the dial error survives, and without it the failure reads as silence. + if [ -s "${output_log}" ]; then + einfo "last ${handoff_tail} lines of ${output_log}:" + tail -n "${handoff_tail}" "${output_log}" 2>/dev/null | while IFS= read -r _line; do + einfo " [rds-agent] ${_line}" + done + else + einfo "${output_log} is empty — the agent produced no output at all" + fi + # The agent keeps retrying, so it is left running: rds-init fails on + # the missing handoff and the engine stays down, which is what the + # control plane observes. + return 0 + fi + sleep 1 + _waited=$((_waited + 1)) + done + eend 0 +} diff --git a/scripts/images/rds-mariadb/rds-agent_initd_test.sh b/scripts/images/rds-mariadb/rds-agent_initd_test.sh new file mode 100755 index 000000000..ca042c55d --- /dev/null +++ b/scripts/images/rds-mariadb/rds-agent_initd_test.sh @@ -0,0 +1,48 @@ +#!/bin/sh +set -eu + +SCRIPT_DIR=$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd) +CASE=$(mktemp -d) +trap 'rm -rf "${CASE}"' EXIT + +source_time_handoff="${CASE}/source-time" +configured_handoff="${CASE}/configured" +mkdir -p "${configured_handoff}" +touch "${configured_handoff}/bootstrap.env" +{ + printf 'RDS_HANDOFF_DIR=%s\n' "${configured_handoff}" + printf 'RDS_ENGINE=mariadb\n' +} >"${CASE}/agent.env" + +# Simulate OpenRC sourcing the service before start_pre loads agent.env. +export RDS_HANDOFF_DIR="${source_time_handoff}" +# shellcheck source=rds-agent.initd +. "${SCRIPT_DIR}/rds-agent.initd" +export AGENT_ENV="${CASE}/agent.env" +export handoff_timeout=0 + +EEND_STATUS= +ebegin() { :; } +einfo() { :; } +eend() { EEND_STATUS=$1; } + +start_pre +start_post + +if [ "${HANDOFF_ENV}" != "${configured_handoff}/bootstrap.env" ]; then + echo "FAIL: handoff path did not use RDS_HANDOFF_DIR from agent.env" >&2 + exit 1 +fi +if [ "${EEND_STATUS}" != "0" ]; then + echo "FAIL: post-start wait did not find the configured handoff" >&2 + exit 1 +fi +# The agent checks this against the engine its own image bakes. Left out of the +# export list it would read as no assertion at all, so a VM launched as the +# wrong engine would bootstrap instead of refusing. +if [ "${RDS_ENGINE:-}" != "mariadb" ]; then + echo "FAIL: start_pre did not export RDS_ENGINE from agent.env" >&2 + exit 1 +fi + +echo "rds-agent.initd: all tests passed" diff --git a/scripts/images/rds-mariadb/rds-datadir.initd b/scripts/images/rds-mariadb/rds-datadir.initd new file mode 100644 index 000000000..8a180b5a1 --- /dev/null +++ b/scripts/images/rds-mariadb/rds-datadir.initd @@ -0,0 +1,35 @@ +#!/sbin/openrc-run +# rds-datadir — boot oneshot that discovers the hot-plugged data volume, formats +# it if authorized, and mounts it at the datadir mount point. It runs after the +# agent has written the authenticated handoff and before anything can use the +# MariaDB datadir. Soft ordering keeps a failed boot visible on the console. + +description="Spinifex RDS data volume mount" + +AGENT_ENV="/etc/spinifex-rds/agent.env" + +depend() { + after localmount rds-agent + before rds-init mariadb +} + +start_pre() { + # This image's layout, stated here because the mount script is shared by + # every engine preset. A delivered RDS_DATA_MOUNT still wins, so the control + # plane can move the mount point without rebuilding the image. + RDS_DATA_MOUNT="/var/lib/mysql" + RDS_ENGINE_USER="mysql" + if [ -f "${AGENT_ENV}" ]; then + # shellcheck disable=SC1090 + . "${AGENT_ENV}" + fi + export RDS_HANDOFF_DIR RDS_DATA_MOUNT RDS_ENGINE_USER +} + +start() { + ebegin "Mounting the RDS data volume" + # To the console for the same reason rds-init's is: the serial log is the + # only view of a boot that failed before the engine came up. + /usr/local/sbin/rds-datadir > /dev/console 2>&1 + eend $? +} diff --git a/scripts/images/rds-mariadb/rds-engine-stamp_test.sh b/scripts/images/rds-mariadb/rds-engine-stamp_test.sh new file mode 100755 index 000000000..965c6c0b2 --- /dev/null +++ b/scripts/images/rds-mariadb/rds-engine-stamp_test.sh @@ -0,0 +1,57 @@ +#!/bin/sh +set -eu + +# The engine this preset bakes, and the series it pins, are stated in places that +# have to agree: the stamp setup.sh writes, the manifest tags resolveEngineAMI +# selects on, the apk pin the packages are installed from, and the engine value +# the control plane launches instances with. A drift between them launches the +# wrong image, refuses the right one, or reports a version the VM is not running. +# +# This preset carries no bootstrap yet, so the datadir stamp and the agent's +# layout table are checked by the postgres preset's copy of this test only. + +SCRIPT_DIR=$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd) +REPO_ROOT=$(CDPATH='' cd -- "${SCRIPT_DIR}/../../.." && pwd) +ENGINE="mariadb" +ENGINE_GO="${REPO_ROOT}/spinifex/handlers/rds/engine_mariadb.go" + +FAILS=0 +fail() { echo "FAIL: $*" >&2; FAILS=$((FAILS + 1)); } + +stamped=$(sed -n "s|^printf '\\(.*\\)\\\\n' > /etc/spinifex-rds/engine\$|\\1|p" "${SCRIPT_DIR}/setup.sh") +if [ "${stamped}" != "${ENGINE}" ]; then + fail "setup.sh stamps engine '${stamped}', want '${ENGINE}'" +fi + +if ! grep -q "engine=${ENGINE} " "${SCRIPT_DIR}/manifest.conf"; then + fail "manifest.conf SYSTEM_TAG does not carry engine=${ENGINE}" + grep '^SYSTEM_TAG' "${SCRIPT_DIR}/manifest.conf" >&2 +fi + +if ! grep -q "Name: *\"${ENGINE}\"" "${ENGINE_GO}"; then + fail "the control plane has no engine named ${ENGINE}" +fi + +# The tag is what an instance's recorded EngineVersion resolves an AMI by, so it +# has to be the same series the control plane pins and the packages install. +version=$(sed -n 's/^[[:space:]]*MajorVersion: *"\([^"]*\)".*/\1/p' "${ENGINE_GO}") +if [ -z "${version}" ]; then + fail "could not read MajorVersion from ${ENGINE_GO}" +else + if ! grep -q "engine-version=${version} " "${SCRIPT_DIR}/manifest.conf"; then + fail "manifest.conf SYSTEM_TAG does not carry engine-version=${version}" + grep '^SYSTEM_TAG' "${SCRIPT_DIR}/manifest.conf" >&2 + fi + # Alpine's package name carries no version, so this fuzzy pin is the only + # thing standing between a rebuild and a silent series bump. + if ! grep -q "APK_PACKAGES=.*${ENGINE}=~${version} " "${SCRIPT_DIR}/manifest.conf"; then + fail "manifest.conf does not pin the ${ENGINE} packages to =~${version}" + grep '^APK_PACKAGES' "${SCRIPT_DIR}/manifest.conf" >&2 + fi +fi + +if [ "${FAILS}" -eq 0 ]; then + echo "rds-engine-stamp: all tests passed" + exit 0 +fi +exit 1 diff --git a/scripts/images/rds-mariadb/rds-mycnf_test.sh b/scripts/images/rds-mariadb/rds-mycnf_test.sh new file mode 100755 index 000000000..6923fa5ef --- /dev/null +++ b/scripts/images/rds-mariadb/rds-mycnf_test.sh @@ -0,0 +1,74 @@ +#!/bin/sh +set -eu + +# The generated parameter files live on the data volume and reach the server +# through one baked include. Three properties make that work, and each fails +# silently rather than loudly if it breaks: the include must be read after every +# packaged drop-in, it must point at the mount rds-datadir uses, and it must be +# named so MariaDB reads it at all. + +SCRIPT_DIR=$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd) +INCLUDE="zz-rds-include.cnf" + +FAILS=0 +fail() { echo "FAIL: $*" >&2; FAILS=$((FAILS + 1)); } +pass() { echo "ok: $*"; } + +# MariaDB reads only .cnf and .ini out of an include directory. Any other suffix +# would leave the customer's parameters silently unread. +case "${INCLUDE}" in + *.cnf) pass "the include is named so MariaDB reads it" ;; + *) fail "${INCLUDE} is not a name MariaDB reads out of an include directory" ;; +esac + +if [ ! -f "${SCRIPT_DIR}/${INCLUDE}" ]; then + fail "${INCLUDE} is missing from the preset" +fi +if ! grep -q "${INCLUDE}:/etc/my.cnf.d/${INCLUDE}" "${SCRIPT_DIR}/manifest.conf"; then + fail "manifest.conf does not install ${INCLUDE} into /etc/my.cnf.d" +fi + +# Alpine's own drop-in is the file this has to outsort: MariaDB walks the include +# directory in byte order and takes the last occurrence of a setting, so a digit +# prefix would sort ahead of it and let a distribution default beat the platform. +packaged="mariadb-server.cnf" +if [ "$(printf '%s\n%s\n' "${INCLUDE}" "${packaged}" | LC_ALL=C sort | tail -n 1)" = "${INCLUDE}" ]; then + pass "the include is read after ${packaged}" +else + fail "${packaged} is read after ${INCLUDE}, so packaged defaults win" +fi + +# setup.sh asserts the same property against the real directory at build time. +grep -q 'LC_ALL=C sort | tail -n 1' "${SCRIPT_DIR}/setup.sh" || + fail "setup.sh does not assert the include is the last drop-in" + +include_dir=$(sed -n 's/^!includedir[[:space:]]*//p' "${SCRIPT_DIR}/${INCLUDE}") +if [ -z "${include_dir}" ]; then + fail "${INCLUDE} declares no !includedir" +else + # The generated files survive a VM replacement only by living on the data + # volume, so the include has to sit under the mount rds-datadir provides. + data_mount=$(sed -n 's/^[[:space:]]*RDS_DATA_MOUNT="\([^"]*\)".*/\1/p' "${SCRIPT_DIR}/rds-datadir.initd") + case "${include_dir}" in + "${data_mount}"/*) pass "the include target is on the data volume at ${include_dir}" ;; + *) fail "${include_dir} is not under the data mount ${data_mount:-}" ;; + esac + + # An !includedir MariaDB cannot open is a fatal defaults-parsing error, which + # would take the client down alongside the server on a boot with no volume. + grep -q "install -d .* ${include_dir}\$" "${SCRIPT_DIR}/setup.sh" || + fail "setup.sh does not bake ${include_dir} into the image" +fi + +# The packaged drop-in disables TCP. Left in place, the endpoint resolves and the +# health probe passes while nothing on the customer ENI can ever connect. +grep -q 's/\^skip-networking\$/' "${SCRIPT_DIR}/setup.sh" || + fail "setup.sh does not disable the packaged skip-networking" +grep -q "skip\[-_\]networking" "${SCRIPT_DIR}/setup.sh" || + fail "setup.sh does not assert that no configuration file disables TCP" + +if [ "${FAILS}" -eq 0 ]; then + echo "rds-mycnf: all tests passed" + exit 0 +fi +exit 1 diff --git a/scripts/images/rds-mariadb/rds-openrc_test.sh b/scripts/images/rds-mariadb/rds-openrc_test.sh new file mode 100755 index 000000000..104f9f88d --- /dev/null +++ b/scripts/images/rds-mariadb/rds-openrc_test.sh @@ -0,0 +1,44 @@ +#!/bin/sh +set -eu + +SCRIPT_DIR=$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd) +WORK=$(mktemp -d) +trap 'rm -rf "${WORK}"' EXIT + +capture_dependencies() { + _script=$1 + ( + need() { echo "need $*"; } + after() { echo "after $*"; } + before() { echo "before $*"; } + # shellcheck disable=SC1090 + . "${SCRIPT_DIR}/${_script}" + depend + ) +} + +assert_line() { + _file=$1 + _line=$2 + grep -Fxq "${_line}" "${_file}" || { + echo "FAIL: ${_file} missing dependency: ${_line}" >&2 + cat "${_file}" >&2 + exit 1 + } +} + +capture_dependencies rds-agent.initd > "${WORK}/agent" +capture_dependencies rds-datadir.initd > "${WORK}/datadir" + +assert_line "${WORK}/agent" "need net" +assert_line "${WORK}/agent" "after localmount" +assert_line "${WORK}/agent" "before rds-datadir rds-init mariadb" +assert_line "${WORK}/datadir" "after localmount rds-agent" +assert_line "${WORK}/datadir" "before rds-init mariadb" + +if grep -Fq "before rds-agent" "${WORK}/datadir"; then + echo "FAIL: rds-datadir still starts before the bootstrap handoff" >&2 + exit 1 +fi + +echo "rds-openrc: dependency order tests passed" diff --git a/scripts/images/rds-mariadb/setup.sh b/scripts/images/rds-mariadb/setup.sh new file mode 100644 index 000000000..6a599e1bf --- /dev/null +++ b/scripts/images/rds-mariadb/setup.sh @@ -0,0 +1,77 @@ +#!/bin/sh +set -eu + +# setup.sh — guest customisation for the spinifex-rds-mariadb AMI. Runs inside +# the libguestfs appliance under build-system-image.sh, after packages and +# INSTALL_FILES are placed. + +# INSTALL_FILES land 0644; OpenRC requires 0755 on init scripts, and rds-datadir +# is executed directly by its service. +chmod 0755 /etc/init.d/rds-datadir /etc/init.d/rds-agent /usr/local/sbin/rds-datadir +chmod 0755 /etc/init.d/mulga-mgmt-net /etc/init.d/mulga-mgmt-net-routes \ + /usr/local/sbin/mulga-mgmt-net + +# mulga-mgmt-net goes in the boot runlevel, not default (where ENABLE_SERVICES +# lands services). It applies mgmt0's static address, which rds-agent needs to +# reach the gateway at all, and DHCPs the data NIC so the init-local Ec2 crawl +# reaches IMDS; a default entry runs after cloud-init-local and is too late. +rc-update add mulga-mgmt-net boot + +# Where cloud-init drops the agent's env file and the gateway CA. Created here +# so the delivery lands in a root-only directory. +install -d -m 0700 /etc/spinifex-rds + +# The engine this image bakes. rds-agent builds its engine implementation from +# this file, so running the wrong implementation is structurally impossible +# rather than merely validated against; a VM launched as another engine is +# refused before anything touches the datadir. +printf 'mariadb\n' > /etc/spinifex-rds/engine +chmod 0444 /etc/spinifex-rds/engine + +# Empty in the image: rds-datadir mounts over it at boot. The package already +# creates it 0750 mysql:mysql; restated so a packaging change cannot loosen it. +install -d -m 0750 -o mysql -g mysql /var/lib/mysql + +# The include target, baked so it exists even when the data volume is not +# mounted. MariaDB treats an !includedir it cannot open as a fatal error while +# parsing defaults, which would take down the client alongside the server on +# exactly the boot an operator needs both to diagnose the missing volume. +install -d -m 0750 -o mysql -g mysql /var/lib/mysql/conf.d + +# The generated configuration must be read last, or a packaged default would win +# over a platform-owned setting. Sorted in byte order, the way MariaDB itself +# walks an include directory, so a future package adding a later-sorting file +# fails the build rather than silently overriding the platform. +last_dropin=$(ls /etc/my.cnf.d | LC_ALL=C sort | tail -n 1) +if [ "${last_dropin}" != "zz-rds-include.cnf" ]; then + echo "[rds-mariadb-setup] /etc/my.cnf.d/${last_dropin} is read after the RDS include" + exit 1 +fi + +# Alpine's packaged server drop-in sets skip-networking, which would leave the +# instance reachable only over its unix socket — the endpoint would resolve, the +# health probe would pass, and nothing on the customer ENI could ever connect. +# Disabled at the source rather than countered from a later file, so the image +# does not depend on override ordering for the endpoint to work at all. +sed -i 's/^skip-networking$/# skip-networking — disabled: the customer reaches this instance over TCP/' \ + /etc/my.cnf.d/mariadb-server.cnf +if grep -rEq '^[[:space:]]*skip[-_]networking' /etc/my.cnf /etc/my.cnf.d; then + echo "[rds-mariadb-setup] a packaged configuration file still disables TCP networking" + grep -rEn '^[[:space:]]*skip[-_]networking' /etc/my.cnf /etc/my.cnf.d + exit 1 +fi + +# Bind /dev/console to the serial port so userspace boot output reaches ttyS0, +# which the orchestrator captures host-side. Linux makes the last console= the +# controlling one, and stock Alpine lists tty0 last — reorder so ttyS0 wins. +sed -i \ + 's|console=ttyS0,115200n8 console=ttyAMA0,115200n8 console=tty0|console=tty0 console=ttyAMA0,115200n8 console=ttyS0,115200n8|' \ + /etc/update-extlinux.conf /boot/extlinux.conf + +# Cut the boot-menu countdown from 10s to ~1s, a fixed tax on every VM start. +# Both the generator config (seconds) and the rendered output (1/10s) are patched +# so a regenerate keeps it; a small nonzero keeps the menu interruptible. +sed -i 's/^timeout=.*/timeout=1/' /etc/update-extlinux.conf +sed -i 's/^TIMEOUT[[:space:]].*/TIMEOUT 10/' /boot/extlinux.conf + +echo "[rds-mariadb-setup] done" diff --git a/scripts/images/rds-mariadb/zz-rds-include.cnf b/scripts/images/rds-mariadb/zz-rds-include.cnf new file mode 100644 index 000000000..c2bc84851 --- /dev/null +++ b/scripts/images/rds-mariadb/zz-rds-include.cnf @@ -0,0 +1,19 @@ +# /etc/my.cnf.d/zz-rds-include.cnf — Spinifex RDS generated-configuration include. +# +# The generated files live on the data volume rather than under /etc. A class +# change is a VM replacement: a fresh boot volume from this AMI with the same +# data volume reattached. Parameters under /etc would be discarded by that +# replacement and the instance would silently revert to catalog defaults while +# the API still reported the customer's values. +# +# zz- so this is the last drop-in the packaged !includedir reads. MariaDB walks +# an include directory in byte order and takes the last occurrence of a setting, +# which is what makes a platform-owned value beat a distribution default — and a +# digit prefix would sort ahead of the packaged mariadb-server.cnf rather than +# after it. setup.sh asserts the resulting order at build time. +# +# The directory is also baked empty into this image. MariaDB treats an +# !includedir it cannot open as a fatal defaults-parsing error, so a boot with no +# data volume mounted would otherwise take the client down with the server. + +!includedir /var/lib/mysql/conf.d diff --git a/scripts/images/rds-postgres/manifest.conf b/scripts/images/rds-postgres/manifest.conf index e07c7da24..d3f5400a4 100644 --- a/scripts/images/rds-postgres/manifest.conf +++ b/scripts/images/rds-postgres/manifest.conf @@ -23,7 +23,7 @@ INSTALL_BINARIES="bin/rds-agent:/usr/local/bin/rds-agent" # the guest, or mgmt0 never gets its static address and rds-agent cannot reach # the gateway, and the customer ENI never comes up so nothing reaches the DB. INSTALL_FILES="\ -scripts/images/rds-postgres/rds-datadir:/usr/local/sbin/rds-datadir \ +scripts/images/common/rds-datadir:/usr/local/sbin/rds-datadir \ scripts/images/rds-postgres/rds-datadir.initd:/etc/init.d/rds-datadir \ scripts/images/rds-postgres/rds-init:/usr/local/sbin/rds-init \ scripts/images/rds-postgres/rds-init.initd:/etc/init.d/rds-init \ diff --git a/scripts/images/rds-postgres/rds-datadir.initd b/scripts/images/rds-postgres/rds-datadir.initd index 03a449c56..977ce2d94 100644 --- a/scripts/images/rds-postgres/rds-datadir.initd +++ b/scripts/images/rds-postgres/rds-datadir.initd @@ -14,11 +14,16 @@ depend() { } start_pre() { + # This image's layout, stated here because the mount script is shared by + # every engine preset. A delivered RDS_DATA_MOUNT still wins, so the control + # plane can move the mount point without rebuilding the image. + RDS_DATA_MOUNT="/var/lib/postgresql" + RDS_ENGINE_USER="postgres" if [ -f "${AGENT_ENV}" ]; then # shellcheck disable=SC1090 . "${AGENT_ENV}" - export RDS_HANDOFF_DIR RDS_DATA_MOUNT fi + export RDS_HANDOFF_DIR RDS_DATA_MOUNT RDS_ENGINE_USER } start() {