Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ config/
!.idea/misc.xml
!.idea/sqldialects.xml
!.idea/vcs.xml
**/out/

*.iml
*.ipr
Expand Down
87 changes: 79 additions & 8 deletions docs/cluster-connection.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,16 @@ Other Custom Resources (like `Database`, `Role`, `Schema`, `Grant`, `DefaultPriv

## Spec

| Field | Type | Description | Required | Mutable |
|------------------|---------------------|-----------------------------------------------------------------------|----------|---------|
| `host` | `string` | The hostname of the PostgreSQL instance. | Yes | Yes |
| `port` | `integer` | The port of the PostgreSQL instance (1-65535). | Yes | Yes |
| `database` | `string` | The database to connect to (usually `postgres` for admin operations). | Yes | Yes |
| `adminSecretRef` | `ResourceRef` | Reference to the Kubernetes Secret containing the admin credentials. | Yes | Yes |
| `parameters` | `map[string]string` | Additional connection parameters. | No | Yes |
| Field | Type | Description | Required | Mutable |
|----------------------|----------------------|-----------------------------------------------------------------------|----------|---------|
| `host` | `string` | The hostname of the PostgreSQL instance. | Yes | Yes |
| `port` | `integer` | The port of the PostgreSQL instance (1-65535). | Yes | Yes |
| `database` | `string` | The database to connect to (usually `postgres` for admin operations). | Yes | Yes |
| `adminSecretRef` | `ResourceRef` | Reference to the Kubernetes Secret containing the admin credentials. | No | Yes |
| `adminSecretFileRef` | `FileRef` | Reference to a file containing the admin credentials. | No | Yes |
| `parameters` | `map[string]string` | Additional connection parameters. | No | Yes |

> **Note:** Exactly one of `adminSecretRef` or `adminSecretFileRef` must be provided.

### ResourceRef (`adminSecretRef`)

Expand All @@ -24,7 +27,59 @@ Other Custom Resources (like `Database`, `Role`, `Schema`, `Grant`, `DefaultPriv

The referenced secret must be of type `kubernetes.io/basic-auth` and contain the keys `username` and `password`.

### Example
### FileRef (`adminSecretFileRef`)

| Field | Type | Description | Required |
|--------|----------|----------------------------------------------------------------|----------|
| `path` | `string` | The path to the file containing the admin credentials. | Yes |

Use this option when the credentials are mounted as a file instead of a Kubernetes Secret.

### File format

The file must contain JSON with the following fields:

```json
{
"username": "root",
"password": "password"
}
```

- `password` **required**
- `username` **required**

#### Mount the credentials file

The file must be accessible inside the operator pod at the path specified in `adminSecretFileRef.path`. Mount it using a Volume and VolumeMount on the operator Deployment:

```yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: postgresql-operator
spec:
template:
spec:
containers:
- name: postgresql-operator
volumeMounts:
- name: db-credentials
mountPath: /mnt/secrets
readOnly: true
volumes:
- name: db-credentials
secret:
secretName: db-credentials-secret
```

> **Note:** The volume source can be any type that provides a file.

> **Note:** The Helm chart does not support extra volumes yet.

### Examples

#### Using a Kubernetes Secret (`adminSecretRef`)

```yaml
apiVersion: v1
Expand Down Expand Up @@ -54,3 +109,19 @@ spec:
#sslmode: "require" # Enforce SSL encryption
#connectTimeout: "10" # Timeout in seconds for connection attempts
```

#### Using a file reference (`adminSecretFileRef`)

```yaml
apiVersion: postgresql.aboutbits.it/v1
kind: ClusterConnection
metadata:
name: quarkus-postgres-connection
spec:
adminSecretFileRef:
path: "/mnt/secrets/db-credentials.json"
host: localhost
port: 5432
database: postgres
```

11 changes: 10 additions & 1 deletion docs/docker-environment.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,16 @@ users:

## 2. Create PostgreSQL Connection and Secret

For the `postgresql` Dev Service, you can generate the necessary Custom Resources to test the Operator:
For the `postgresql` Dev Service, you can generate the necessary Custom Resources to test the Operator.

A `ClusterConnection` requires admin credentials, which can be provided in one of two ways:

- **`adminSecretRef`** references a Kubernetes `basic-auth` Secret (username + password).
- **`adminSecretFileRef`** references a JSON file mounted into the operator pod.

Exactly one of these must be specified.

### Using a Kubernetes Secret (`adminSecretRef`)

1. From the Dev UI, get the `postgresql` Dev Service properties (username, password, host, port).
2. Convert the `postgresql` Dev Service properties to a **Basic Auth Secret** and a **ClusterConnection** CR instance.
Expand Down
2 changes: 1 addition & 1 deletion docs/terraform.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ Every optional field of every Custom Resource is affected, in particular:

| Custom Resource | Optional fields |
|---------------------|------------------------------------------------------------------------------------------------|
| `ClusterConnection` | `parameters`, `adminSecretRef.namespace` |
| `ClusterConnection` | `parameters`, `adminSecretRef`, `adminSecretRef.namespace`, `adminSecretFileRef` |
| `Database` | `owner`, `reclaimPolicy`, `clusterRef.namespace` |
| `Schema` | `owner`, `reclaimPolicy`, `clusterRef.namespace` |
| `Role` | `comment`, `passwordSecretRef`, `flags` (including `flags.validUntil`), `clusterRef.namespace` |
Expand Down
1 change: 1 addition & 0 deletions operator/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ dependencies {
*/
testImplementation("io.quarkus:quarkus-junit")
testImplementation("io.quarkus:quarkus-junit-mockito")
testImplementation("io.fabric8:kubernetes-server-mock")
testImplementation("org.awaitility:awaitility")
testImplementation(libs.assertj)
testImplementation(libs.datafaker)
Expand Down
33 changes: 33 additions & 0 deletions operator/src/main/java/it/aboutbits/postgresql/core/FileRef.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package it.aboutbits.postgresql.core;

import io.fabric8.generator.annotation.Required;
import io.fabric8.generator.annotation.ValidationRule;
import lombok.Getter;
import lombok.Setter;
import org.jspecify.annotations.NullMarked;

/// A reference to a file inside the operator container.
///
/// This class is used wherever a CRD spec needs to point to a specific file
/// The [#path] field identifies the file location within the container.
///
/// ### Example usage in a CR manifest
///
/// ```yaml
/// spec:
/// adminSecretFileRef:
/// path: "/mnt/secrets/db-credentials.json"
/// ```
@Getter
@Setter
@NullMarked
public class FileRef {
/// The path to the file.
/// Must not be blank.
@Required
@ValidationRule(
value = "self.trim().size() > 0",
message = "The path must not be empty."
)
private String path = "";
}
Original file line number Diff line number Diff line change
@@ -1,39 +1,88 @@
package it.aboutbits.postgresql.core;

import com.fasterxml.jackson.databind.ObjectMapper;
import io.fabric8.kubernetes.client.KubernetesClient;
import it.aboutbits.postgresql.crd.clusterconnection.ClusterConnection;
import jakarta.inject.Singleton;
import lombok.RequiredArgsConstructor;
import org.jspecify.annotations.NullMarked;
import org.jspecify.annotations.Nullable;

import java.io.IOException;
import java.nio.charset.Charset;
import java.nio.file.Files;
import java.nio.file.NoSuchFileException;
import java.nio.file.Path;
import java.util.Base64;

@Singleton
@RequiredArgsConstructor
@NullMarked
public final class KubernetesService {
private final ObjectMapper objectMapper;

private record FileCredentials(
@Nullable String username,
@Nullable String password
) {
}

public static final String SECRET_TYPE_BASIC_AUTH = "kubernetes.io/basic-auth";
public static final String SECRET_DATA_BASIC_AUTH_USERNAME_KEY = "username";
public static final String SECRET_DATA_BASIC_AUTH_PASSWORD_KEY = "password";

public Credentials getSecretRefCredentials(
public Credentials getAdminCredentials(
KubernetesClient kubernetesClient,
ClusterConnection clusterConnection
) {
return getSecretRefCredentials(
kubernetesClient,
clusterConnection.getSpec().getAdminSecretRef(),
clusterConnection.getMetadata().getNamespace()
);
var spec = clusterConnection.getSpec();
if (spec.getAdminSecretRef() != null) {
Comment thread
ThoSap marked this conversation as resolved.
var secretRef = spec.getAdminSecretRef();
var defaultNamespace = clusterConnection.getMetadata().getNamespace();
var credentials = getSecretRefCredentials(kubernetesClient, secretRef, defaultNamespace);
if (credentials.username() == null) {
var secretNamespace = getSecretNamespace(secretRef, defaultNamespace);
throw new IllegalStateException(
"The Secret reference is missing required data username [secret.namespace=%s, secret.name=%s]".formatted(
secretNamespace, secretRef.getName()));
}
return credentials;
} else if (spec.getAdminSecretFileRef() != null) {
return getSecretFileRefCredentials(spec.getAdminSecretFileRef());
}

throw new IllegalStateException("Exactly one of 'adminSecretRef' or 'adminSecretFileRef' must be provided");
}
Comment thread
ThoSap marked this conversation as resolved.

public Credentials getSecretFileRefCredentials(FileRef fileRef) {
var path = Path.of(fileRef.getPath());

try (var in = Files.newInputStream(path)) {
var file = objectMapper.readValue(in, FileCredentials.class);
if (file.username() == null) {
throw new IllegalStateException(
"Credentials file is missing required field 'username' [path=%s]".formatted(path));
}
if (file.password() == null) {
throw new IllegalStateException(
"Credentials file is missing required field 'password' [path=%s]".formatted(path));
}
return new Credentials(file.username(), file.password());
} catch (NoSuchFileException e) {
throw new IllegalStateException(
"Credentials file not found [path=%s]".formatted(path), e);
} catch (IOException e) {
throw new IllegalStateException(
"Failed to read the credentials file [path=%s]".formatted(path), e);
}
}

public Credentials getSecretRefCredentials(
KubernetesClient kubernetesClient,
ResourceRef secretRef,
String defaultNamespace
) {
var secretNamespace = secretRef.getNamespace() != null
? secretRef.getNamespace()
: defaultNamespace;
var secretNamespace = getSecretNamespace(secretRef, defaultNamespace);

var secretName = secretRef.getName();

Expand Down Expand Up @@ -91,4 +140,10 @@ public Credentials getSecretRefCredentials(
password
);
}

private String getSecretNamespace(ResourceRef secretRef, String defaultNamespace) {
return secretRef.getNamespace() != null
? secretRef.getNamespace()
: defaultNamespace;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ public CloseableDSLContext getDSLContext(
ClusterConnection clusterConnection,
String database
) throws DataAccessException {
var credentials = kubernetesService.getSecretRefCredentials(
var credentials = kubernetesService.getAdminCredentials(
kubernetesClient,
clusterConnection
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,13 @@
import io.fabric8.generator.annotation.Min;
import io.fabric8.generator.annotation.Required;
import io.fabric8.generator.annotation.ValidationRule;
import it.aboutbits.postgresql.core.FileRef;
import it.aboutbits.postgresql.core.ResourceRef;
import it.aboutbits.postgresql.core.schema_customizer.HostCustomizer;
import lombok.Getter;
import lombok.Setter;
import org.jspecify.annotations.NullMarked;
import org.jspecify.annotations.Nullable;

import java.util.HashMap;
import java.util.Map;
Expand All @@ -18,6 +20,10 @@
@Setter
@SchemaCustomizer(value = HostCustomizer.class, input = "host")
@NullMarked
@ValidationRule(
Comment thread
ThoSap marked this conversation as resolved.
value = "(has(self.adminSecretRef) ? 1 : 0) + (has(self.adminSecretFileRef) ? 1 : 0) == 1",
message = "Exactly one of 'adminSecretRef' or 'adminSecretFileRef' must be provided"
)
public class ClusterConnectionSpec {
@Required
@ValidationRule(
Expand All @@ -38,8 +44,11 @@ public class ClusterConnectionSpec {
)
private String database = "postgres";

@Required
private ResourceRef adminSecretRef = new ResourceRef();
@io.fabric8.generator.annotation.Nullable
private @Nullable ResourceRef adminSecretRef;

@io.fabric8.generator.annotation.Nullable
private @Nullable FileRef adminSecretFileRef;

@io.fabric8.generator.annotation.Nullable
private Map<String, String> parameters = new HashMap<>();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import io.fabric8.kubernetes.client.KubernetesClient;
import it.aboutbits.postgresql._support.testdata.base.TestDataCreator;
import it.aboutbits.postgresql._support.testdata.persisted.Given;
import it.aboutbits.postgresql.core.FileRef;
import it.aboutbits.postgresql.core.ResourceRef;
import it.aboutbits.postgresql.crd.clusterconnection.ClusterConnection;
import it.aboutbits.postgresql.crd.clusterconnection.ClusterConnectionSpec;
Expand Down Expand Up @@ -41,6 +42,11 @@ public class ClusterConnectionCreate extends TestDataCreator<ClusterConnection>

private @Nullable ResourceRef withAdminSecretRef;

private @Nullable FileRef withAdminSecretFileRef;

@Setter(AccessLevel.NONE)
private boolean withoutAdminSecret = false;

private @Nullable String withApplicationName;

public ClusterConnectionCreate(
Expand All @@ -61,6 +67,16 @@ public ClusterConnectionCreate withoutNamespace() {
return this;
}

public ClusterConnectionCreate withAdminSecretFileRef(FileRef fileRef) {
this.withAdminSecretFileRef = fileRef;
return this;
}

public ClusterConnectionCreate withoutAdminSecret() {
this.withoutAdminSecret = true;
return this;
}

@Override
protected ClusterConnection create(int index) {
// given
Expand All @@ -81,6 +97,9 @@ protected ClusterConnection create(int index) {
spec.setPort(getPort());
spec.setDatabase(getDatabase());
spec.setAdminSecretRef(getAdminSecretRef());
if (withAdminSecretFileRef != null) {
spec.setAdminSecretFileRef(withAdminSecretFileRef);
}
spec.setParameters(getParameters());

item.setSpec(spec);
Expand Down Expand Up @@ -154,7 +173,11 @@ private String getDatabase() {
return withDatabase;
}

private ResourceRef getAdminSecretRef() {
private @Nullable ResourceRef getAdminSecretRef() {
if (withoutAdminSecret) {
return null;
}

if (withAdminSecretRef != null) {
return withAdminSecretRef;
}
Expand Down
Loading
Loading