Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -60,3 +60,5 @@ gradle-app.setting

# Quinoa
.quinoa/

**/out/

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Follow-up to #60 (comment). The pattern is generic now, thanks. Please move it into the "IntelliJ IDEA" section above (lines 13-22) instead of the end of the file.

@fredordercloud why was this needed in the first place, did you not use the Gradle tasks but the IntelliJ builder instead?

90 changes: 82 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`| `ResourceFileRef` | Reference to a file containing the admin credentials. | No | Yes |
Comment thread
ThoSap marked this conversation as resolved.
Outdated
| `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,18 @@ 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
### ResourceFileRef (`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. The file must be a JSON object with the keys `username` and `password`, for example `{"username": "postgres", "password": "password"}`.


### Examples

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

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

### Using a file reference (`adminSecretFileRef`)
Comment thread
ThoSap marked this conversation as resolved.
Outdated

Instead of a Kubernetes Secret, you can mount a JSON credentials file into the operator pod and reference its path. This is useful when credentials are managed externally.

#### 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: operator
Comment thread
ThoSap marked this conversation as resolved.
Outdated
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.

```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
34 changes: 34 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,34 @@
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
Comment thread
ThoSap marked this conversation as resolved.
Outdated
///
/// 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,29 +1,79 @@
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 it.aboutbits.postgresql.crd.clusterconnection.ClusterConnectionSpec;
import jakarta.inject.Singleton;
import org.jspecify.annotations.NullMarked;

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

@Singleton
@NullMarked
public final class KubernetesService {
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();

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.
return getSecretRefCredentials(
kubernetesClient,
spec.getAdminSecretRef(),
clusterConnection.getMetadata().getNamespace()
);
} 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());

if (!Files.exists(path)) {
throw new IllegalStateException("Credential file not found [path=%s]".formatted(path));
Comment thread
ThoSap marked this conversation as resolved.
Outdated
}

try {
var content = Files.readString(path);
var json = OBJECT_MAPPER.readTree(content);
Comment thread
ThoSap marked this conversation as resolved.
Outdated

var usernameNode = json.get(SECRET_DATA_BASIC_AUTH_USERNAME_KEY);
var username = usernameNode != null && !usernameNode.isNull()
? usernameNode.asText()
: null;
Comment thread
ThoSap marked this conversation as resolved.
Outdated
if (username == null) {
throw new IllegalStateException("Credential file is missing required field '%s' [path=%s]".formatted(
SECRET_DATA_BASIC_AUTH_USERNAME_KEY,
path
));
}

var passwordNode = json.get(SECRET_DATA_BASIC_AUTH_PASSWORD_KEY);
if (passwordNode == null || passwordNode.isNull()) {
throw new IllegalStateException("Credential file is missing required field '%s' [path=%s]".formatted(
SECRET_DATA_BASIC_AUTH_PASSWORD_KEY,
path
));
}

return new Credentials(username, passwordNode.asText());
} catch (IOException e) {
throw new IllegalStateException("Failed to read Credential file [path=%s]".formatted(path), e);
}
}

public Credentials getSecretRefCredentials(
Expand Down
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