diff --git a/.gitea/workflows/build.yaml b/.gitea/workflows/build.yaml new file mode 100644 index 0000000..ed42608 --- /dev/null +++ b/.gitea/workflows/build.yaml @@ -0,0 +1,7 @@ +name: build +on: + push: +jobs: + maven: + uses: self:KofTwentyTwo/ci-workflows/.gitea/workflows/maven.yaml@main + secrets: inherit diff --git a/.gitea/workflows/qrun-release.yaml b/.gitea/workflows/qrun-release.yaml deleted file mode 100644 index 5ebc530..0000000 --- a/.gitea/workflows/qrun-release.yaml +++ /dev/null @@ -1,25 +0,0 @@ -# Standard QRun release caller (v2, thin contract): every push and manual dispatch delegates to the protected qrun-release-v1 channel. Do not edit per repository. -name: qrun-release -on: - push: {} - workflow_dispatch: {} -permissions: - contents: read - actions: read -jobs: - release: - uses: self:KofTwentyTwo/platform-ci/.gitea/workflows/java-docker.yaml@qrun-release-v1 - with: - runner: linux-small - docker-runner: container-build - browsers: true - browser-runner: browser-build - postpublish: - if: ${{ needs.release.outputs.capture == 'true' }} - needs: release - permissions: - contents: write - actions: write - uses: self:KofTwentyTwo/platform-ci/.gitea/workflows/qrun-source-version.yaml@qrun-release-v1 - with: - runner: linux-small diff --git a/.gitea/workflows/release-rc.yaml b/.gitea/workflows/release-rc.yaml deleted file mode 100644 index 12ec9fc..0000000 --- a/.gitea/workflows/release-rc.yaml +++ /dev/null @@ -1,72 +0,0 @@ -name: Release candidate -'on': - workflow_dispatch: - inputs: - dry_run: - description: Plan a cut without changing release state - type: boolean - default: false - finalize: - description: Retry finalization of an existing cut; do not cut another RC - type: boolean - default: false - recover: - description: Recover the same published RC after a deployment timeout; never - reupload - type: boolean - default: false - circle_build: - description: Original release run id (recovery only) - type: string - default: '' - merge_sha: - description: Existing cut merge_sha (finalize retry only) - type: string - default: '' - bump_sha: - description: Existing cut bump_sha (finalize retry only) - type: string - default: '' - branch: - description: Existing cut branch (finalize retry only) - type: string - default: '' - version: - description: Existing cut version (finalize retry only) - type: string - default: '' - pull_request: - types: - - closed - branches: - - develop -permissions: - contents: read -jobs: - release-rc: - # The old qbit-rc-published repository_dispatch is replaced by the already-proven workflow_dispatch - # completion: the publisher's own ActionsWRITE job dispatches this workflow with finalize=true plus - # merge_sha/bump_sha/branch/version (same inputs as the manual finalize retry). - permissions: - contents: write - actions: read - pull-requests: write - if: github.event_name == 'workflow_dispatch' - || (github.event_name == 'pull_request' && github.event.pull_request.merged - == true && github.event.pull_request.base.ref == 'develop' && contains(github.event.pull_request.labels.*.name, - 'release:rc')) - uses: self:KofTwentyTwo/platform-ci/.gitea/workflows/qbit-release-rc.yaml@qrun-release-v1 - with: - dry_run: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run || false - }} - finalize: ${{ github.event_name == 'workflow_dispatch' && inputs.finalize }} - recover: ${{ github.event_name == 'workflow_dispatch' && inputs.recover || false - }} - circle_build: ${{ github.event_name == 'workflow_dispatch' && inputs.circle_build - || '' }} - merge_sha: ${{ inputs.merge_sha || '' }} - bump_sha: ${{ inputs.bump_sha || '' }} - branch: ${{ inputs.branch || '' }} - version: ${{ inputs.version || '' }} - secrets: - QRUN_TAG_SIGNING_KEY: ${{ secrets.QRUN_TAG_SIGNING_KEY }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 40dec57..bcbb1d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ All notable changes to this project are documented here. Format: [Keep a Changel ## [Unreleased] +- Fix: native **Create Group** now obeys the two-person gate. With `twoPersonApproval=true`, creating a group whose permissions expand to any `authentik.*` permission queues an `authentik_approval_request` for a second person instead of persisting the grant immediately; the same identity-permission expansion and second-person condition as Edit Group apply, nothing is written before approval, self-approval is refused and a decided request cannot be approved again. Previously a new group with `authentik.users.read` was created without approval. - **Breaking (security):** `AuthentikMaintenance.applyPolicy` runs as the host's maintenance service principal (QQQ service identities, qqq `docs/SERVICE-IDENTITY.md`; config `maintenancePrincipal`, default `maintenance`). The self-made `MaintenanceSession`, which granted itself `policies.manage` and the document's grants, is gone; under OAuth2 it was rejected anyway (its audit insert failed). The principal's Authentik service account must hold `policies.manage` and every grant it applies, and the subset rule still holds. The audit operator is `service:{principal}`; the job name (`run-once:`/`maintenance:`) prefixes the reason. Hosts declare the principal: `ServiceIdentitiesMetaData.declare(qInstance, new QServicePrincipalMetaData().withName("maintenance"))`. Needs the QQQ release with service identities (qqq#1255). - Fix: a degraded `authentik.mapper` health check now says what to fix. The message and details (`flaggedGroups`, `summary`) name each flagged group with a safe reason: `grants nothing: `, `malformed ` or `superuser group carries qqq.io/permissions`. It used to say only "Mapper drift detected". Typed attribute values and service-account activity subjects (people) stay out of the outcome. A group with no `qqq.io/*` attributes is not drift and never was. - Fix: the `token_broker` health check probes with the grant the broker uses (token exchange) and sends no subject token, so nothing is issued. `invalid_request` (credentials passed) is UP, `invalid_client` is DOWN (bad secret), and `invalid_grant` is DOWN (provider does not allow token exchange). It used to probe `client_credentials`, which the broker provider does not allow, so Authentik logged a 400 every 5 minutes ("Invalid grant_type for provider"). diff --git a/src/main/java/com/kingsrook/qbits/authentik/admin/AuthentikAdmin.java b/src/main/java/com/kingsrook/qbits/authentik/admin/AuthentikAdmin.java index fe8c7d0..abed392 100644 --- a/src/main/java/com/kingsrook/qbits/authentik/admin/AuthentikAdmin.java +++ b/src/main/java/com/kingsrook/qbits/authentik/admin/AuthentikAdmin.java @@ -1407,6 +1407,10 @@ public class AuthentikAdmin } plan.withChange(null, name + (parentPks.isEmpty() ? "" : ", under " + request.str("parents")) + (permissions.isEmpty() ? "" : ", permissions " + String.join(", ", permissions))); + if(config.getTwoPersonApproval() && !request.bool("approved") && addsIdentityPermission(permissions, List.of())) + { + plan.withState(AdminPlan.State.NEEDS_APPROVAL).withMessage("granting authentik.* needs a second person"); + } return (plan); } diff --git a/src/test/java/com/kingsrook/qbits/authentik/SchemaDriftTest.java b/src/test/java/com/kingsrook/qbits/authentik/SchemaDriftTest.java index 016c3ef..383f1d6 100644 --- a/src/test/java/com/kingsrook/qbits/authentik/SchemaDriftTest.java +++ b/src/test/java/com/kingsrook/qbits/authentik/SchemaDriftTest.java @@ -161,7 +161,7 @@ public class SchemaDriftTest { return (surfacePath); } - String regex = surfacePath.replace("{key}", "\\{[^/]+\\}"); + String regex = java.util.regex.Pattern.quote(surfacePath).replace("{key}", "\\E\\{[^/]+\\}\\Q"); Iterator> fields = paths.fields(); while(fields.hasNext()) { diff --git a/src/test/java/com/kingsrook/qbits/authentik/admin/AuthentikAdminTest.java b/src/test/java/com/kingsrook/qbits/authentik/admin/AuthentikAdminTest.java index a086965..31bdeb6 100644 --- a/src/test/java/com/kingsrook/qbits/authentik/admin/AuthentikAdminTest.java +++ b/src/test/java/com/kingsrook/qbits/authentik/admin/AuthentikAdminTest.java @@ -541,6 +541,34 @@ class AuthentikAdminTest + /******************************************************************************* + ** Creation-time identity grants obey the same second-person gate as edits. + *******************************************************************************/ + @Test + void creatingGroupWithIdentityPermissionsNeedsASecondPerson() throws QException + { + AdminRequest request = AdminRequest.of(AdminAction.CREATE_GROUP, "", "synthetic approval check") + .with("name", "synthetic-identity-readers").with("permissions", "authentik.users.read"); + AuthentikAdmin.AdminResult queued = run(request); + assertThat(queued.ok()).as(queued.message()).isTrue(); + assertThat(queued.pending()).isTrue(); + assertThat(t.writes()).isEmpty(); + List approvals = rows(AuthentikLocalTables.APPROVAL_REQUEST); + assertThat(approvals).hasSize(1); + java.io.Serializable id = approvals.get(0).getValue("id"); + assertThat(t.admin().approve(id).ok()).isFalse(); + assertThat(t.writes()).isEmpty(); + t.loginAs("second@example.test", IdentityPermission.APPROVE, IdentityPermission.GROUPS_MANAGE); + AuthentikAdmin.AdminResult approved = t.admin().approve(id); + assertThat(approved.ok()).as(approved.message()).isTrue(); + assertThat(t.authentik.groupByName("synthetic-identity-readers").getJSONObject("attributes") + .getJSONArray("qqq.io/permissions").toList()).containsExactly("authentik.users.read"); + assertThat(rows(AuthentikLocalTables.APPROVAL_REQUEST).get(0).getValueString("status")).isEqualTo("APPROVED"); + assertThat(t.admin().approve(id).ok()).isFalse(); + } + + + /******************************************************************************* ** Re-parenting: loops and superuser parents are refused. *******************************************************************************/ diff --git a/src/test/java/com/kingsrook/qbits/authentik/contract/AuthentikTestStack.java b/src/test/java/com/kingsrook/qbits/authentik/contract/AuthentikTestStack.java index ea0028c..4ea1659 100644 --- a/src/test/java/com/kingsrook/qbits/authentik/contract/AuthentikTestStack.java +++ b/src/test/java/com/kingsrook/qbits/authentik/contract/AuthentikTestStack.java @@ -255,9 +255,36 @@ public final class AuthentikTestStack TestApi admin = admin(); try { + long[] nextRetry = { System.currentTimeMillis() + 60_000 }; waitFor(Duration.ofMinutes(8), "all three kof22 blueprints applied", () -> - isSuccessful(admin, INFRA_BLUEPRINT) && isSuccessful(admin, BOOTSTRAP_BLUEPRINT) && isSuccessful(admin, TOKEN_BLUEPRINT) + { + //////////////////////////////////////////////////////////////////// + // built-in blueprints may race our custom dependencies on first // + // boot, leaving one of ours in "error" or never applied // + // ("unknown"); re-apply it (throttled). // + //////////////////////////////////////////////////////////////////// + if(System.currentTimeMillis() >= nextRetry[0]) + { + nextRetry[0] = System.currentTimeMillis() + 30_000; + for(String name : List.of(INFRA_BLUEPRINT, BOOTSTRAP_BLUEPRINT, TOKEN_BLUEPRINT)) + { + try + { + JSONObject current = blueprint(name); + if(current != null && List.of("error", "unknown").contains(current.optString("status"))) + { + admin.post("/managed/blueprints/" + current.getString("pk") + "/apply/", new JSONObject()); + } + } + catch(Exception e) + { + // Bootstrap authentication and built-in dependencies settle after HTTP readiness. + } + } + } + return (isSuccessful(admin, INFRA_BLUEPRINT) && isSuccessful(admin, BOOTSTRAP_BLUEPRINT) && isSuccessful(admin, TOKEN_BLUEPRINT) && admin.first("/core/users/?username=" + SERVICE_ACCOUNT) != null); + }); } catch(IllegalStateException e) { @@ -278,8 +305,16 @@ public final class AuthentikTestStack } try { - worker.getLogs().lines().filter(line -> line.contains("blueprint") && (line.contains("error") || line.contains("warning") || line.contains("fail"))) + worker.getLogs().lines().filter(line -> (line.contains("blueprint") && (line.contains("error") || line.contains("warning") || line.contains("fail"))) + || line.contains("\"level\": \"error\"") || line.contains("Traceback")) + .filter(line -> !line.contains("clear_failed_blueprints")) .limit(60).forEach(line -> System.err.println("worker: " + line)); + //////////////////////////////////////////////////////////////// + // blueprints re-applied through the API run in the server // + //////////////////////////////////////////////////////////////// + server.getLogs().lines().filter(line -> line.contains("\"level\": \"error\"") || line.contains("\"level\": \"warning\"") || line.contains("Traceback")) + .filter(line -> !line.contains("Base URL is not configured")) + .limit(60).forEach(line -> System.err.println("server: " + line)); } catch(RuntimeException unavailable) { diff --git a/src/test/java/com/kingsrook/qbits/authentik/contract/InvitationEnrollmentContractIT.java b/src/test/java/com/kingsrook/qbits/authentik/contract/InvitationEnrollmentContractIT.java index 5035cc6..51be1d8 100644 --- a/src/test/java/com/kingsrook/qbits/authentik/contract/InvitationEnrollmentContractIT.java +++ b/src/test/java/com/kingsrook/qbits/authentik/contract/InvitationEnrollmentContractIT.java @@ -174,7 +174,7 @@ class InvitationEnrollmentContractIT } assertThat(used).isNotNull(); assertThat(used.getJSONObject("user").optInt("pk", -1)).isNotEqualTo(userPk); - var config = assignment == null ? new com.kingsrook.qbits.authentik.AuthentikQBitConfig().withBaseUrl(stack.baseUrl()) + var config = assignment == null ? new com.kingsrook.qbits.authentik.AuthentikQBitConfig().withBaseUrl(stack.baseUrl()).withAllowInsecureHttp(true) .withApiToken(stack.serviceAccountToken()).withAppKey("kof22-admin").withListCacheTtlSeconds(0) : assignment.config; var instance = assignment == null ? com.kingsrook.qbits.authentik.AuthentikTestSupport.produce(config) : assignment.instance; com.kingsrook.qqq.backend.core.context.QContext.init(instance, @@ -265,7 +265,7 @@ class InvitationEnrollmentContractIT String actor = create(admin, "/core/users/", new JSONObject().put("username", owner + "-operator").put("name", "Invitation operator") .put("email", owner + "-operator@example.test").put("type", "internal").put("path", "users/invitation-contract"), cleanup); assertThat(admin.post("/core/groups/" + admins + "/add_user/", new JSONObject().put("pk", Integer.parseInt(actor))).status()).isEqualTo(204); - config = new AuthentikQBitConfig().withBaseUrl(stack.baseUrl()).withApiToken(stack.serviceAccountToken()).withAppKey("kof22-admin") + config = new AuthentikQBitConfig().withBaseUrl(stack.baseUrl()).withAllowInsecureHttp(true).withApiToken(stack.serviceAccountToken()).withAppKey("kof22-admin") .withListCacheTtlSeconds(0).withGroupCacheTtlSeconds(0).withInvitationFlow(owner).withLocalBackendName("memory") .withRecoveryEmailStage("kof22-recovery-email") .withFeatures(EnumSet.of(AuthentikFeature.READ, AuthentikFeature.USERS, AuthentikFeature.GROUPS, AuthentikFeature.MEMBERSHIP)); diff --git a/src/test/java/com/kingsrook/qbits/authentik/contract/Phase1ReadContractIT.java b/src/test/java/com/kingsrook/qbits/authentik/contract/Phase1ReadContractIT.java index ffae214..6095ddd 100644 --- a/src/test/java/com/kingsrook/qbits/authentik/contract/Phase1ReadContractIT.java +++ b/src/test/java/com/kingsrook/qbits/authentik/contract/Phase1ReadContractIT.java @@ -96,7 +96,7 @@ class Phase1ReadContractIT void beforeEach() throws QException { config = new AuthentikQBitConfig() - .withBaseUrl(stack.baseUrl()) + .withBaseUrl(stack.baseUrl()).withAllowInsecureHttp(true) .withApiToken(stack.serviceAccountToken()) .withAppKey("kof22-admin") .withApplicationsInScope(List.of(AuthentikTestStack.APP_SLUG)) diff --git a/src/test/java/com/kingsrook/qbits/authentik/contract/QbitReadContractIT.java b/src/test/java/com/kingsrook/qbits/authentik/contract/QbitReadContractIT.java index 0b4b564..2f663a0 100644 --- a/src/test/java/com/kingsrook/qbits/authentik/contract/QbitReadContractIT.java +++ b/src/test/java/com/kingsrook/qbits/authentik/contract/QbitReadContractIT.java @@ -126,7 +126,7 @@ class QbitReadContractIT void beforeEach() throws QException { config = new AuthentikQBitConfig() - .withBaseUrl(stack.baseUrl()) + .withBaseUrl(stack.baseUrl()).withAllowInsecureHttp(true) .withApiToken(stack.serviceAccountToken()) .withAppKey("kof22-admin") .withListCacheTtlSeconds(0); diff --git a/src/test/java/com/kingsrook/qbits/authentik/contract/QbitWriteContractIT.java b/src/test/java/com/kingsrook/qbits/authentik/contract/QbitWriteContractIT.java index e0006a2..08bd6be 100644 --- a/src/test/java/com/kingsrook/qbits/authentik/contract/QbitWriteContractIT.java +++ b/src/test/java/com/kingsrook/qbits/authentik/contract/QbitWriteContractIT.java @@ -155,7 +155,7 @@ class QbitWriteContractIT { MemoryRecordStore.getInstance().reset(); config = new AuthentikQBitConfig() - .withBaseUrl(stack.baseUrl()) + .withBaseUrl(stack.baseUrl()).withAllowInsecureHttp(true) .withApiToken(stack.serviceAccountToken()) .withAppKey("kof22-admin") .withListCacheTtlSeconds(0) diff --git a/src/test/java/com/kingsrook/qbits/authentik/contract/SchemaDriftIT.java b/src/test/java/com/kingsrook/qbits/authentik/contract/SchemaDriftIT.java index 380511b..ed80929 100644 --- a/src/test/java/com/kingsrook/qbits/authentik/contract/SchemaDriftIT.java +++ b/src/test/java/com/kingsrook/qbits/authentik/contract/SchemaDriftIT.java @@ -89,7 +89,7 @@ class SchemaDriftIT for(java.util.Iterator it = paths.fieldNames(); it.hasNext(); ) { String candidate = it.next(); - if(candidate.equals(path) || candidate.matches(path.replace("{key}", "\\{[^/]+\\}"))) + if(candidate.equals(path) || candidate.matches(java.util.regex.Pattern.quote(path).replace("{key}", "\\E\\{[^/]+\\}\\Q"))) { return (SchemaDriftTest.parameterNames(schema, paths.get(candidate).get(operation.method()))); } diff --git a/src/test/java/com/kingsrook/qbits/authentik/contract/TokenContractIT.java b/src/test/java/com/kingsrook/qbits/authentik/contract/TokenContractIT.java index d01ad3a..89377ee 100644 --- a/src/test/java/com/kingsrook/qbits/authentik/contract/TokenContractIT.java +++ b/src/test/java/com/kingsrook/qbits/authentik/contract/TokenContractIT.java @@ -579,7 +579,7 @@ class TokenContractIT private void configureQbit(int owner, String subject) throws Exception { MemoryRecordStore.getInstance().reset(); - config = new AuthentikQBitConfig().withBaseUrl(stack.baseUrl()).withApiToken(stack.serviceAccountToken()).withAppKey("kof22-admin") + config = new AuthentikQBitConfig().withBaseUrl(stack.baseUrl()).withAllowInsecureHttp(true).withApiToken(stack.serviceAccountToken()).withAppKey("kof22-admin") .withListCacheTtlSeconds(0).withGroupCacheTtlSeconds(0).withBulkPacingMillis(0).withLocalBackendName("memory") .withRecoveryEmailStage("kof22-recovery-email") .withFeatures(EnumSet.of(AuthentikFeature.READ, AuthentikFeature.USERS, AuthentikFeature.TOKENS)) diff --git a/src/test/java/com/kingsrook/qbits/authentik/sync/AuthentikMirrorPostgresIT.java b/src/test/java/com/kingsrook/qbits/authentik/sync/AuthentikMirrorPostgresIT.java index ca05fbf..ca90f32 100644 --- a/src/test/java/com/kingsrook/qbits/authentik/sync/AuthentikMirrorPostgresIT.java +++ b/src/test/java/com/kingsrook/qbits/authentik/sync/AuthentikMirrorPostgresIT.java @@ -16,6 +16,7 @@ package com.kingsrook.qbits.authentik.sync; +import java.util.Set; import java.util.UUID; import javax.sql.DataSource; import com.kingsrook.qbits.authentik.RequiresDockerCondition; @@ -27,6 +28,7 @@ import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.extension.ExtendWith; import org.postgresql.ds.PGSimpleDataSource; +import org.testcontainers.DockerClientFactory; import org.testcontainers.containers.PostgreSQLContainer; import com.github.dockerjava.api.model.ExposedPort; import com.github.dockerjava.api.model.PortBinding; @@ -39,6 +41,7 @@ import com.github.dockerjava.api.model.Ports; @ExtendWith(RequiresDockerCondition.class) class AuthentikMirrorPostgresIT extends AuthentikMirrorSqlTest { + private static final Set LOCAL_DOCKER_HOSTS = Set.of("localhost", "127.0.0.1"); private static PostgreSQLContainer postgres; private String schema; @@ -55,7 +58,11 @@ class AuthentikMirrorPostgresIT extends AuthentikMirrorSqlTest .withCreateContainerCmdModifier(command -> { command.withName(owner); - command.getHostConfig().withPortBindings(new PortBinding(Ports.Binding.bindIp("127.0.0.1"), new ExposedPort(5432))); + // Loopback-only only when the daemon is local; a remote daemon (CI) is reached via its host address. + if(LOCAL_DOCKER_HOSTS.contains(DockerClientFactory.instance().dockerHostIpAddress())) + { + command.getHostConfig().withPortBindings(new PortBinding(Ports.Binding.bindIp("127.0.0.1"), new ExposedPort(5432))); + } }); try { diff --git a/src/test/java/com/kingsrook/qbits/authentik/sync/AuthentikWebhookInboxPostgresIT.java b/src/test/java/com/kingsrook/qbits/authentik/sync/AuthentikWebhookInboxPostgresIT.java index dec6cc9..8de82cc 100644 --- a/src/test/java/com/kingsrook/qbits/authentik/sync/AuthentikWebhookInboxPostgresIT.java +++ b/src/test/java/com/kingsrook/qbits/authentik/sync/AuthentikWebhookInboxPostgresIT.java @@ -17,6 +17,7 @@ package com.kingsrook.qbits.authentik.sync; import java.sql.SQLException; +import java.util.Set; import java.util.UUID; import javax.sql.DataSource; import com.kingsrook.qbits.authentik.RequiresDockerCondition; @@ -24,6 +25,7 @@ import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.extension.ExtendWith; import org.postgresql.ds.PGSimpleDataSource; +import org.testcontainers.DockerClientFactory; import org.testcontainers.containers.PostgreSQLContainer; import com.github.dockerjava.api.model.ExposedPort; import com.github.dockerjava.api.model.PortBinding; @@ -36,6 +38,7 @@ import com.github.dockerjava.api.model.Ports; @ExtendWith(RequiresDockerCondition.class) class AuthentikWebhookInboxPostgresIT extends AuthentikWebhookInboxTest { + private static final Set LOCAL_DOCKER_HOSTS = Set.of("localhost", "127.0.0.1"); private static PostgreSQLContainer postgres; private String schema; @@ -53,7 +56,11 @@ class AuthentikWebhookInboxPostgresIT extends AuthentikWebhookInboxTest .withCreateContainerCmdModifier(command -> { command.withName(owner); - command.getHostConfig().withPortBindings(new PortBinding(Ports.Binding.bindIp("127.0.0.1"), new ExposedPort(5432))); + // Loopback-only only when the daemon is local; a remote daemon (CI) is reached via its host address. + if(LOCAL_DOCKER_HOSTS.contains(DockerClientFactory.instance().dockerHostIpAddress())) + { + command.getHostConfig().withPortBindings(new PortBinding(Ports.Binding.bindIp("127.0.0.1"), new ExposedPort(5432))); + } }); try {