diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml new file mode 100644 index 0000000..88861c8 --- /dev/null +++ b/.github/workflows/build.yaml @@ -0,0 +1,77 @@ +name: Build Artifacts +on: + release: + types: [created] + push: + branches: + - '**' + workflow_dispatch: + inputs: + publish_docker: + description: "Publish image to ghcr.io/netcracker/pgskipper-patroni" + type: boolean + default: false + required: false + +env: + TAG_NAME: ${{ github.event.release.tag_name || github.ref }} + PUSH: ${{ github.event_name != 'workflow_dispatch' || inputs.publish_docker }} + +jobs: + multiplatform_build: + strategy: + fail-fast: false + matrix: + component: + - name: pgskipper-patroni-16 + file: pg16/Dockerfile + context: "" + - name: pgskipper-patroni-15 + file: pg15/Dockerfile + context: "" + runs-on: ubuntu-24.04 + steps: + - name: Validate + run: | + if [[ "${{ github.event_name }}" == "workflow_dispatch" && "${{ github.ref }}" == refs/tags* ]]; then + echo -e "\033[91mManual workflow run on tags is not allowed!\033[0m" + exit 1 + fi + - name: Checkout + uses: actions/checkout@v4 + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${GITHUB_ACTOR} + password: ${{secrets.GITHUB_TOKEN}} + - name: Prepare Tag + run: echo "TAG_NAME=$(echo ${TAG_NAME} | sed 's@refs/tags/@@;s@refs/heads/@@;s@/@_@g')" >> $GITHUB_ENV + - name: Get package IDs for delete + id: get-ids-for-delete + uses: Netcracker/get-package-ids@v0.0.1 + with: + component-name: ${{ matrix.component.name }} + component-tag: ${{ env.TAG_NAME }} + access-token: ${{secrets.GITHUB_TOKEN}} + if: ${{ env.PUSH }} + - name: Build and push + uses: docker/build-push-action@v6 + with: + no-cache: true + context: ${{ matrix.component.context }} + file: ${{ matrix.component.file }} + platforms: linux/amd64 #,linux/arm64 extensions required + push: ${{ env.PUSH }} + tags: ghcr.io/netcracker/${{ matrix.component.name }}:${{ env.TAG_NAME }} + provenance: false + - uses: actions/delete-package-versions@v5 + with: + package-name: ${{ matrix.component.name }} + package-type: 'container' + package-version-ids: ${{ steps.get-ids-for-delete.outputs.ids-for-delete }} + if: ${{ steps.get-ids-for-delete.outputs.ids-for-delete != '' }} diff --git a/.github/workflows/clean.yaml b/.github/workflows/clean.yaml new file mode 100644 index 0000000..7727d55 --- /dev/null +++ b/.github/workflows/clean.yaml @@ -0,0 +1,41 @@ +name: Branch Deleted +on: delete + +env: + COMPONENT_NAME: pgskipper-patroni + TAG_NAME: ${{ github.event.ref }} + +jobs: + delete: + if: github.event.ref_type == 'branch' + strategy: + fail-fast: false + matrix: + component: + - name: pgskipper-patroni-16 + context: "" + - name: pgskipper-patroni-15 + context: "" + runs-on: ubuntu-24.04 + steps: + - name: Prepare Tag + run: echo "TAG_NAME=$(echo ${TAG_NAME} | sed 's@refs/heads/@@;s@/@_@g')" >> $GITHUB_ENV + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${GITHUB_ACTOR} + password: ${{secrets.GITHUB_TOKEN}} + - name: Get package IDs for delete + id: get-ids-for-delete + uses: Netcracker/get-package-ids@v0.0.1 + with: + component-name: ${{ matrix.component.name }} + component-tag: ${{ env.TAG_NAME }} + access-token: ${{secrets.GITHUB_TOKEN}} + - uses: actions/delete-package-versions@v5 + with: + package-name: ${{ matrix.component.name }} + package-type: 'container' + package-version-ids: ${{ steps.get-ids-for-delete.outputs.ids-for-delete }} + if: ${{ steps.get-ids-for-delete.outputs.ids-for-delete != '' }} diff --git a/.github/workflows/license.yaml b/.github/workflows/license.yaml new file mode 100644 index 0000000..6609d79 --- /dev/null +++ b/.github/workflows/license.yaml @@ -0,0 +1,24 @@ +name: Add License Header +on: + push: + branches: + - 'main' +env: + COPYRIGHT_COMPANY: 'NetCracker Technology Corporation' + COPYRIGHT_YEAR: '2024-2025' +jobs: + license: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + with: + token: ${{ secrets.GH_ACCESS_TOKEN }} + - run: docker run -v "${PWD}:/src" -i ghcr.io/google/addlicense -v -c "${{ env.COPYRIGHT_COMPANY }}" -y "${{ env.COPYRIGHT_YEAR }}" $(find . -type f -name "*.go" -o -type f -name "*.sh" -o -type f -name "*.py" | xargs echo) + - name: Create Pull Request + uses: peter-evans/create-pull-request@v7 + with: + commit-message: Auto-update license header + branch: license-update + title: Add License Header + body: Automated license header update + delete-branch: true \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0da7671 --- /dev/null +++ b/.gitignore @@ -0,0 +1,22 @@ +# To ignore oc config file. +ansible/.tmp + +# For failed run artifacts. +ansible/*.retry + +# Test results. +ansible/tests + +# Pv cleaner results. +ansible/pv_cleaner_results + +# Build output. +target + +pg15/scripts +pg15/docs +pg15/maintenance + +pg16/scripts +pg16/docs +pg16/maintenance \ No newline at end of file diff --git a/CODE-OF-CONDUCT.md b/CODE-OF-CONDUCT.md new file mode 100644 index 0000000..f5b511b --- /dev/null +++ b/CODE-OF-CONDUCT.md @@ -0,0 +1,73 @@ +# Code of Conduct + +This repository is governed by following code of conduct guidelines. + +We put collaboration, trust, respect and transparency as core values for our community. +Our community welcomes participants from all over the world with different experience, +opinion and ideas to share. + +We have adopted this code of conduct and require all contributors to agree with that to build a healthy, +safe and productive community for all. + +The guideline is aimed to support a community where all people should feel safe to participate, +introduce new ideas and inspire others, regardless of: + +* Age +* Gender +* Gender identity or expression +* Family status +* Marital status +* Ability +* Ethnicity +* Race +* Sex characteristics +* Sexual identity and orientation +* Education +* Native language +* Background +* Caste +* Religion +* Geographic location +* Socioeconomic status +* Personal appearance +* Any other dimension of diversity + +## Our Standards + +We are welcoming the following behavior: + +* Be respectful for different ideas, opinions and points of view +* Be constructive and professional +* Use inclusive language +* Be collaborative and show the empathy +* Focus on the best results for the community + +The following behavior is unacceptable: + +* Violence, threats of violence, or inciting others to commit self-harm +* Personal attacks, trolling, intentionally spreading misinformation, insulting/derogatory comments +* Public or private harassment +* Publishing others' private information, such as a physical or electronic address, without explicit permission +* Derogatory language +* Encouraging unacceptable behavior +* Other conduct which could reasonably be considered inappropriate in a professional community + +## Our Responsibilities + +Project maintainers are responsible for clarifying the standards of the Code of Conduct +and are expected to take appropriate actions in response to any instances of unacceptable behavior. + +Project maintainers have the right and responsibility to remove, edit, or reject comments, +commits, code, wiki edits, issues, and other contributions that are not aligned +to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors +that they deem inappropriate, threatening, offensive, or harmful. + +## Reporting + +If you believe you’re experiencing unacceptable behavior that will not be tolerated as outlined above, +please report to `opensourcegroup@netcracker.com`. All complaints will be reviewed and investigated and will result in a response +that is deemed necessary and appropriate to the circumstances. The project team is obligated to maintain confidentiality +with regard to the reporter of an incident. + +Please also report if you observe a potentially dangerous situation, someone in distress, or violations of these guidelines, +even if the situation is not happening to you. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..292ce26 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,12 @@ +# Contribution Guide + +We'd love to accept patches and contributions to this project. +Please, follow these guidelines to make the contribution process easy and effective for everyone involved. + +## Contributor License Agreement + +You must sign the [Contributor License Agreement](https://pages.netcracker.com/cla-main.html) in order to contribute. + +## Code of Conduct + +Please make sure to read and follow the [Code of Conduct](CODE-OF-CONDUCT.md). diff --git a/README.md b/README.md index 228fc07..0ec68ce 100644 --- a/README.md +++ b/README.md @@ -1 +1 @@ -# postgres-patroni \ No newline at end of file +# pgskipper-patroni \ No newline at end of file diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..8162261 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,15 @@ +# Security Reporting Process + +Please, report any security issue to `opensourcegroup@netcracker.com` where the issue will be triaged appropriately. + +If you know of a publicly disclosed security vulnerability please IMMEDIATELY email `opensourcegroup@netcracker.com` +to inform the team about the vulnerability, so we may start the patch, release, and communication process. + +# Security Release Process + +If the vulnerability is found in the latest stable release, then it would be fixed in patch version for that release. +E.g., issue is found in 2.5.0 release, then 2.5.1 version with a fix will be released. +By default, older versions will not have security releases. + +If the issue doesn't affect any existing public releases, the fix for medium and high issues is performed +in a main branch before releasing a new version. For low priority issues the fix can be planned for future releases. diff --git a/pg15/Dockerfile b/pg15/Dockerfile new file mode 100644 index 0000000..de6c2a5 --- /dev/null +++ b/pg15/Dockerfile @@ -0,0 +1,106 @@ +FROM ubuntu:22.04 + +USER root + +ENV POD_IDENTITY="node1" \ + PATRONI_TTL=60 \ + PATRONI_LOOP_WAIT=10 \ + PATRONI_RETRY_TIMEOUT=40 \ + PATRONI_MAXIMUM_LAG_ON_FAILOVER=1048576 \ + PATRONI_SYNCHRONOUS_MODE="false" \ + PG_CLUST_NAME="common" \ + PG_MAX_CONNECTIONS=200 \ + PG_CONF_MAX_PREPARED_TRANSACTIONS=200 \ + PATRONICTL_CONFIG_FILE="/patroni/pg_node.yml" \ + PG_BIN_DIR="/usr/lib/postgresql/15/bin/" \ + POSTGRESQL_VERSION=15 \ + LC_ALL=en_US.UTF-8 \ + LANG=en_US.UTF-8 \ + EDITOR=/usr/bin/vi \ + PATH="/usr/lib/postgresql/15/bin/:${PATH}" + +# Official CentOS repos contain libprotobuf-c 1.0.2, but decoderbufs require 1.1+, thus, +# we craft a custom build of protobuf-c and publish it at this repo. +# Remove this line after moving to the next CentOS releases. +COPY scripts/archive_wal.sh /opt/scripts/archive_wal.sh +ADD ./scripts/pip.conf /root/.pip/pip.conf +COPY ./scripts/postgresql.conf /tmp/postgresql.conf +COPY ./scripts/fix_permission.sh /usr/libexec/fix-permissions +ADD ./scripts/* / + +RUN echo "deb [trusted=yes] http://apt.postgresql.org/pub/repos/apt jammy-pgdg main" >> /etc/apt/sources.list.d/pgdg.list +RUN ls -la /etc/apt/ +RUN apt-get -y update +RUN apt-get -o DPkg::Options::="--force-confnew" -y dist-upgrade +RUN apt-get update && \ + apt-get install -y --allow-downgrades gcc-12 cpp-12 gcc-12-base libgcc-12-dev libstdc++6 libgcc-s1 libnsl2 +RUN apt-get --no-install-recommends install -y python3.11 python3-pip python3-dev libpq-dev cython3 wget curl +RUN echo '127.0.0.1 pypi.python.org' >> /etc/host + +# rename 'tape' group to 'postgres' and creating postgres user - hask for ubuntu +RUN groupmod -n postgres tape +RUN adduser -uid 26 -gid 26 postgres + +ARG DEBIAN_FRONTEND=noninteractive +RUN apt-get --no-install-recommends install -y postgresql-15 postgresql-contrib-15 postgresql-server-dev-15 postgresql-plpython3-15 postgresql-15-hypopg postgresql-15-powa postgresql-15-orafce\ + hostname gettext jq vim \ + postgresql-15-cron postgresql-15-repack postgresql-15-pgaudit postgresql-15-pg-stat-kcache postgresql-15-pg-qualstats postgresql-15-set-user postgresql-15-postgis pgbackrest \ + postgresql-15-pg-wait-sampling postgresql-15-pg-track-settings postgresql-15-pgnodemx postgresql-15-pg-hint-plan postgresql-15-decoderbufs + +# Install LDAP utilities including openldap-clients and necessary libraries +RUN apt-get update && apt-get install -y \ + ldap-utils \ + libldap-2.5-0 \ + libsasl2-modules-gssapi-mit \ + libldap-common \ + && rm -rf /var/lib/apt/lists/* + +RUN localedef -i en_US -f UTF-8 en_US.UTF-8 && \ + localedef -i es_PE -f UTF-8 es_PE.UTF-8 && \ + localedef -i es_ES -f UTF-8 es_ES.UTF-8 + +RUN wget https://github.com/zubkov-andrei/pg_profile/releases/download/0.3.6/pg_profile--0.3.6.tar.gz && \ +tar -xzf pg_profile--0.3.6.tar.gz --directory $(pg_config --sharedir)/extension && \ +rm -rf pg_profile--0.3.6.tar.gz + +# Install pgsentinel and pg_dbms_stats +RUN apt update && apt-get install -y git make gcc && \ + git clone https://github.com/pgsentinel/pgsentinel.git && \ + cd pgsentinel && \ + git checkout 0218c2147daab0d2dbbf08433cb480163d321839 && \ + cd src && make install && \ + cd ../.. && git clone --depth 1 --branch REL14_0 https://github.com/ossc-db/pg_dbms_stats.git && \ + cd pg_dbms_stats && sed -i 's/$(MAJORVERSION)/14/g' Makefile && \ + make install && \ + apt-get purge -y --auto-remove git make gcc && \ + cd .. && rm -rf pgsentinel + +RUN apt-get install -y alien + +RUN cat /root/.pip/pip.conf +RUN python3 -m pip install -U setuptools +RUN python3 -m pip install psutil patroni[kubernetes,etcd]==3.3.2 psycopg2-binary==2.9.5 requests python-dateutil urllib3 six prettytable --no-cache +RUN mv /var/lib/postgresql /var/lib/pgsql + +RUN mkdir /patroni && chmod -R 777 /patroni/ && \ + chmod +x /usr/libexec/fix-permissions && \ + /usr/libexec/fix-permissions /var/run/postgresql && \ + /usr/libexec/fix-permissions /var/lib/pgsql && \ + mkdir -p /var/lib/pgsql/data/ && \ + chown -R postgres:postgres /var/lib/pgsql && \ + chmod +x /*.py && \ + chmod +x /*.sh && \ + chmod 777 /opt/scripts/archive_wal.sh && \ + ln -s /usr/bin/python3 /usr/bin/python + +# Volumes are defined to support read-only root file system +VOLUME /etc +VOLUME /patroni +VOLUME /run/postgresql + +WORKDIR /patroni +ENTRYPOINT ["/start.sh"] + +USER 26 +EXPOSE 5432 +EXPOSE 8008 diff --git a/pg16/Dockerfile b/pg16/Dockerfile new file mode 100644 index 0000000..c65a4ed --- /dev/null +++ b/pg16/Dockerfile @@ -0,0 +1,102 @@ +FROM ubuntu:22.04 + +USER root + +ENV POD_IDENTITY="node1" \ + PATRONI_TTL=60 \ + PATRONI_LOOP_WAIT=10 \ + PATRONI_RETRY_TIMEOUT=40 \ + PATRONI_MAXIMUM_LAG_ON_FAILOVER=1048576 \ + PATRONI_SYNCHRONOUS_MODE="false" \ + PG_CLUST_NAME="common" \ + PG_MAX_CONNECTIONS=200 \ + PG_CONF_MAX_PREPARED_TRANSACTIONS=200 \ + PATRONICTL_CONFIG_FILE="/patroni/pg_node.yml" \ + PG_BIN_DIR="/usr/lib/postgresql/16/bin/" \ + POSTGRESQL_VERSION=16 \ + LC_ALL=en_US.UTF-8 \ + LANG=en_US.UTF-8 \ + EDITOR=/usr/bin/vi \ + PATH="/usr/lib/postgresql/16/bin/:${PATH}" + +# Official CentOS repos contain libprotobuf-c 1.0.2, but decoderbufs require 1.1+, thus, +# we craft a custom build of protobuf-c and publish it at this repo. +# Remove this line after moving to the next CentOS releases. +COPY scripts/archive_wal.sh /opt/scripts/archive_wal.sh +ADD ./scripts/pip.conf /root/.pip/pip.conf +COPY ./scripts/postgresql.conf /tmp/postgresql.conf +COPY ./scripts/fix_permission.sh /usr/libexec/fix-permissions +ADD ./scripts/* / + +RUN echo "deb [trusted=yes] http://apt.postgresql.org/pub/repos/apt jammy-pgdg main" >> /etc/apt/sources.list.d/pgdg.list +RUN ls -la /etc/apt/ +RUN apt-get -y update +RUN apt-get -o DPkg::Options::="--force-confnew" -y dist-upgrade +RUN apt-get update && \ + apt-get install -y --allow-downgrades gcc-12 cpp-12 gcc-12-base libgcc-12-dev libstdc++6 libgcc-s1 libnsl2 +RUN apt-get --no-install-recommends install -y python3.11 python3-pip python3-dev libpq-dev cython3 wget curl + +# rename 'tape' group to 'postgres' and creating postgres user - hask for ubuntu +RUN groupmod -n postgres tape +RUN adduser -uid 26 -gid 26 postgres + +ARG DEBIAN_FRONTEND=noninteractive +RUN apt-get --no-install-recommends install -y postgresql-16 postgresql-contrib-16 postgresql-server-dev-16 postgresql-plpython3-16 postgresql-16-hypopg postgresql-16-powa postgresql-16-orafce\ + hostname gettext jq vim \ + postgresql-16-cron postgresql-16-repack postgresql-16-pgaudit postgresql-16-pg-stat-kcache postgresql-16-pg-qualstats postgresql-16-set-user postgresql-16-postgis pgbackrest \ + postgresql-16-pg-wait-sampling postgresql-16-pg-track-settings postgresql-16-pg-hint-plan postgresql-16-pgnodemx postgresql-16-decoderbufs + +# Install LDAP utilities including openldap-clients and necessary libraries +RUN apt-get update && apt-get install -y \ + ldap-utils \ + libldap-2.5-0 \ + libsasl2-modules-gssapi-mit \ + libldap-common \ + && rm -rf /var/lib/apt/lists/* + + +RUN localedef -i en_US -f UTF-8 en_US.UTF-8 && \ + localedef -i es_PE -f UTF-8 es_PE.UTF-8 && \ + localedef -i es_ES -f UTF-8 es_ES.UTF-8 + +# Install pgsentinel and pg_dbms_stats +RUN apt update && apt-get install -y git make gcc && \ + git clone https://github.com/pgsentinel/pgsentinel.git && \ + cd pgsentinel && \ + git checkout 0218c2147daab0d2dbbf08433cb480163d321839 && \ + cd src && make install && \ + cd ../.. && git clone --depth 1 --branch REL14_0 https://github.com/ossc-db/pg_dbms_stats.git && \ + cd pg_dbms_stats && sed -i 's/$(MAJORVERSION)/14/g' Makefile && \ + make install && \ + apt-get purge -y --auto-remove git make gcc && \ + cd .. && rm -rf pgsentinel + +RUN apt-get install -y alien + +RUN cat /root/.pip/pip.conf +RUN python3 -m pip install -U setuptools +RUN python3 -m pip install psutil patroni[kubernetes,etcd]==3.3.2 psycopg2-binary==2.9.5 requests python-dateutil urllib3 six prettytable --no-cache +RUN mv /var/lib/postgresql /var/lib/pgsql + +RUN mkdir /patroni && chmod -R 777 /patroni/ && \ + chmod +x /usr/libexec/fix-permissions && \ + /usr/libexec/fix-permissions /var/run/postgresql && \ + /usr/libexec/fix-permissions /var/lib/pgsql && \ + mkdir -p /var/lib/pgsql/data/ && \ + chown -R postgres:postgres /var/lib/pgsql && \ + chmod +x /*.py && \ + chmod +x /*.sh && \ + chmod 777 /opt/scripts/archive_wal.sh && \ + ln -s /usr/bin/python3 /usr/bin/python + +# Volumes are defined to support read-only root file system +VOLUME /etc +VOLUME /patroni +VOLUME /run/postgresql + +WORKDIR /patroni +ENTRYPOINT ["/start.sh"] + +USER 26 +EXPOSE 5432 +EXPOSE 8008 diff --git a/scripts/archive_wal.sh b/scripts/archive_wal.sh new file mode 100644 index 0000000..4aa75e5 --- /dev/null +++ b/scripts/archive_wal.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +# Copyright 2024-2025 NetCracker Technology Corporation +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +p="${1}" +f="${2}" + +set +x + +export `cat /proc/1/environ | tr '\0' '\n' | grep PG_ROOT_PASSWORD` + +sha256sum -b "$p" | cut -d " " -f1 | xargs -I {} echo sha256={} | \ +python3 -c "import sys; print(chr(38) + sys.stdin.read().strip())" | \ +xargs -I SHA curl -u postgres:"${PG_ROOT_PASSWORD}" -s -S -f --connect-timeout 5 --speed-time 30 --speed-limit 100 -XPOST -F "file=@$p" postgres-backup-daemon:8082/archive/put?filename="$f"SHA \ No newline at end of file diff --git a/scripts/daemon-recovery.sh b/scripts/daemon-recovery.sh new file mode 100644 index 0000000..8f0b327 --- /dev/null +++ b/scripts/daemon-recovery.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +# Copyright 2024-2025 NetCracker Technology Corporation +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +restore_version="" + +die() { + printf '%s\n' "$1" >&2 + exit 1 +} + +while :; do + case $1 in + --restore-version) + if [ "$2" ]; then + restore_version=$2 + shift + else + echo "Proceed with empty restore_version" +# die 'ERROR: "--restore-version" requires a non-empty option argument.' + fi + ;; + --restore-version=?*) + restore_version=${1#*=} # Delete everything up to "=" and assign the remainder. + ;; + --restore-version=) + echo "Proceed with empty restore_version" +# die 'ERROR: "--restore-version" requires a non-empty option argument if specified.' + ;; + --) + shift + break + ;; + -?*) + printf 'WARN: Unknown option (ignored): %s\n' "$1" >&2 + ;; + *) + break + esac + shift +done + +cd /var/lib/pgsql/data/postgresql_${POD_IDENTITY} + + +if [[ -z "${restore_version}" ]] ; then + curl -u postgres:"${PG_ROOT_PASSWORD}" postgres-backup-daemon:8081/get | tar -xzf - +else + curl -u postgres:"${PG_ROOT_PASSWORD}" postgres-backup-daemon:8081/get?id=${restore_version} | tar -xzf - +fi \ No newline at end of file diff --git a/scripts/fix_permission.sh b/scripts/fix_permission.sh new file mode 100644 index 0000000..d287182 --- /dev/null +++ b/scripts/fix_permission.sh @@ -0,0 +1,21 @@ +#!/bin/sh +# Copyright 2024-2025 NetCracker Technology Corporation +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Fix permissions on the given directory to allow group read/write of +# regular files and execute of directories. +find "$1" -exec chown postgres {} \; +find "$1" -exec chgrp 0 {} \; +find "$1" -exec chmod g+rw {} \; +find "$1" -type d -exec chmod g+x {} + \ No newline at end of file diff --git a/scripts/pip.conf b/scripts/pip.conf new file mode 100755 index 0000000..d547ee0 --- /dev/null +++ b/scripts/pip.conf @@ -0,0 +1,4 @@ +[global] +index-url = https://pypi.org/simple +break-system-packages = true +trusted-host = pypi.org \ No newline at end of file diff --git a/scripts/populate_patroni_config.py b/scripts/populate_patroni_config.py new file mode 100644 index 0000000..48f8416 --- /dev/null +++ b/scripts/populate_patroni_config.py @@ -0,0 +1,89 @@ +# Copyright 2024-2025 NetCracker Technology Corporation +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import logging + +import sys +import yaml +# python /patroni/populate_patroni_config.py /patroni/pg_node.yml patroni/pg_conf_active.conf +import json +from utils import get_log_level + +logging.basicConfig( + level=get_log_level(), + format='[%(asctime)s][%(levelname)-5s][category=%(name)s] %(message)s', + datefmt='%Y-%m-%dT%H:%M:%S' +) +logger = logging.getLogger(__name__) + + +def is_number(s): + """ Returns True if string is a number. """ + try: + float(s) + return True + except ValueError: + return False + + +def populate_patroni_config(patroni_conf_filename, settings_conf_filename): + with open(patroni_conf_filename) as f: + patroni_conf = yaml.safe_load(f) + + # todo[anin] check utils.read_property_file instead + config_data = '' + with open(settings_conf_filename) as f: + for line in f: + if "=" in line: + param_name = line[0:line.find("=")] + param_value = line[line.find("=")+1:-1] + config_data = \ + config_data + '\n' + \ + param_name + ": " + \ + ("!!str " if not is_number(param_value) else "") + \ + param_value + else: + config_data = config_data + '\n' + line + logger.debug("Result data from config file: {}\n".format(config_data)) + conf = yaml.safe_load(config_data) + logger.debug(conf) + if conf: + params = patroni_conf['bootstrap']['dcs']['postgresql']['parameters'] + for key in conf: + value = conf[key] + if key == "log_line_prefix" and value[:1] == "\\": + value = value[1:] + + logger.debug("Apply {}={}".format(key, value)) + params[key] = value + + with open(patroni_conf_filename, mode='w') as f: + yaml.dump(patroni_conf, f, default_flow_style=False) + + +def main(): + logger.info("Try to apply provided settings to patroni config. {}" + .format(sys.argv)) + if len(sys.argv) == 3: + patroni_conf_filename = sys.argv[1] + settings_conf_filename = sys.argv[2] + populate_patroni_config(patroni_conf_filename, settings_conf_filename) + + else: + sys.exit("Usage: {0} /patroni/pg_node.yml /patroni/pg_conf_active.conf" + .format(sys.argv[0])) + + +if __name__ == '__main__': + main() diff --git a/scripts/postgresql.conf b/scripts/postgresql.conf new file mode 100644 index 0000000..b1002d6 --- /dev/null +++ b/scripts/postgresql.conf @@ -0,0 +1 @@ +include_dir '/properties' \ No newline at end of file diff --git a/scripts/prepare_settings_file.py b/scripts/prepare_settings_file.py new file mode 100644 index 0000000..a1b2c82 --- /dev/null +++ b/scripts/prepare_settings_file.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python3 +# Copyright 2024-2025 NetCracker Technology Corporation +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +import sys +import os + +import logging +from utils import read_property_file, get_log_level + +logging.basicConfig( + level=get_log_level(), + format='[%(asctime)s][%(levelname)-5s][category=%(name)s] %(message)s', + datefmt='%Y-%m-%dT%H:%M:%S' +) +logger = logging.getLogger(__name__) + +PG_USER_CONF = "/properties/postgresql.user.conf" +POSTGRESQL_VERSION = int(os.getenv("POSTGRESQL_VERSION", "96")) +RUN_PROPAGATE_SCRIPT = os.getenv("RUN_PROPAGATE_SCRIPT", "True").lower() + + +def get_parameters_from_env(): + result = {} + for key in os.environ: + if key.lower().startswith("pg_conf_"): + prop_name = (key.lower()[8:len(key)]).strip() + prop_value = (os.environ[key]).strip() + result[prop_name] = prop_value + return result + + +def get_parameters_from_user_conf(): + return read_property_file(PG_USER_CONF) + + +def main(): + logger.info("Try to prepare active properties configuration based on " + "current env and provided properties file. {}" + .format(sys.argv)) + if len(sys.argv) == 2: + target_file = sys.argv[1] + + params = { + "shared_preload_libraries": "pg_stat_statements, " + "pg_hint_plan, pg_cron", + } + + logger.debug("Default parameters: {}".format(params)) + + env_params = get_parameters_from_env() + logger.info("Parameters from env: {}".format(env_params)) + for key, value in list(env_params.items()): + params[key] = value + + logger.info("RUN_PROPAGATE_SCRIPT is set to: {}, ".format(RUN_PROPAGATE_SCRIPT)) + if RUN_PROPAGATE_SCRIPT == "true": + conf_params = get_parameters_from_user_conf() + logger.debug("Parameters from user config: {}".format(conf_params)) + for key, value in list(conf_params.items()): + params[key] = value + else: + params.pop('shared_preload_libraries', None) + + # pg_cron is required extension. + # check if it is present in shared_preload_libraries + if RUN_PROPAGATE_SCRIPT == "true": + libraries = [x.strip() for x in params.get("shared_preload_libraries", "").split(",")] + if 'pg_cron' not in libraries: + libraries.append("pg_cron") + params["shared_preload_libraries"] = ", ".join(libraries) + + logger.info("Result: {}".format(params)) + logger.debug("Target file {}".format(target_file)) + with open(target_file, mode='w') as f: + for key, value in list(params.items()): + f.write("{}={}\n".format(key, value)) + else: + sys.exit("Usage: {0} ./new.properties".format(sys.argv[0])) + + +if __name__ == '__main__': + main() diff --git a/scripts/propagate_settings.sh b/scripts/propagate_settings.sh new file mode 100755 index 0000000..e651099 --- /dev/null +++ b/scripts/propagate_settings.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# Copyright 2024-2025 NetCracker Technology Corporation +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +source /setEnv.sh + +echo "Prepare file with current properties" +python3 /prepare_settings_file.py /patroni/pg_conf_propagate.conf + +echo "Propagate to patroni file with current properties" +python3 /propagate_settings_file.py /patroni/pg_conf_propagate.conf diff --git a/scripts/propagate_settings_file.py b/scripts/propagate_settings_file.py new file mode 100644 index 0000000..7c2e9c1 --- /dev/null +++ b/scripts/propagate_settings_file.py @@ -0,0 +1,127 @@ +#!/usr/bin/env python +# Copyright 2024-2025 NetCracker Technology Corporation +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import json +import re +import subprocess +import sys +import os +import time + +from utils import read_property_file, get_host_ip, get_log_level +from utils_db import get_settings_data, get_context_data, is_values_diff, is_restart_pending, schedule_restart, patroni_restart_state +import logging +import requests + +logging.basicConfig( + level=logging.INFO, + format='[%(asctime)s][%(levelname)-5s][category=%(name)s] %(message)s', + datefmt='%Y-%m-%dT%H:%M:%S' +) +logger = logging.getLogger(__name__) + + +def main(): + + logger.info("Try to propagate property file to cluster. {}".format(sys.argv)) + if len(sys.argv) == 2: + source_file = sys.argv[1] + properties = read_property_file(source_file) + + # find properties which requires update + properties4update = {} + restart_required = False + for key, value in list(properties.items()): + current_value = get_settings_data(key) + if is_values_diff(value, current_value): + context = get_context_data(key) + logger.info(context) + if context == "internal": + logger.error("We cannot change variable of internal context: {}".format(key)) + sys.exit(1) + properties4update[key] = value + + if not properties4update: + logger.info("No properties to update") + return + logger.info("Need to update: {}".format(properties4update)) + + # form patch + # todo[anin] add parameters validation (int - max, min val; string; enum) + patch_data = {"postgresql": {"parameters": {}}} + for key, value in list(properties4update.items()): + tmp = "" + if key != 'log_line_prefix': + tmp = value.strip() + if "\\" in tmp: + tmp = tmp.replace("\\", "\\\\") + else: + if "\\" == value[:2]: + tmp = value[2:] + else: + tmp = value + + patch_data["postgresql"]["parameters"][key] = tmp # json.dumps(value) + + # send patch + # curl -i -XPATCH -d @/patroni/parameters_data http://$(hostname -i):8008/config + logger.debug("Patch prepared: {}".format(patch_data)) + user = os.getenv('PATRONI_REST_API_USER') + password = os.getenv('PATRONI_REST_API_PASSWORD') + from requests.auth import HTTPBasicAuth + basic_auth = HTTPBasicAuth(user, password) + logger.info(requests.patch( + "http://{}:8008/config".format(get_host_ip()), + data=json.dumps(patch_data), + auth=basic_auth)) + + + # todo[anin] replace with pg_settings.pending_restart check. + # There is problem - patroni updates config after restart command. + # So we cannot detect pending_restart flag until actual restart. + # for key, value in properties4update.items(): + # (current_value, unit, category, vartype, context) = get_setting_data(key) + # if is_values_differs(value, current_value, unit, vartype): + # logger.info("Schedule restart because some settings requires restart") + # schedule_restart() + # return + iterations = int(os.getenv('CHANGE_SETTINGS_RETRIES', 5)) + sleep = int(os.getenv('CHANGE_SETTINGS_INTERVAL', 3)) + if patroni_restart_state(basic_auth, iterations, sleep): + schedule_restart() + + return + + # # todo[anin] this code can be interrupted by callback executor + # # wait while value will be applied to current server + # applied = True + # for i in range(1, 60): + # applied = True + # for key, value in properties4update.items(): + # (current_value, unit, category, vartype) = get_setting_data(key) + # if is_values_differs(value, current_value, unit, vartype): + # applied = False + # sleep(1) + # break + # + # if not applied: + # sys.exit("Setting were not applied") + + else: + sys.exit("Usage: {0} ./active.properties".format(sys.argv[0])) + + +if __name__ == '__main__': + main() diff --git a/scripts/setEnv.sh b/scripts/setEnv.sh new file mode 100755 index 0000000..0c094da --- /dev/null +++ b/scripts/setEnv.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash +# Copyright 2024-2025 NetCracker Technology Corporation +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +LOG_LEVEL=${LOG_LEVEL:-info} + +if [[ -z "${POD_IDENTITY}" ]]; +then + echo "POD_IDENTITY is not defined, defaulting POD_IDENTITY" + export POD_IDENTITY="node" +fi + +ROOT_DIR_NAME="postgresql_${POD_IDENTITY}" +ROOT_DIR="/var/lib/pgsql/data/${ROOT_DIR_NAME}" + +##################################################################################################### +## Calculate PG_CONF_MAX_CONNECTIONS parameters based on merge of old and new configurations +PG_MAX_CONNECTIONS=${PG_MAX_CONNECTIONS:-200} +export PG_CONF_MAX_CONNECTIONS=${PG_CONF_MAX_CONNECTIONS:-$PG_MAX_CONNECTIONS} +echo "PG_CONF_MAX_CONNECTIONS=${PG_CONF_MAX_CONNECTIONS}" + +##################################################################################################### +## Calculate max_prepared_transactions +PG_CONF_MAX_PREPARED_TRANSACTIONS=${PG_CONF_MAX_PREPARED_TRANSACTIONS:-200} + +##################################################################################################### +## Calculate memory setting based on memory limit and max_connections settings +PG_RESOURCES_LIMIT_MEM=${PG_RESOURCES_LIMIT_MEM:-256Mi} +echo "PG_RESOURCES_LIMIT_MEM=${PG_RESOURCES_LIMIT_MEM}" + +declare -A m +# see https://kubernetes.io/docs/concepts/configuration/manage-compute-resources-container/#meaning-of-memory +m=(["ki"]=1 ["mi"]=1024 ["gi"]=1048576 ["k"]=1 ["m"]=1000 ["g"]=1000000 ) + +if [[ ${PG_RESOURCES_LIMIT_MEM} =~ ^([0-9]*)([A-Za-z]+) ]] ; then + ei=`echo ${BASH_REMATCH[2]} | tr '[:upper:]' '[:lower:]'` + LIM_VAL=${BASH_REMATCH[1]} + LIM_MULT=${m[$ei]} + _PG_RESOURCES_LIMIT_MEM_KIB=$(expr $LIM_VAL \* $LIM_MULT) +elif [[ ${PG_RESOURCES_LIMIT_MEM} =~ ^([0-9]*) ]] ; then + _PG_RESOURCES_LIMIT_MEM_KIB=$(expr ${PG_RESOURCES_LIMIT_MEM} / 1024) +else + echo "Cannot parse PG_RESOURCES_LIMIT_MEM value ${PG_RESOURCES_LIMIT_MEM}" + exit 1 +fi + +echo "_PG_RESOURCES_LIMIT_MEM_KIB=${_PG_RESOURCES_LIMIT_MEM_KIB}" +patroni_mem=$((${_PG_RESOURCES_LIMIT_MEM_KIB}>512000?102400:51200)) +_PG_AVAILABLE_KIB=$(expr ${_PG_RESOURCES_LIMIT_MEM_KIB} - ${patroni_mem}) +PG_AVAILABLE=${_PG_AVAILABLE_KIB}kB +# echo "PG_AVAILABLE=${PG_AVAILABLE}" + +_PG_CONF_SHARED_BUFFERS_KIB=$(expr ${_PG_AVAILABLE_KIB} / 4 ) +export PG_CONF_SHARED_BUFFERS=${_PG_CONF_SHARED_BUFFERS_KIB}kB +# echo "PG_CONF_SHARED_BUFFERS=${PG_CONF_SHARED_BUFFERS}" + +export PG_CONF_EFFECTIVE_CACHE_SIZE=$(expr ${_PG_AVAILABLE_KIB} - ${_PG_CONF_SHARED_BUFFERS_KIB})kB +# echo "PG_CONF_EFFECTIVE_CACHE_SIZE=${PG_CONF_EFFECTIVE_CACHE_SIZE}" + +_PG_CONF_WORK_MEM_KIB=$(expr ${_PG_CONF_SHARED_BUFFERS_KIB} / ${PG_CONF_MAX_CONNECTIONS}) +_PG_CONF_WORK_MEM_KIB=$(($_PG_CONF_WORK_MEM_KIB>64?$_PG_CONF_WORK_MEM_KIB:64)) +export PG_CONF_WORK_MEM=${_PG_CONF_WORK_MEM_KIB}kB +# echo "PG_CONF_WORK_MEM=${PG_CONF_WORK_MEM}" + +export PG_CONF_MAINTENANCE_WORK_MEM=$(expr ${_PG_CONF_SHARED_BUFFERS_KIB} / 4)kB +# echo "PG_CONF_MAINTENANCE_WORK_MEM=${PG_CONF_MAINTENANCE_WORK_MEM}" diff --git a/scripts/settings_check.sh b/scripts/settings_check.sh new file mode 100644 index 0000000..1db8af2 --- /dev/null +++ b/scripts/settings_check.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +# Copyright 2024-2025 NetCracker Technology Corporation +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +source /setEnv.sh + +echo "Prepare file with current properties" +python3 /prepare_settings_file.py /patroni/pg_conf_check.conf + +RESTART_PG=${RESTART_PG:-false} + +echo "Propagate to patroni file with current properties" +python3 /validate_settings_file.py --conf-file=/patroni/pg_conf_check.conf --restart-pg=${RESTART_PG} \ No newline at end of file diff --git a/scripts/setup_endpoint_callback.py b/scripts/setup_endpoint_callback.py new file mode 100644 index 0000000..cb680ec --- /dev/null +++ b/scripts/setup_endpoint_callback.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python +# Copyright 2024-2025 NetCracker Technology Corporation +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import subprocess +import sys +import logging +import os + +logging.basicConfig( + level=logging.INFO, + format='[%(asctime)s][%(levelname)-5s][category=%(name)s] %(message)s', + datefmt='%Y-%m-%dT%H:%M:%S' +) +logger = logging.getLogger(__name__) +RUN_PROPAGATE_SCRIPT = os.getenv("RUN_PROPAGATE_SCRIPT", "True").lower() + + +def main(): + logger.info("Start callback with parameters {}".format(sys.argv)) + if len(sys.argv) == 4: + action, role, cluster = sys.argv[1], sys.argv[2], sys.argv[3] + logger.info("Cluster name: {}, new role: {}".format(cluster, role)) + if action not in ('on_start', 'on_role_change', 'on_restart', 'on_reload'): + return + if role == "master": + logger.info("We were promoted to master. " + "Start configuration checks.") + logger.info("Triggering propagate_settings script.") + subprocess.check_call("/propagate_settings.sh") + elif role == "replica": + logger.info("Role is set to replica, " + "will terminate active applications connections") + connection_properties = { + 'host': 'localhost', + 'user': 'postgres', + } + import psycopg2 + with psycopg2.connect(**connection_properties) as conn: + with conn.cursor() as cur: + def execute_silently(query_): + logger.debug( + "Executing next query: {}".format(query_)) + try: + cur.execute(query_) + except psycopg2.Error: + logger.exception("Exception happened during " + "execution of the query") + # TODO handle pg-pool case + execute_silently(""" + select pg_terminate_backend(pid) from + pg_stat_activity where datname <> 'postgres' and + pid <> pg_backend_pid() + """) + logger.info("Connections are terminated successfully") + else: + sys.exit("Usage: {0} action role name".format(sys.argv[0])) + + +if __name__ == '__main__': + main() diff --git a/scripts/start.sh b/scripts/start.sh new file mode 100755 index 0000000..a3e4e40 --- /dev/null +++ b/scripts/start.sh @@ -0,0 +1,186 @@ +#!/usr/bin/env bash +# Copyright 2024-2025 NetCracker Technology Corporation +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +source /setEnv.sh + +export PASSWD_DIR=$(dirname ${ROOT_DIR})/passwd + + +# prepare datadir +mkdir -p ${ROOT_DIR} +chmod 700 ${ROOT_DIR} +chown $(id -u):$(id -u) ${ROOT_DIR} + + +function set_property() { + PROP_NAME=$1 + PROP_VALUE=$2 + FILE=$3 + sed -i "/^#*${PROP_NAME}[ ]*=/{h;s/.*/${PROP_NAME} = ${PROP_VALUE}/};\${x;/^\$/{s//$PROP_NAME = '$PROP_VALUE'/;H};x}" ${FILE} +} + +cur_user=$(id -u) +if [ "$cur_user" != "26" ] +then + echo "starting as not postgres user" + set -e + + echo "Adding randomly generated uid to passwd file..." + + sed -i '/postgres/d' /etc/passwd + + if ! whoami &> /dev/null; then + if [ -w /etc/passwd ]; then + echo "${USER_NAME:-postgres}:x:$(id -u):0:${USER_NAME:-postgres} user:${ROOT_DIR}:/sbin/nologin" >> /etc/passwd + fi + fi + +fi + +# removing postmaster.pid file in case if pgsql was stoped not gracefully +if [ -e "$ROOT_DIR/postmaster.pid" ] +then + echo "Removing postmaster.pid file" + rm -rf "$ROOT_DIR/postmaster.pid" + rm -rf "$ROOT_DIR/postmaster.opts" +fi + +if [[ -z "$PG_ROOT_PASSWORD" ]] ; then + echo "Cannot start cluster with empty postgres password" + exit 1 +fi + +# copy file from provided template if needed +if [[ -f /patroni-properties/patroni-config-template.yaml ]] ; then + echo "File /patroni-properties/patroni-config-template.yaml found. Will use as template for patroni configuration." + cp -f /patroni-properties/patroni-config-template.yaml /patroni/pg_template.yaml +else + echo "Cannot work without /patroni-properties/patroni-config-template.yaml. Please provide template via configmap." + exit 1 +fi + +if [[ "${DR_MODE}" =~ ^[Tt]rue$ ]]; then + PATRONI_CLUSTER_MEMBER_ID="${POD_IDENTITY}-dr" +else + PATRONI_CLUSTER_MEMBER_ID="${POD_IDENTITY}" + DR_MODE="false" +fi + +if [[ -z "${ETCD_HOST}" ]]; then + ETCD_HOST="etcd" +fi + +NODE_NAME="${POD_IDENTITY}" + +export NODE_NAME +export PATRONI_CLUSTER_MEMBER_ID +export DR_MODE +export ETCD_HOST + +if [[ -n "${FROM_SCRATCH}" ]]; then + echo "Cluster from scratch. Removing everything under ${ROOT_DIR}/*" + rm -rf ${ROOT_DIR}/* +fi + +# prepare config for patroni +PG_BIN_DIR=${PG_BIN_DIR} \ +PG_ROOT_PASSWORD=${PG_ROOT_PASSWORD} \ +PG_REPL_PASSWORD=${PG_REPL_PASSWORD} \ +LISTEN_ADDR=`hostname -i` \ +PG_CLUST_NAME=${PG_CLUST_NAME} \ +POD_NAMESPACE=${POD_NAMESPACE} \ +envsubst < /patroni/pg_template.yaml > /patroni/pg_node.yml + +echo "Prepare file with initial properties" +python3 /prepare_settings_file.py /patroni/pg_conf_initial.conf + +echo "Initial properties: " +cat /patroni/pg_conf_initial.conf + +echo "Apply properties to bootstrap section" +python3 /populate_patroni_config.py /patroni/pg_node.yml /patroni/pg_conf_initial.conf + +echo "Config result" +cat /patroni/pg_node.yml | grep -v password + +echo "Check if we have datafiles from previous start and apply required settings" + +if [[ -d /var/lib/pgsql/data/data/${ROOT_DIR_NAME} ]] ; then + echo "Find an uncommon database location" + echo "Moving it to ROOT_DIR, might take a while" + mv /var/lib/pgsql/data/data/${ROOT_DIR_NAME} /var/lib/pgsql/data/ + [[ $? != 0 ]] && echo "Something goes wrong, please check is moving correctly" || echo "Moving complete" +fi + +required_array=("shared_buffers" "effective_cache_size" "work_mem" "maintenance_work_mem") +if [[ -f ${ROOT_DIR}/postgresql.base.conf ]] ; then + echo "Set properties" + cat /patroni/pg_conf_initial.conf | while read line + do + if ! [[ ${line} =~ \s*#.* ]]; then + IFS='=' read -r pg_setting_name value <<< "${line}" + for e in "${@:required_array}"; do + if [[ "$e" == "${pg_setting_name}" ]] ; then + echo "Setting from config file: ${pg_setting_name} = ${value}" + set_property ${pg_setting_name} ${value} ${ROOT_DIR}/postgresql.base.conf; + fi + done + fi + done +fi + +if [[ -f /certs/server.crt ]] ; then + cp /certs/server.crt /patroni/server.crt && chmod 600 /patroni/server.crt + cp /certs/server.key /patroni/server.key && chmod 600 /patroni/server.key + ls -ll / +fi + +# Disable coredumps to keep PV clean and free. +ulimit -c 0 + +# Start Patroni in the background. We need background mode to be +# able to handle TERM, which is sent by docker upon stopping +# the container. This is our chance to stop DB gracefully. +PATH="${PATH}:${PG_BIN_DIR}" patroni /patroni/pg_node.yml & + +PATRONI_PID=$! +if [[ -z ${PATRONI_PID} ]] +then + echo -e "\nERROR: could not find PID of Patroni!" + exit 1 +else + echo -e "\nPatroni PID is ${PATRONI_PID}." +fi + +function exit_handler() { + echo "Received termination signal. Propagating it to Patroni..." + + # Handle both SIGINT and SIGTERM similarly, because Patroni + # shuts the database down using SIGINT both on SIGINT and SIGTERM. + kill ${PATRONI_PID} + + echo "Termination signal sent, waiting for the process to stop..." + wait ${PATRONI_PID} + echo "Patroni process terminated." + + echo "Try to collect controldata" + pg_controldata -D ${ROOT_DIR} +} + +trap exit_handler SIGINT SIGTERM + +wait ${PATRONI_PID} + diff --git a/scripts/utils.py b/scripts/utils.py new file mode 100644 index 0000000..b8cb11d --- /dev/null +++ b/scripts/utils.py @@ -0,0 +1,122 @@ +# Copyright 2024-2025 NetCracker Technology Corporation +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import os +import socket, struct +import re +import subprocess + +import logging + +import time + +comment_pattern = re.compile("\s*#.*") + + +def read_property_file(filename): + """ + Reads data from filename and parse it to dictionary. + :param filename: + :return: + :rtype: dict + """ + result = {} + with open(filename) as f: + for line in f: + if "=" in line and not comment_pattern.match(line): + param_name = (line[0:line.find("=")]).strip() + param_value = (line[line.find("=")+1:]) + if param_name != 'log_line_prefix': + param_value = param_value.strip() + else: + if param_value[:1] == '%': + param_value = "\{}".format(param_value) + param_value = param_value.lstrip() + param_value = param_value.rstrip('\n') + result[param_name] = param_value + + return result + +def is_ipv4(host): + p = re.compile("^(?:[0-9]{1,3}\.){3}[0-9]{1,3}$") + return p.match(host) + +def get_host_ip(): + IP = os.getenv("POD_IP") + if not IP: + import fcntl + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + return socket.inet_ntoa(fcntl.ioctl(s.fileno(), 0x8915, struct.pack('256s', b"eth0"[:15]))[20:24]) + else: + if is_ipv4(IP): + return IP + else: + if IP: + return "[{}]".format(IP) + +def get_log_level(): + # todo[anin] change default + # loglevel = os.getenv('LOG_LEVEL', 'info') + loglevel = os.getenv('LOG_LEVEL', 'debug') + return logging.DEBUG if loglevel == "debug" else logging.INFO + +def execute_shell_command(cmd): + p = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, shell=True) + (output, error) = p.communicate() + exit_code = p.wait() + return {'output': output.decode("utf-8"), 'exit_code': exit_code, 'error': error} + + +def retry(exceptions=None, tries=5, delay=1, backoff=1, logger=None): + """ + :param exceptions: if defined - only specified exceptions will be checked + :type exceptions: tuple of Exception or Exception + :param tries: how much to try before fail. <=0 means no limits. + :param delay: basic delay between tries + :param backoff: delay increase factor after each retry + :param logger: + :type logger: logging.Logger + :return: + """ + def deco_retry(f): + + def handle_error(e, mtries, mdelay): + msg = "Error occurred during execution: {}. Will retry in {} seconds.".format(str(e), delay) + if logger: + logger.exception(msg) + else: + print(msg) + time.sleep(mdelay) + mtries -= 1 + mdelay *= backoff + return mtries, mdelay + + def f_retry(*args, **kwargs): + mtries, mdelay = tries, delay + while tries <= 0 or mtries > 1: + if exceptions: + try: + return f(*args, **kwargs) + except exceptions as e: + mtries, mdelay = handle_error(e, mtries, mdelay) + else: + try: + return f(*args, **kwargs) + except Exception as e: + mtries, mdelay = handle_error(e, mtries, mdelay) + return f(*args, **kwargs) + + return f_retry + return deco_retry + diff --git a/scripts/utils_db.py b/scripts/utils_db.py new file mode 100644 index 0000000..6ce1eda --- /dev/null +++ b/scripts/utils_db.py @@ -0,0 +1,127 @@ +# Copyright 2024-2025 NetCracker Technology Corporation +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import re +import subprocess +import time +import logging +import psycopg2 +import requests + +from utils import get_log_level, get_host_ip, execute_shell_command + +logging.basicConfig( + level=get_log_level(), + format='[%(asctime)s][%(levelname)-5s][category=%(name)s] %(message)s', + datefmt='%Y-%m-%dT%H:%M:%S' +) +logger = logging.getLogger(__name__) + + +def get_context_data(setting_name): + conn = None + cursor = None + conn_string = "host='localhost' dbname='postgres' user='postgres' " \ + "connect_timeout=3 options='-c statement_timeout=3000'" + try: + conn = psycopg2.connect(conn_string) + cursor = conn.cursor() + cursor.execute("select context from pg_settings where name=%(sname)s", + {"sname": setting_name}) + row = cursor.fetchone() + if row: + return row[0] + else: + return None + except psycopg2.OperationalError: + return None + finally: + close_connection(cursor, conn) + # return map(lambda x: x.strip(), subprocess.check_output( + # "psql -U postgres -t -c \"select setting, unit, category, vartype from pg_settings where name='{}'\"".format(setting_name), shell=True).split("|")) + + +def get_settings_data(setting_name): + conn = None + cursor = None + conn_string = "host='localhost' dbname='postgres' user='postgres' " \ + "connect_timeout=3 options='-c statement_timeout=3000'" + try: + conn = psycopg2.connect(conn_string) + cursor = conn.cursor() + logger.info(setting_name) + cursor.execute("select current_setting (%(sname)s, 't')", + {"sname": setting_name}) + row = cursor.fetchone() + if row: + return row[0] + else: + return None + except psycopg2.OperationalError: + return None + finally: + close_connection(cursor, conn) + + +def is_restart_pending(): + conn = None + cursor = None + conn_string = "host='localhost' dbname='postgres' user='postgres' " \ + "connect_timeout=3 options='-c statement_timeout=3000'" + try: + conn = psycopg2.connect(conn_string) + cursor = conn.cursor() + cursor.execute("select count(*) from pg_settings where pending_restart = TRUE") + row = cursor.fetchone() + return int(row[0]) > 0 + except Exception as e: + logger.exception("Cannot get amount of parameters which requires restart") + raise e + finally: + close_connection(cursor, conn) + + +def schedule_restart(): + logger.debug("Schedule restart") + restart_command = "patronictl -c /patroni/pg_node.yml restart $PG_CLUST_NAME $(hostname) --force" +# res = execute_shell_command(restart_command) +# logger.debug(res) + return execute_shell_command(restart_command) + + +def close_connection(cursor, conn): + # see http://initd.org/psycopg/docs/cursor.html#cursor.closed + if cursor and not cursor.closed: + cursor.close() + # see http://initd.org/psycopg/docs/connection.html#connection.closed + if conn and conn.closed == 0: + conn.close() + + +def is_values_diff(value, db_value): + logger.debug("Start comparison for value: {}, db_value: {}" + .format(value, db_value)) + return value != db_value + + +def patroni_restart_state(basic_auth, iterations=5, sleep=3): + for i in range(iterations): + time.sleep(sleep) + r = requests.get("http://{}:8008".format(get_host_ip()), + auth=basic_auth) + logger.info("Checking restart state... It is {}".format(r.json().get('pending_restart', False))) + restart_required = r.json().get('pending_restart', False) + if restart_required: + break + return restart_required \ No newline at end of file diff --git a/scripts/validate_settings_file.py b/scripts/validate_settings_file.py new file mode 100644 index 0000000..86b6792 --- /dev/null +++ b/scripts/validate_settings_file.py @@ -0,0 +1,71 @@ +# Copyright 2024-2025 NetCracker Technology Corporation +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import sys +import os +import argparse + +from utils import read_property_file, get_log_level +from utils_db import get_settings_data, is_values_diff, is_restart_pending, schedule_restart, patroni_restart_state +import logging + +logging.basicConfig( + level=get_log_level(), + format='[%(asctime)s][%(levelname)-5s][category=%(name)s] %(message)s', + datefmt='%Y-%m-%dT%H:%M:%S' +) +logger = logging.getLogger(__name__) + + +def main(conf_file, restart_pg=False): + logger.info("Start settings validation {}".format(sys.argv)) + properties = read_property_file(conf_file) + + # find properties which requires update + properties4update = {} + for key, value in list(properties.items()): + logger.debug("Try to check setting: {} with expected value {}".format(key, value)) + (current_value) = get_settings_data(key) + logger.debug("Value from DB: {}".format(current_value)) + if is_values_diff(value, current_value): + properties4update[key] = value + + if not properties4update: + logger.info("No properties to update") + return + user = os.getenv('PATRONI_REST_API_USER') + password = os.getenv('PATRONI_REST_API_PASSWORD') + from requests.auth import HTTPBasicAuth + basic_auth = HTTPBasicAuth(user, password) + + if patroni_restart_state(basic_auth): + logger.info("Schedule restart because some settings requires restart and restart_pg is true") + schedule_restart() + sys.exit(1) + else: + sys.exit(0) + + + +if __name__ == '__main__': + parser = argparse.ArgumentParser(description='Validation procedure') + parser.add_argument('--conf-file', dest='conf_file', default=None, required=True, + help='path to file with postgresql settings') + parser.add_argument('--restart-pg', dest='restart_pg', default='false', + help='Restart postgres if there are settings which requires restart') + + args = parser.parse_args() + + main(args.conf_file, args.restart_pg == "true") +