From 75b3f77472f7da5a057eaf58267d0eee259afd33 Mon Sep 17 00:00:00 2001 From: Ihor Kalnytskyi Date: Tue, 3 Jan 2023 17:43:44 +0200 Subject: [PATCH] BREAKING CHANGE: don't set connection env vars The 'setup-postgres' action used to set libpq environment variables [1] with connection parameters so the PostgreSQL client applications [2], such as 'psql' or 'createuser', won't require any configuration before using. Unfortunately these libpq environment variables are also used by all libpq clients including database drivers such as 'psycopg'. While this may sound good, it may as well lead to undesired behaviour and unobvious issues when connection parameters are automatically pulled from environment but most not. Nevertheless, the need to easy usage of the client applications [2] is indisputable because providing a bunch of connection parameters all the time is tedious. Therefore this patch pushes requires connection parameters to the connection service file [3], so the client applications can pull the data on-demand. E.g: $ psql service=superuser -c "SELECT 1;" $ PGSERVICE=superuser createuser myuser [1] https://www.postgresql.org/docs/15/libpq-envars.html [2] https://www.postgresql.org/docs/15/reference-client.html [3] https://www.postgresql.org/docs/15/libpq-pgservice.html --- .github/workflows/ci.yml | 4 +++ README.md | 53 +++++++++++++++++++++++++++++++++++++++- action.yml | 47 ++++++++++++++++++++++------------- test_action.py | 48 ++++++++++++++++++++++++++++++------ 4 files changed, 126 insertions(+), 26 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 04dbe1daf..e467f0841 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,7 +30,9 @@ jobs: python3 -m pytest -vv test_action.py env: CONNECTION_URI: ${{ steps.postgres.outputs.connection-uri }} + SERVICE_NAME: ${{ steps.postgres.outputs.service-name }} EXPECTED_CONNECTION_URI: postgresql://postgres:postgres@localhost:5432/postgres + EXPECTED_SERVICE_NAME: postgres shell: bash parametrized: @@ -59,5 +61,7 @@ jobs: python3 -m pytest -vv test_action.py env: CONNECTION_URI: ${{ steps.postgres.outputs.connection-uri }} + SERVICE_NAME: ${{ steps.postgres.outputs.service-name }} EXPECTED_CONNECTION_URI: postgresql://yoda:GrandMaster@localhost:34837/jedi_order + EXPECTED_SERVICE_NAME: yoda shell: bash diff --git a/README.md b/README.md index 5d2c09794..ff390b266 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,7 @@ key features: | Username | `postgres` | | Password | `postgres` | | Database | `postgres` | +| Service | `postgres` | #### User permissions @@ -53,7 +54,57 @@ steps: - run: pytest -vv tests/ env: - DATABASE_URI: ${{ steps.postgres.outputs.connection-uri }} + CONNECTION_STR: ${{ steps.postgres.outputs.connection-uri }} + + - run: pytest -vv tests/ + env: + CONNECTION_STR: service=${{ steps.postgres.outputs.service-name }} +``` + +## Recipes + +#### Create a new user w/ database via CLI + +```yaml +steps: + - uses: ikalnytskyi/action-setup-postgres@v3 + + - run: | + createuser myuser + createdb --owner myuser mydatabase + psql -c "ALTER USER myuser WITH PASSWORD 'mypassword'" + + env: + # This activates connection parameters for the superuser created by + # the action in the step above. It's mandatory to set this before using + # createuser/psql/etc tools. + # + # The service name is the same as the username (i.e. 'postgres') but + # it's recommended to use action's output to get the name in order to + # be forward compatible. + PGSERVICE: ${{ steps.postgres.outputs.service-name }} + shell: bash +``` + +#### Create a new user w/ database via psycopg + +```yaml +steps: + - uses: ikalnytskyi/action-setup-postgres@v3 +``` + +```python +import psycopg + +# 'postgres' is the username here, but it's recommended to use the +# action's 'service-name' output parameter here. +connection = psycopg.connect("service=postgres") + +# CREATE/DROP USER statements don't work within transactions, and with +# autocommit disabled transactions are created by psycopg automatically. +connection.autocommit = True +connection.execute(f"CREATE USER myuser WITH PASSWORD 'mypassword'") +connection.execute(f"CREATE DATABASE mydatabase WITH OWNER 'myuser'") ``` ## Rationale diff --git a/action.yml b/action.yml index 5d8d471c4..cf33b14cf 100644 --- a/action.yml +++ b/action.yml @@ -24,7 +24,10 @@ inputs: outputs: connection-uri: description: The connection URI to connect to PostgreSQL. - value: ${{ steps.connection-uri.outputs.value }} + value: ${{ steps.set-outputs.outputs.connection-uri }} + service-name: + description: The service name with connection parameters. + value: ${{ steps.set-outputs.outputs.service-name }} runs: using: composite steps: @@ -71,21 +74,27 @@ runs: echo "port = ${{ inputs.port }}" >> "$PGDATA/postgresql.conf" pg_ctl start - # Set environment variables for PostgreSQL client applications [1] such - # as 'psql' or 'createuser'. + # Save required connection parameters for created superuser to the + # connection service file [1]. This allows using these connection + # parameters by setting 'PGSERVICE' environment variable or by + # requesting them via connection string. # - # PGHOST is required for Linux/macOS because we turned off unix sockets - # and they use them by default. + # HOST is required for Linux/macOS because these OS-es default to unix + # sockets but we turned them off. # - # PGPORT, PGUSER, PGPASSWORD and PGDATABASE are required because they - # could be parametrized via action input parameters. + # PORT, USER, PASSWORD and DBNAME are required because they could be + # parametrized via action input parameters. # - # [1] https://www.postgresql.org/docs/15/reference-client.html - echo "PGHOST=localhost" >> $GITHUB_ENV - echo "PGPORT=${{ inputs.port }}" >> $GITHUB_ENV - echo "PGUSER=${{ inputs.username }}" >> $GITHUB_ENV - echo "PGPASSWORD=${{ inputs.password }}" >> $GITHUB_ENV - echo "PGDATABASE=${{ inputs.database }}" >> $GITHUB_ENV + # [1] https://www.postgresql.org/docs/15/libpq-pgservice.html + cat < "$PGDATA/pg_service.conf" + [${{ inputs.username }}] + host=localhost + port=${{ inputs.port }} + user=${{ inputs.username }} + password=${{ inputs.password }} + dbname=${{ inputs.database }} + EOF + echo "PGSERVICEFILE=$PGDATA/pg_service.conf" >> $GITHUB_ENV shell: bash - name: Setup PostgreSQL database @@ -97,11 +106,15 @@ runs: if [ "${{ inputs.database }}" != "postgres" ]; then createdb -O "${{ inputs.username }}" "${{ inputs.database }}" fi + env: + PGSERVICE: ${{ inputs.username }} shell: bash - - name: Expose connection URI + - name: Set action outputs run: | - CONNECTION_URI="postgresql://${{ inputs.username }}:${{ inputs.password }}@localhost:${{inputs.port}}/${{ inputs.database }}" - echo "value=$CONNECTION_URI" >> $GITHUB_OUTPUT + CONNECTION_URI="postgresql://${{ inputs.username }}:${{ inputs.password }}@localhost:${{ inputs.port }}/${{ inputs.database }}" + + echo "connection-uri=$CONNECTION_URI" >> $GITHUB_OUTPUT + echo "service-name=${{ inputs.username }}" >> $GITHUB_OUTPUT shell: bash - id: connection-uri + id: set-outputs diff --git a/test_action.py b/test_action.py index 50b80c8c5..ebf955135 100644 --- a/test_action.py +++ b/test_action.py @@ -21,6 +21,16 @@ def connection_uri() -> str: return connection_uri +@pytest.fixture(scope="function") +def service_name() -> str: + """Read and return connection URI from environment.""" + + service_name = os.getenv("SERVICE_NAME") + if service_name is None: + pytest.fail("SERVICE_NAME: environment variable is not set") + return service_name + + @pytest.fixture(scope="function") def connection_factory() -> ConnectionFactory: """Return 'psycopg.Connection' factory.""" @@ -30,19 +40,32 @@ def factory(connection_uri: str) -> psycopg.Connection: return factory -@pytest.fixture(scope="function") -def connection(connection_uri: str, connection_factory: ConnectionFactory) -> psycopg.Connection: +@pytest.fixture(scope="function", params=["uri", "kv-string"]) +def connection( + request: pytest.FixtureRequest, + connection_factory: ConnectionFactory, + connection_uri: str, + service_name: str, +) -> psycopg.Connection: """Return 'psycopg.Connection' for connection URI set in environment.""" - return connection_factory(connection_uri) + if request.param == "uri": + return connection_factory(connection_uri) + elif request.param == "kv-string": + return connection_factory(f"service={service_name}") + raise RuntimeError("f{request.param}: unknown value") -def test_connection_uri(): +def test_connection_uri(connection_uri): """Test that CONNECTION_URI matches EXPECTED_CONNECTION_URI.""" - connection_uri = os.getenv("CONNECTION_URI") - expected_connection_uri = os.getenv("EXPECTED_CONNECTION_URI") - assert connection_uri == expected_connection_uri + assert connection_uri == os.getenv("EXPECTED_CONNECTION_URI") + + +def test_service_name(service_name): + """Test that SERVICE_NAME matches EXPECTED_SERVICE_NAME.""" + + assert service_name == os.getenv("EXPECTED_SERVICE_NAME") def test_server_encoding(connection: psycopg.Connection): @@ -146,9 +169,18 @@ def test_user_create_drop_user( connection.execute(f"DROP USER {username}") -def test_client_applications(connection_factory: ConnectionFactory, connection_uri: str): +def test_client_applications( + connection_factory: ConnectionFactory, + service_name: str, + connection_uri: str, + monkeypatch: pytest.MonkeyPatch, +): """Test that PostgreSQL client applications can be used.""" + # Request connection parameters from the connection service file prepared + # by our action. + monkeypatch.setenv("PGSERVICE", service_name) + username = "us3rname" password = "passw0rd" database = "databas3"