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"