From acc2f7f7a6db0a33365bd8fbf60b5ab0f4d4224c Mon Sep 17 00:00:00 2001 From: Yuri Astrakhan Date: Sat, 29 Oct 2022 14:40:16 -0400 Subject: [PATCH 01/13] Use `$GITHUB_OUTPUT` instead of `::set-output` The `::set-output` command has been deprecated and is scheduled for removal on Jun 1, 2023 [1]. The recommended way to set output parameters today is by writing them to `$GITHUB_OUTPUT` file [2]. [1] https://github.blog/changelog/2022-10-11-github-actions-deprecating-save-state-and-set-output-commands/ [2] https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions#setting-an-output-parameter Resolves: #6 --- action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/action.yml b/action.yml index dbd3ead..a6b4664 100644 --- a/action.yml +++ b/action.yml @@ -79,6 +79,6 @@ runs: - name: Expose connection URI run: | CONNECTION_URI="postgresql://${{ inputs.username }}:${{ inputs.password }}@localhost:${{inputs.port}}/${{ inputs.database }}" - echo ::set-output name=value::$CONNECTION_URI + echo "value=$CONNECTION_URI" >> $GITHUB_OUTPUT shell: bash id: connection-uri From f9abc1d65cf831e8d3f83f180b4150a38baec248 Mon Sep 17 00:00:00 2001 From: Ihor Kalnytskyi Date: Mon, 2 Jan 2023 21:40:05 +0200 Subject: [PATCH 02/13] BREAKING CHANGE: create superuser The 'setup-postgres' action used to create a superuser that hasn't been exposed to users via 'connection-uri' output. The superuser has been named after GitHub Action's system user (i.e. 'runner'), had no password and could have been used via PostgreSQL client applications [1] or when using manually constructed connection URI with no user set. The user set via action's input parameters used to be unprivileged with escalated permissions to create databases on-demand. I don't remember why I made things this way, maybe I got confused somewhere along the way, but I don't think having both private superuser and public unprivileged user is a good idea. It's quite common in tests to dynamically create databases and/or users for applications under test, thus superuser permissions are required. This patch removes a private superuser named after the GitHub Action's system user (i.e. 'runner') in favor of granting superuser permissions to a user set via action's input parameters. Those who explicitly relied on 'runner' user might got affected as the user WON'T exist anymore. [1] https://www.postgresql.org/docs/15/reference-client.html --- .github/workflows/ci.yml | 4 +- README.md | 26 ++++++++-- action.yml | 54 +++++++++++++-------- test_action.py | 100 ++++++++++++++++++++++++++++++++++----- 4 files changed, 145 insertions(+), 39 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fb5e63a..04dbe1d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,7 +26,7 @@ jobs: - name: Run tests run: | - python3 -m pip install --upgrade pip pytest psycopg + python3 -m pip install --upgrade pip pytest psycopg furl python3 -m pytest -vv test_action.py env: CONNECTION_URI: ${{ steps.postgres.outputs.connection-uri }} @@ -55,7 +55,7 @@ jobs: - name: Run tests run: | - python3 -m pip install --upgrade pip pytest psycopg + python3 -m pip install --upgrade pip pytest psycopg furl python3 -m pytest -vv test_action.py env: CONNECTION_URI: ${{ steps.postgres.outputs.connection-uri }} diff --git a/README.md b/README.md index 8350a38..5d2c097 100644 --- a/README.md +++ b/README.md @@ -3,17 +3,19 @@ This action sets up a PostgreSQL server for the rest of the job. Here are some key features: -* Runs on Linux, macOS and Windows runners. -* Adds PostgreSQL [binaries][1] (e.g. `psql`) to `PATH`. -* Uses PostgreSQL installed in [GitHub Actions Virtual Environments][2]. -* [Easy to check][3] that IT DOES NOT contain malicious code. +* Runs on Linux, macOS and Windows action runners. +* Adds PostgreSQL [client applications][1] to `PATH`. +* Uses PostgreSQL binaries baked into [GitHub Actions Runner Images][2]. +* Easy [to prove][3] that it DOES NOT contain malicious code. [1]: https://www.postgresql.org/docs/current/reference-client.html -[2]: https://github.com/actions/virtual-environments +[2]: https://github.com/actions/runner-images [3]: action.yml ## Usage +#### Connection parameters + | Key | Value | |----------|-----------------------------------------------------| | URI | `postgresql://postgres:postgres@localhost/postgres` | @@ -23,6 +25,13 @@ key features: | Password | `postgres` | | Database | `postgres` | +#### User permissions + +| Key | Value | +|-------------|-------| +| usesuper | true | +| usecreatedb | true | + #### Basic ```yaml @@ -47,6 +56,13 @@ steps: DATABASE_URI: ${{ steps.postgres.outputs.connection-uri }} ``` +## Rationale + +At the time of developing there were no GitHub Actions on the marketplace to +setup a PostgreSQL server on Linux, Windows and macOS action runners. Most +solutions suggest using Docker which is not available on macOS and Windows +runners. + ## License The scripts and documentation in this project are released under the diff --git a/action.yml b/action.yml index a6b4664..45f5407 100644 --- a/action.yml +++ b/action.yml @@ -38,42 +38,56 @@ runs: fi shell: bash - - name: Setup and start PostgreSQL run: | export PGDATA="$RUNNER_TEMP/pgdata" - pg_ctl init --options="--encoding=UTF-8 --locale=en_US.UTF-8" - # Forbid creating unix sockets since they are created by default in the - # directory we don't have permissions to. + # There are couple of reasons why we need to create a new PostgreSQL + # database cluster. First and foremost, we have to create a superuser + # with provided credentials. Second, we want the PostgreSQL client + # applications [1] to be available for execution without + # run-from-another-user dances. Third, we want to make sure that + # settings are the same between operating systems and aren't changed by + # package vendors. + # + # [1] https://www.postgresql.org/docs/15/reference-client.html + initdb \ + --username="${{ inputs.username }}" \ + --encoding="UTF-8" \ + --locale="en_US.UTF-8" \ + --no-instructions + + # Do not create unix sockets since they are created by default in the + # directory we have no permissions to (owned by system postgres user). echo "unix_socket_directories = ''" >> "$PGDATA/postgresql.conf" echo "port = ${{ inputs.port }}" >> "$PGDATA/postgresql.conf" pg_ctl start - # Both PGHOST and PGUSER are used by PostgreSQL tooling such as 'psql' - # or 'createuser'. Since PostgreSQL data has been resetup, we cannot - # rely on defaults anymore. + # Set environment variables for PostgreSQL client applications [1] such + # as 'psql' or 'createuser'. # - # PGHOST is required for Linux and macOS since they default to unix - # sockets, and we have turned them off. + # PGHOST is required for Linux/macOS because we turned off unix sockets + # and they use them by default. # - # PGUSER is required for Windows since default the tooling's default - # user is 'postgres', while 'pg_ctl init' creates one with the name of - # the current user. + # PGPORT, PGUSER and PGDATABASE 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 "PGUSER=${USER:-$USERNAME}" >> $GITHUB_ENV echo "PGPORT=${{ inputs.port }}" >> $GITHUB_ENV + echo "PGUSER=${{ inputs.username }}" >> $GITHUB_ENV + echo "PGDATABASE=${{ inputs.database }}" >> $GITHUB_ENV shell: bash - - name: Setup PostgreSQL user and database + - name: Setup PostgreSQL database run: | - createuser --createdb ${{ inputs.username }} - - if [ "${{ inputs.database}}" != "postgres" ]; then - createdb -O ${{ inputs.username }} ${{ inputs.database }} + # The 'postgres' database is a pre-created database meant for use by + # users, utilities and third party applications. There's no way to + # parametrize the name, so all we can do is to avoid creating a + # database if provided name is 'postgres'. + if [ "${{ inputs.database }}" != "postgres" ]; then + createdb -O "${{ inputs.username }}" "${{ inputs.database }}" fi - - psql -c "ALTER USER ${{ inputs.username }} PASSWORD '${{ inputs.password }}';" ${{ inputs.database }} shell: bash - name: Expose connection URI diff --git a/test_action.py b/test_action.py index 057ba24..e59ad62 100644 --- a/test_action.py +++ b/test_action.py @@ -1,20 +1,40 @@ -import typing as t +import locale import os +import subprocess +import typing as t import psycopg +import furl import pytest +ConnectionFactory = t.Callable[[str], psycopg.Connection] + + +@pytest.fixture(scope="function") +def connection_uri() -> str: + """Read and return connection URI from environment.""" + + connection_uri = os.getenv("CONNECTION_URI") + if connection_uri is None: + pytest.fail("CONNECTION_URI: environment variable is not set") + return connection_uri + + @pytest.fixture(scope="function") -def connection_factory() -> t.Callable[[str], psycopg.Connection]: +def connection_factory() -> ConnectionFactory: + """Return 'psycopg.Connection' factory.""" + def factory(connection_uri: str) -> psycopg.Connection: return psycopg.connect(connection_uri) return factory @pytest.fixture(scope="function") -def connection(connection_factory) -> psycopg.Connection: - return connection_factory(os.getenv("CONNECTION_URI")) +def connection(connection_uri: str, connection_factory: ConnectionFactory) -> psycopg.Connection: + """Return 'psycopg.Connection' for connection URI set in environment.""" + + return connection_factory(connection_uri) def test_connection_uri(): @@ -34,12 +54,15 @@ def test_server_encoding(connection: psycopg.Connection): def test_locale(connection: psycopg.Connection): """Test that PostgreSQL's locale is 'en_US.UTF-8'.""" - assert connection.execute("SHOW LC_COLLATE").fetchone()[0] == "en_US.UTF-8" - assert connection.execute("SHOW LC_CTYPE").fetchone()[0] == "en_US.UTF-8" + lc_collate = connection.execute("SHOW LC_COLLATE").fetchone()[0] + lc_ctype = connection.execute("SHOW LC_CTYPE").fetchone()[0] + + assert locale.normalize(lc_collate) == "en_US.UTF-8" + assert locale.normalize(lc_ctype) == "en_US.UTF-8" def test_user_permissions(connection: psycopg.Connection): - """Test that a user can create databases but is not a superuser.""" + """Test that a user has super/createdb permissions.""" with connection: record = connection \ @@ -49,7 +72,7 @@ def test_user_permissions(connection: psycopg.Connection): usecreatedb, usesuper = record assert usecreatedb - assert not usesuper + assert usesuper def test_user_create_insert_select(connection: psycopg.Connection): @@ -82,12 +105,65 @@ def test_user_create_insert_non_ascii(connection: psycopg.Connection): def test_user_create_drop_database(connection: psycopg.Connection): - """Test that a user has no permissions to create databases.""" + """Test that a user has permissions to create databases.""" # CREATE/DROP DATABASE statements don't work within transactions, and with # autocommit disabled transactions are created by psycopg automatically. connection.autocommit = True - database_name = "foobar42" - connection.execute(f"CREATE DATABASE {database_name}") - connection.execute(f"DROP DATABASE {database_name}") + database = "databas3" + connection.execute(f"CREATE DATABASE {database}") + connection.execute(f"DROP DATABASE {database}") + + +def test_user_create_drop_user( + connection: psycopg.Connection, + connection_factory: ConnectionFactory, + connection_uri: str +): + """Test that a user has permissions to create users.""" + + # CREATE/DROP USER statements don't work within transactions, and with + # autocommit disabled transactions are created by psycopg automatically. + connection.autocommit = True + + username = "us3rname" + password = "passw0rd" + database = "databas3" + + connection.execute(f"CREATE USER {username} WITH PASSWORD '{password}'") + connection.execute(f"CREATE DATABASE {database} WITH OWNER '{username}'") + + try: + # Smoke test that created user can successfully log-in and execute + # queries for its own database. + connection_uri = furl.furl( + connection_uri, username=username, password=password, path=database).url + test_user_create_insert_select(connection_factory(connection_uri)) + + finally: + connection.execute(f"DROP DATABASE {database}") + connection.execute(f"DROP USER {username}") + + +def test_client_applications(connection_uri, connection_factory): + """Test that PostgreSQL client applications can be used.""" + + username = "us3rname" + password = "passw0rd" + database = "databas3" + + subprocess.check_call(["createuser", username]) + subprocess.check_call(["createdb", "--owner", username, database]) + subprocess.check_call(["psql", "-c", f"ALTER USER {username} WITH PASSWORD '{password}'"]) + + try: + # Smoke test that created user can successfully log-in and execute + # queries for its own database. + connection_uri = furl.furl( + connection_uri, username=username, password=password, path=database).url + test_user_create_insert_select(connection_factory(connection_uri)) + + finally: + subprocess.check_call(["dropdb", database]) + subprocess.check_call(["dropuser", username]) From f02428f92250994ae6f47d0d11a768c17c88b0af Mon Sep 17 00:00:00 2001 From: Ihor Kalnytskyi Date: Tue, 3 Jan 2023 16:50:26 +0200 Subject: [PATCH 03/13] BREAKING CHANGE: enforce password authentication It turns out that PostgreSQL comes with weird default that allows passwordless authentication for localhost connections [1]. This essentially means that 'password' input parameter for this action was ignored. The 'setup-postgres' action's primary use case is to be used on CI where most of the time authentication is desired in order to verify that passwords are passed correctly from applications under test. This patch enforces password authentication even for localhost connections, making sure that passwords are verified and not ignored. This will break everyone who previously passed wrong password or didn't pass it at all. [1] https://www.postgresql.org/docs/15/auth-trust.html Fixes: #5 --- action.yml | 13 +++++++++++-- test_action.py | 25 ++++++++++++++++++++++++- 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/action.yml b/action.yml index 45f5407..5d8d471 100644 --- a/action.yml +++ b/action.yml @@ -41,6 +41,12 @@ runs: - name: Setup and start PostgreSQL run: | export PGDATA="$RUNNER_TEMP/pgdata" + export PWFILE="$RUNNER_TEMP/pwfile" + + # Unfortunately 'initdb' could only receive a password via file on disk + # or prompt to enter on. Prompting is not an option since we're running + # in non-interactive mode. + echo '${{ inputs.password }}' > $PWFILE # There are couple of reasons why we need to create a new PostgreSQL # database cluster. First and foremost, we have to create a superuser @@ -53,6 +59,8 @@ runs: # [1] https://www.postgresql.org/docs/15/reference-client.html initdb \ --username="${{ inputs.username }}" \ + --pwfile="$PWFILE" \ + --auth="scram-sha-256" \ --encoding="UTF-8" \ --locale="en_US.UTF-8" \ --no-instructions @@ -69,13 +77,14 @@ runs: # PGHOST is required for Linux/macOS because we turned off unix sockets # and they use them by default. # - # PGPORT, PGUSER and PGDATABASE are required because they could be - # parametrized via action input parameters. + # PGPORT, PGUSER, PGPASSWORD and PGDATABASE 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 shell: bash diff --git a/test_action.py b/test_action.py index e59ad62..50b80c8 100644 --- a/test_action.py +++ b/test_action.py @@ -146,7 +146,7 @@ def test_user_create_drop_user( connection.execute(f"DROP USER {username}") -def test_client_applications(connection_uri, connection_factory): +def test_client_applications(connection_factory: ConnectionFactory, connection_uri: str): """Test that PostgreSQL client applications can be used.""" username = "us3rname" @@ -167,3 +167,26 @@ def test_client_applications(connection_uri, connection_factory): finally: subprocess.check_call(["dropdb", database]) subprocess.check_call(["dropuser", username]) + + +def test_auth_wrong_username(connection_factory: ConnectionFactory, connection_uri: str): + """Test that wrong username is rejected!""" + + connection_furl = furl.furl(connection_uri, username="wrong") + + with pytest.raises(psycopg.OperationalError) as excinfo: + connection_factory(connection_furl.url) + + assert 'password authentication failed for user "wrong"' in str(excinfo.value) + + +def test_auth_wrong_password(connection_factory: ConnectionFactory, connection_uri: str): + """Test that wrong password is rejected!""" + + connection_furl = furl.furl(connection_uri, password="wrong") + username = connection_furl.username + + with pytest.raises(psycopg.OperationalError) as excinfo: + connection_factory(connection_furl.url) + + assert f'password authentication failed for user "{username}"' in str(excinfo.value) From 75b3f77472f7da5a057eaf58267d0eee259afd33 Mon Sep 17 00:00:00 2001 From: Ihor Kalnytskyi Date: Tue, 3 Jan 2023 17:43:44 +0200 Subject: [PATCH 04/13] 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 04dbe1d..e467f08 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 5d2c097..ff390b2 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 5d8d471..cf33b14 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 50b80c8..ebf9551 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" From 814fad8e06f7409a87197f5c5d0944de316f0f11 Mon Sep 17 00:00:00 2001 From: Ihor Kalnytskyi Date: Wed, 4 Jan 2023 02:40:58 +0200 Subject: [PATCH 05/13] Use '@v4' tag in usage examples --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index ff390b2..8562595 100644 --- a/README.md +++ b/README.md @@ -37,14 +37,14 @@ key features: ```yaml steps: - - uses: ikalnytskyi/action-setup-postgres@v3 + - uses: ikalnytskyi/action-setup-postgres@v4 ``` #### Advanced ```yaml steps: - - uses: ikalnytskyi/action-setup-postgres@v3 + - uses: ikalnytskyi/action-setup-postgres@v4 with: username: ci password: sw0rdfish @@ -67,7 +67,7 @@ steps: ```yaml steps: - - uses: ikalnytskyi/action-setup-postgres@v3 + - uses: ikalnytskyi/action-setup-postgres@v4 - run: | createuser myuser @@ -90,7 +90,7 @@ steps: ```yaml steps: - - uses: ikalnytskyi/action-setup-postgres@v3 + - uses: ikalnytskyi/action-setup-postgres@v4 ``` ```python From 6f936810594c57c6112e722df9b297c581c5d738 Mon Sep 17 00:00:00 2001 From: Ihor Kalnytskyi Date: Fri, 29 Dec 2023 22:05:18 +0200 Subject: [PATCH 06/13] CI: Bump actions/checkout to v4 The checkout v2, the version currently in use on CI, depends on node12 which is going to be removed soon. Let's use the latest version in order to get rid of deprecation warnings in logs and remain compatible with GitHun runners. --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e467f08..8ca8e8b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,7 +18,7 @@ jobs: - windows-latest - macos-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - name: Run setup-postgres uses: ./ @@ -44,7 +44,7 @@ jobs: - windows-latest - macos-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - name: Run setup-postgres uses: ./ From 6fd93bdef2ccfa8cb3a27c021d807cfb4c4b6509 Mon Sep 17 00:00:00 2001 From: Ihor Kalnytskyi Date: Sat, 6 Jan 2024 00:54:18 +0200 Subject: [PATCH 07/13] Add macOS 13 support The macOS 13 runner image isn't shipped with pre-installed PostgreSQL server. Even though it has Beta status, it'd be nice to support this runner image too. Reported-by: @baconcheese113 Fixes: #16 --- .github/workflows/ci.yml | 1 + action.yml | 8 ++++++++ 2 files changed, 9 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8ca8e8b..2d64642 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,6 +17,7 @@ jobs: - ubuntu-latest - windows-latest - macos-latest + - macos-13 steps: - uses: actions/checkout@v4 diff --git a/action.yml b/action.yml index cf33b14..3ca099c 100644 --- a/action.yml +++ b/action.yml @@ -38,6 +38,14 @@ runs: elif [ "$RUNNER_OS" == "Windows" ]; then echo "$PGBIN" >> $GITHUB_PATH echo "PQ_LIB_DIR=$PGROOT\lib" >> $GITHUB_ENV + elif [ "$RUNNER_OS" == "macOS" ]; then + case "$(sw_vers -productVersion)" in + 13.*) + # Unfortunately, the macOS 13 runner image doesn't come w/ + # pre-installed PostgreSQL server. + brew install postgresql@14 + ;; + esac fi shell: bash From 11ff483e7e18cd069a38fe51cf98cafd6232ad89 Mon Sep 17 00:00:00 2001 From: Ihor Kalnytskyi Date: Sat, 6 Jan 2024 12:55:42 +0200 Subject: [PATCH 08/13] Unset PG* env vars except PGSERVICEFILE Unfortunately, the Windows runner has some PostgreSQL environment variables set, which are neither set for Linux or macOS runners. This is may be especially confusing since variables such as PGUSER or PGPASSWORD may point to a user that doesn't exist. Since one of the design decisions made previously is to restrain this action from pointing to any user by default, we better unset these environment variables to avoid confusion. In addition to that change, let's also mention in the README file that it's up to users to set connection parameters whatever way they prefer. Reported-by: bkoelman Fixes: #17 --- README.md | 16 ++++++++++++++-- action.yml | 7 +++++++ test_action.py | 38 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 59 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 8562595..276eda5 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,19 @@ key features: ## Usage +> [!IMPORTANT] +> +> In order to connect to a PostgreSQL server, use either connection parameters +> from the table below ([link](#connection-parameters)), or retrieve a +> connection URI from the `connection-uri` output ([link](#advanced)). + +> [!TIP] +> +> `libpq`-using applications may choose to set the `PGSERVICE=postgres` +> environment variable instead ([link](#create-a-new-user-w-database-via-cli)), +> where `postgres` is the service name extracted from the `service-name` +> output. + #### Connection parameters | Key | Value | @@ -73,11 +86,10 @@ steps: 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. + # createuser/psql and other libpq-using applications. # # 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 diff --git a/action.yml b/action.yml index 3ca099c..456b2cd 100644 --- a/action.yml +++ b/action.yml @@ -38,6 +38,13 @@ runs: elif [ "$RUNNER_OS" == "Windows" ]; then echo "$PGBIN" >> $GITHUB_PATH echo "PQ_LIB_DIR=$PGROOT\lib" >> $GITHUB_ENV + + # The Windows runner has some PostgreSQL environment variables set + # that may confuse users since they may be irrelevant to the + # PostgreSQL server we're using. + for name in "PGROOT" "PGDATA" "PGBIN" "PGUSER" "PGPASSWORD"; do + echo "$name=" >> $GITHUB_ENV + done elif [ "$RUNNER_OS" == "macOS" ]; then case "$(sw_vers -productVersion)" in 13.*) diff --git a/test_action.py b/test_action.py index ebf9551..a1f4540 100644 --- a/test_action.py +++ b/test_action.py @@ -1,5 +1,6 @@ import locale import os +import pathlib import subprocess import typing as t @@ -84,6 +85,30 @@ def test_locale(connection: psycopg.Connection): assert locale.normalize(lc_ctype) == "en_US.UTF-8" +def test_environment_variables(): + """Test that only expected 'PG*' variables are set.""" + + pg_environ = {k: v for k, v in os.environ.items() if k.startswith("PG")} + + # In case of Windows, there might be a mix of forward and backward slashes + # as separators. So let's compare paths semantically instead. + pg_servicefile = pathlib.Path(pg_environ.pop("PGSERVICEFILE", "")) + pg_servicefile_exp = pathlib.Path(os.environ["RUNNER_TEMP"], "pgdata", "pg_service.conf") + assert pg_servicefile.resolve() == pg_servicefile_exp.resolve() + + if os.name == "nt": + pg_environ_exp = { + "PGBIN": "", + "PGDATA": "", + "PGPASSWORD": "", + "PGROOT": "", + "PGUSER": "", + } + else: + pg_environ_exp = {} + assert pg_environ == pg_environ_exp + + def test_user_permissions(connection: psycopg.Connection): """Test that a user has super/createdb permissions.""" @@ -201,6 +226,19 @@ def test_client_applications( subprocess.check_call(["dropuser", username]) +def test_libpq_applications(service_name: str, monkeypatch: pytest.MonkeyPatch): + """Test that libpq-using applications can be used.""" + + # Request connection parameters from the connection service file prepared + # by our action. + monkeypatch.setenv("PGSERVICE", service_name) + + with psycopg.connect() as connection: + assert connection \ + .execute("SELECT usename FROM pg_user WHERE usename = CURRENT_USER") \ + .fetchone() + + def test_auth_wrong_username(connection_factory: ConnectionFactory, connection_uri: str): """Test that wrong username is rejected!""" From 74e396491e76c84f8ad92a4f5a69ab0deb75bbc2 Mon Sep 17 00:00:00 2001 From: Ihor Kalnytskyi Date: Wed, 10 Jan 2024 01:19:06 +0200 Subject: [PATCH 09/13] Bump version to v5 --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 276eda5..6556e92 100644 --- a/README.md +++ b/README.md @@ -50,14 +50,14 @@ key features: ```yaml steps: - - uses: ikalnytskyi/action-setup-postgres@v4 + - uses: ikalnytskyi/action-setup-postgres@v5 ``` #### Advanced ```yaml steps: - - uses: ikalnytskyi/action-setup-postgres@v4 + - uses: ikalnytskyi/action-setup-postgres@v5 with: username: ci password: sw0rdfish @@ -80,7 +80,7 @@ steps: ```yaml steps: - - uses: ikalnytskyi/action-setup-postgres@v4 + - uses: ikalnytskyi/action-setup-postgres@v5 - run: | createuser myuser @@ -102,7 +102,7 @@ steps: ```yaml steps: - - uses: ikalnytskyi/action-setup-postgres@v4 + - uses: ikalnytskyi/action-setup-postgres@v5 ``` ```python From 88de67f5519b085a2a69370751ef8d9e5d694dd6 Mon Sep 17 00:00:00 2001 From: Ihor Kalnytskyi Date: Sun, 17 Mar 2024 00:56:02 +0200 Subject: [PATCH 10/13] Fix macOS 13 support Due to some dark magic used to prepare the macOS 13 runner, the brew install command fails to link unrelated kegs, such as `python@3.12`. Fortunately, since `python@3.12` is a dependency of a dependency, and that dependency is already installed, we can workaround the issue by forcing Homebrew to do not try upgrading dependencies unless absolutely neccessary. Fixes: #27 --- action.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/action.yml b/action.yml index 456b2cd..4d6cc59 100644 --- a/action.yml +++ b/action.yml @@ -50,7 +50,10 @@ runs: 13.*) # Unfortunately, the macOS 13 runner image doesn't come w/ # pre-installed PostgreSQL server. - brew install postgresql@14 + export HOMEBREW_NO_INSTALLED_DEPENDENTS_CHECK=1 + export HOMEBREW_NO_INSTALL_CLEANUP=1 + export HOMEBREW_NO_INSTALL_UPGRADE=1 + brew install --skip-post-install postgresql@14 ;; esac fi From 85a5c36aa4983555cf5f67915ded32fbdec20472 Mon Sep 17 00:00:00 2001 From: Ihor Kalnytskyi Date: Tue, 19 Mar 2024 02:36:21 +0200 Subject: [PATCH 11/13] Add macOS 14 support The macOS 14 runner image isn't shipped with pre-installed PostgreSQL server. Even though it has Beta status, it'd be nice to support this runner image too. Co-authored-by: hfhbd --- .github/workflows/ci.yml | 1 + action.yml | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2d64642..b7cf1f0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,6 +18,7 @@ jobs: - windows-latest - macos-latest - macos-13 + - macos-14 steps: - uses: actions/checkout@v4 diff --git a/action.yml b/action.yml index 4d6cc59..ee399a4 100644 --- a/action.yml +++ b/action.yml @@ -47,7 +47,7 @@ runs: done elif [ "$RUNNER_OS" == "macOS" ]; then case "$(sw_vers -productVersion)" in - 13.*) + 13.*|14.*) # Unfortunately, the macOS 13 runner image doesn't come w/ # pre-installed PostgreSQL server. export HOMEBREW_NO_INSTALLED_DEPENDENTS_CHECK=1 From 89e158678347488cdd62fe7e46d2391c6bcbfc71 Mon Sep 17 00:00:00 2001 From: Ihor Kalnytskyi Date: Tue, 19 Mar 2024 02:51:00 +0200 Subject: [PATCH 12/13] Add support for missing action runners Turns out that Windows Server 2019 does not support locale names specified in the format defined by the POSIX standard. This patch converts the POSIX format to the one supported by Windows 2019. In addition to that, this patch adds all supported action runners to the continue integration, so we can make sure that this action works properly on every supported platform. Reported-by: Irena Rindos Fixes: #25 --- .github/workflows/ci.yml | 20 ++++++++++++++++---- action.yml | 16 ++++++++++++++-- test_action.py | 41 +++++++++++++++++++++++++++++++--------- 3 files changed, 62 insertions(+), 15 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b7cf1f0..257c43e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,11 +14,13 @@ jobs: strategy: matrix: os: - - ubuntu-latest - - windows-latest - - macos-latest - - macos-13 + - ubuntu-22.04 + - ubuntu-20.04 + - windows-2022 + - windows-2019 - macos-14 + - macos-13 + - macos-12 steps: - uses: actions/checkout@v4 @@ -26,6 +28,11 @@ jobs: uses: ./ id: postgres + - name: Run setup-python + uses: actions/setup-python@v5 + with: + python-version: "3.10" + - name: Run tests run: | python3 -m pip install --upgrade pip pytest psycopg furl @@ -57,6 +64,11 @@ jobs: port: 34837 id: postgres + - name: Run setup-python + uses: actions/setup-python@v5 + with: + python-version: "3.10" + - name: Run tests run: | python3 -m pip install --upgrade pip pytest psycopg furl diff --git a/action.yml b/action.yml index ee399a4..d63da77 100644 --- a/action.yml +++ b/action.yml @@ -64,6 +64,18 @@ runs: export PGDATA="$RUNNER_TEMP/pgdata" export PWFILE="$RUNNER_TEMP/pwfile" + DEFAULT_ENCODING="UTF-8" + DEFAULT_LOCALE="en_US.$DEFAULT_ENCODING" + + # Unfortunately, Windows Server 2019 doesn't understand locale + # specified in the format defined by the POSIX standard, i.e. + # _.. Therefore, we have to convert it + # into something it can swallow, i.e. -. + if [[ "$RUNNER_OS" == "Windows" && "$(wmic os get Caption)" == *"2019"* ]]; then + DEFAULT_LOCALE="${DEFAULT_LOCALE%%.*}" + DEFAULT_LOCALE="${DEFAULT_LOCALE//_/-}" + fi + # Unfortunately 'initdb' could only receive a password via file on disk # or prompt to enter on. Prompting is not an option since we're running # in non-interactive mode. @@ -82,8 +94,8 @@ runs: --username="${{ inputs.username }}" \ --pwfile="$PWFILE" \ --auth="scram-sha-256" \ - --encoding="UTF-8" \ - --locale="en_US.UTF-8" \ + --encoding="$DEFAULT_ENCODING" \ + --locale="$DEFAULT_LOCALE" \ --no-instructions # Do not create unix sockets since they are created by default in the diff --git a/test_action.py b/test_action.py index a1f4540..f84149f 100644 --- a/test_action.py +++ b/test_action.py @@ -12,6 +12,24 @@ ConnectionFactory = t.Callable[[str], psycopg.Connection] +@pytest.fixture(scope="function") +def is_windows() -> bool: + """Returns True if running on Windows.""" + + return os.name == "nt" + + +@pytest.fixture(scope="function") +def is_windows_server_2019(is_windows: bool) -> bool: + """Returns True if running on Windows Server 2019.""" + + if not is_windows: + return False + + windows_caption = subprocess.check_output(["wmic", "os", "get", "Caption"], text=True) + return "Windows Server 2019" in windows_caption + + @pytest.fixture(scope="function") def connection_uri() -> str: """Read and return connection URI from environment.""" @@ -57,35 +75,40 @@ def connection( raise RuntimeError("f{request.param}: unknown value") -def test_connection_uri(connection_uri): +def test_connection_uri(connection_uri: str): """Test that CONNECTION_URI matches EXPECTED_CONNECTION_URI.""" assert connection_uri == os.getenv("EXPECTED_CONNECTION_URI") -def test_service_name(service_name): +def test_service_name(service_name: str): """Test that SERVICE_NAME matches EXPECTED_SERVICE_NAME.""" assert service_name == os.getenv("EXPECTED_SERVICE_NAME") def test_server_encoding(connection: psycopg.Connection): - """Test that PostgreSQL's encoding is 'UTF-8'.""" + """Test that PostgreSQL's encoding matches the one we passed to initdb.""" assert connection.execute("SHOW SERVER_ENCODING").fetchone()[0] == "UTF8" -def test_locale(connection: psycopg.Connection): - """Test that PostgreSQL's locale is 'en_US.UTF-8'.""" +def test_locale(connection: psycopg.Connection, is_windows_server_2019: bool): + """Test that PostgreSQL's locale matches the one we paased to initdb.""" + + locale_exp = "en_US.UTF-8" + + if is_windows_server_2019: + locale_exp = "en-US" lc_collate = connection.execute("SHOW LC_COLLATE").fetchone()[0] lc_ctype = connection.execute("SHOW LC_CTYPE").fetchone()[0] - assert locale.normalize(lc_collate) == "en_US.UTF-8" - assert locale.normalize(lc_ctype) == "en_US.UTF-8" + assert locale.normalize(lc_collate) == locale_exp + assert locale.normalize(lc_ctype) == locale_exp -def test_environment_variables(): +def test_environment_variables(is_windows: bool): """Test that only expected 'PG*' variables are set.""" pg_environ = {k: v for k, v in os.environ.items() if k.startswith("PG")} @@ -96,7 +119,7 @@ def test_environment_variables(): pg_servicefile_exp = pathlib.Path(os.environ["RUNNER_TEMP"], "pgdata", "pg_service.conf") assert pg_servicefile.resolve() == pg_servicefile_exp.resolve() - if os.name == "nt": + if is_windows: pg_environ_exp = { "PGBIN": "", "PGDATA": "", From 687404e7b78b0b3471751635e69f70d54a09d6e7 Mon Sep 17 00:00:00 2001 From: Ihor Kalnytskyi Date: Thu, 2 May 2024 02:02:48 +0300 Subject: [PATCH 13/13] Version 6 --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 6556e92..bcb5e08 100644 --- a/README.md +++ b/README.md @@ -50,14 +50,14 @@ key features: ```yaml steps: - - uses: ikalnytskyi/action-setup-postgres@v5 + - uses: ikalnytskyi/action-setup-postgres@v6 ``` #### Advanced ```yaml steps: - - uses: ikalnytskyi/action-setup-postgres@v5 + - uses: ikalnytskyi/action-setup-postgres@v6 with: username: ci password: sw0rdfish @@ -80,7 +80,7 @@ steps: ```yaml steps: - - uses: ikalnytskyi/action-setup-postgres@v5 + - uses: ikalnytskyi/action-setup-postgres@v6 - run: | createuser myuser @@ -102,7 +102,7 @@ steps: ```yaml steps: - - uses: ikalnytskyi/action-setup-postgres@v5 + - uses: ikalnytskyi/action-setup-postgres@v6 ``` ```python