Skip to content

Commit e09164c

Browse files
fredordercloudFred Campos
andauthored
Add adminSecretFileRef as alternative to adminSecretRef for file-based credentials in ClusterConnection (#60)
Co-authored-by: Fred Campos <fred@shop2shop.co.za>
1 parent 6248b1e commit e09164c

12 files changed

Lines changed: 736 additions & 23 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ config/
2020
!.idea/misc.xml
2121
!.idea/sqldialects.xml
2222
!.idea/vcs.xml
23+
**/out/
2324

2425
*.iml
2526
*.ipr

docs/cluster-connection.md

Lines changed: 79 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,16 @@ Other Custom Resources (like `Database`, `Role`, `Schema`, `Grant`, `DefaultPriv
77

88
## Spec
99

10-
| Field | Type | Description | Required | Mutable |
11-
|------------------|---------------------|-----------------------------------------------------------------------|----------|---------|
12-
| `host` | `string` | The hostname of the PostgreSQL instance. | Yes | Yes |
13-
| `port` | `integer` | The port of the PostgreSQL instance (1-65535). | Yes | Yes |
14-
| `database` | `string` | The database to connect to (usually `postgres` for admin operations). | Yes | Yes |
15-
| `adminSecretRef` | `ResourceRef` | Reference to the Kubernetes Secret containing the admin credentials. | Yes | Yes |
16-
| `parameters` | `map[string]string` | Additional connection parameters. | No | Yes |
10+
| Field | Type | Description | Required | Mutable |
11+
|----------------------|----------------------|-----------------------------------------------------------------------|----------|---------|
12+
| `host` | `string` | The hostname of the PostgreSQL instance. | Yes | Yes |
13+
| `port` | `integer` | The port of the PostgreSQL instance (1-65535). | Yes | Yes |
14+
| `database` | `string` | The database to connect to (usually `postgres` for admin operations). | Yes | Yes |
15+
| `adminSecretRef` | `ResourceRef` | Reference to the Kubernetes Secret containing the admin credentials. | No | Yes |
16+
| `adminSecretFileRef` | `FileRef` | Reference to a file containing the admin credentials. | No | Yes |
17+
| `parameters` | `map[string]string` | Additional connection parameters. | No | Yes |
18+
19+
> **Note:** Exactly one of `adminSecretRef` or `adminSecretFileRef` must be provided.
1720
1821
### ResourceRef (`adminSecretRef`)
1922

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

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

27-
### Example
30+
### FileRef (`adminSecretFileRef`)
31+
32+
| Field | Type | Description | Required |
33+
|--------|----------|----------------------------------------------------------------|----------|
34+
| `path` | `string` | The path to the file containing the admin credentials. | Yes |
35+
36+
Use this option when the credentials are mounted as a file instead of a Kubernetes Secret.
37+
38+
### File format
39+
40+
The file must contain JSON with the following fields:
41+
42+
```json
43+
{
44+
"username": "root",
45+
"password": "password"
46+
}
47+
```
48+
49+
- `password` **required**
50+
- `username` **required**
51+
52+
#### Mount the credentials file
53+
54+
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:
55+
56+
```yaml
57+
apiVersion: apps/v1
58+
kind: Deployment
59+
metadata:
60+
name: postgresql-operator
61+
spec:
62+
template:
63+
spec:
64+
containers:
65+
- name: postgresql-operator
66+
volumeMounts:
67+
- name: db-credentials
68+
mountPath: /mnt/secrets
69+
readOnly: true
70+
volumes:
71+
- name: db-credentials
72+
secret:
73+
secretName: db-credentials-secret
74+
```
75+
76+
> **Note:** The volume source can be any type that provides a file.
77+
78+
> **Note:** The Helm chart does not support extra volumes yet.
79+
80+
### Examples
81+
82+
#### Using a Kubernetes Secret (`adminSecretRef`)
2883

2984
```yaml
3085
apiVersion: v1
@@ -54,3 +109,19 @@ spec:
54109
#sslmode: "require" # Enforce SSL encryption
55110
#connectTimeout: "10" # Timeout in seconds for connection attempts
56111
```
112+
113+
#### Using a file reference (`adminSecretFileRef`)
114+
115+
```yaml
116+
apiVersion: postgresql.aboutbits.it/v1
117+
kind: ClusterConnection
118+
metadata:
119+
name: quarkus-postgres-connection
120+
spec:
121+
adminSecretFileRef:
122+
path: "/mnt/secrets/db-credentials.json"
123+
host: localhost
124+
port: 5432
125+
database: postgres
126+
```
127+

docs/docker-environment.md

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,16 @@ users:
3939

4040
## 2. Create PostgreSQL Connection and Secret
4141

42-
For the `postgresql` Dev Service, you can generate the necessary Custom Resources to test the Operator:
42+
For the `postgresql` Dev Service, you can generate the necessary Custom Resources to test the Operator.
43+
44+
A `ClusterConnection` requires admin credentials, which can be provided in one of two ways:
45+
46+
- **`adminSecretRef`** references a Kubernetes `basic-auth` Secret (username + password).
47+
- **`adminSecretFileRef`** references a JSON file mounted into the operator pod.
48+
49+
Exactly one of these must be specified.
50+
51+
### Using a Kubernetes Secret (`adminSecretRef`)
4352

4453
1. From the Dev UI, get the `postgresql` Dev Service properties (username, password, host, port).
4554
2. Convert the `postgresql` Dev Service properties to a **Basic Auth Secret** and a **ClusterConnection** CR instance.

docs/terraform.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ Every optional field of every Custom Resource is affected, in particular:
9595

9696
| Custom Resource | Optional fields |
9797
|---------------------|------------------------------------------------------------------------------------------------|
98-
| `ClusterConnection` | `parameters`, `adminSecretRef.namespace` |
98+
| `ClusterConnection` | `parameters`, `adminSecretRef`, `adminSecretRef.namespace`, `adminSecretFileRef` |
9999
| `Database` | `owner`, `reclaimPolicy`, `clusterRef.namespace` |
100100
| `Schema` | `owner`, `reclaimPolicy`, `clusterRef.namespace` |
101101
| `Role` | `comment`, `passwordSecretRef`, `flags` (including `flags.validUntil`), `clusterRef.namespace` |

operator/build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ dependencies {
6262
*/
6363
testImplementation("io.quarkus:quarkus-junit")
6464
testImplementation("io.quarkus:quarkus-junit-mockito")
65+
testImplementation("io.fabric8:kubernetes-server-mock")
6566
testImplementation("org.awaitility:awaitility")
6667
testImplementation(libs.assertj)
6768
testImplementation(libs.datafaker)
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
package it.aboutbits.postgresql.core;
2+
3+
import io.fabric8.generator.annotation.Required;
4+
import io.fabric8.generator.annotation.ValidationRule;
5+
import lombok.Getter;
6+
import lombok.Setter;
7+
import org.jspecify.annotations.NullMarked;
8+
9+
/// A reference to a file inside the operator container.
10+
///
11+
/// This class is used wherever a CRD spec needs to point to a specific file
12+
/// The [#path] field identifies the file location within the container.
13+
///
14+
/// ### Example usage in a CR manifest
15+
///
16+
/// ```yaml
17+
/// spec:
18+
/// adminSecretFileRef:
19+
/// path: "/mnt/secrets/db-credentials.json"
20+
/// ```
21+
@Getter
22+
@Setter
23+
@NullMarked
24+
public class FileRef {
25+
/// The path to the file.
26+
/// Must not be blank.
27+
@Required
28+
@ValidationRule(
29+
value = "self.trim().size() > 0",
30+
message = "The path must not be empty."
31+
)
32+
private String path = "";
33+
}

operator/src/main/java/it/aboutbits/postgresql/core/KubernetesService.java

Lines changed: 64 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,88 @@
11
package it.aboutbits.postgresql.core;
22

3+
import com.fasterxml.jackson.databind.ObjectMapper;
34
import io.fabric8.kubernetes.client.KubernetesClient;
45
import it.aboutbits.postgresql.crd.clusterconnection.ClusterConnection;
56
import jakarta.inject.Singleton;
7+
import lombok.RequiredArgsConstructor;
68
import org.jspecify.annotations.NullMarked;
9+
import org.jspecify.annotations.Nullable;
710

11+
import java.io.IOException;
812
import java.nio.charset.Charset;
13+
import java.nio.file.Files;
14+
import java.nio.file.NoSuchFileException;
15+
import java.nio.file.Path;
916
import java.util.Base64;
1017

1118
@Singleton
19+
@RequiredArgsConstructor
1220
@NullMarked
1321
public final class KubernetesService {
22+
private final ObjectMapper objectMapper;
23+
24+
private record FileCredentials(
25+
@Nullable String username,
26+
@Nullable String password
27+
) {
28+
}
29+
1430
public static final String SECRET_TYPE_BASIC_AUTH = "kubernetes.io/basic-auth";
1531
public static final String SECRET_DATA_BASIC_AUTH_USERNAME_KEY = "username";
1632
public static final String SECRET_DATA_BASIC_AUTH_PASSWORD_KEY = "password";
1733

18-
public Credentials getSecretRefCredentials(
34+
public Credentials getAdminCredentials(
1935
KubernetesClient kubernetesClient,
2036
ClusterConnection clusterConnection
2137
) {
22-
return getSecretRefCredentials(
23-
kubernetesClient,
24-
clusterConnection.getSpec().getAdminSecretRef(),
25-
clusterConnection.getMetadata().getNamespace()
26-
);
38+
var spec = clusterConnection.getSpec();
39+
if (spec.getAdminSecretRef() != null) {
40+
var secretRef = spec.getAdminSecretRef();
41+
var defaultNamespace = clusterConnection.getMetadata().getNamespace();
42+
var credentials = getSecretRefCredentials(kubernetesClient, secretRef, defaultNamespace);
43+
if (credentials.username() == null) {
44+
var secretNamespace = getSecretNamespace(secretRef, defaultNamespace);
45+
throw new IllegalStateException(
46+
"The Secret reference is missing required data username [secret.namespace=%s, secret.name=%s]".formatted(
47+
secretNamespace, secretRef.getName()));
48+
}
49+
return credentials;
50+
} else if (spec.getAdminSecretFileRef() != null) {
51+
return getSecretFileRefCredentials(spec.getAdminSecretFileRef());
52+
}
53+
54+
throw new IllegalStateException("Exactly one of 'adminSecretRef' or 'adminSecretFileRef' must be provided");
55+
}
56+
57+
public Credentials getSecretFileRefCredentials(FileRef fileRef) {
58+
var path = Path.of(fileRef.getPath());
59+
60+
try (var in = Files.newInputStream(path)) {
61+
var file = objectMapper.readValue(in, FileCredentials.class);
62+
if (file.username() == null) {
63+
throw new IllegalStateException(
64+
"Credentials file is missing required field 'username' [path=%s]".formatted(path));
65+
}
66+
if (file.password() == null) {
67+
throw new IllegalStateException(
68+
"Credentials file is missing required field 'password' [path=%s]".formatted(path));
69+
}
70+
return new Credentials(file.username(), file.password());
71+
} catch (NoSuchFileException e) {
72+
throw new IllegalStateException(
73+
"Credentials file not found [path=%s]".formatted(path), e);
74+
} catch (IOException e) {
75+
throw new IllegalStateException(
76+
"Failed to read the credentials file [path=%s]".formatted(path), e);
77+
}
2778
}
2879

2980
public Credentials getSecretRefCredentials(
3081
KubernetesClient kubernetesClient,
3182
ResourceRef secretRef,
3283
String defaultNamespace
3384
) {
34-
var secretNamespace = secretRef.getNamespace() != null
35-
? secretRef.getNamespace()
36-
: defaultNamespace;
85+
var secretNamespace = getSecretNamespace(secretRef, defaultNamespace);
3786

3887
var secretName = secretRef.getName();
3988

@@ -91,4 +140,10 @@ public Credentials getSecretRefCredentials(
91140
password
92141
);
93142
}
143+
144+
private String getSecretNamespace(ResourceRef secretRef, String defaultNamespace) {
145+
return secretRef.getNamespace() != null
146+
? secretRef.getNamespace()
147+
: defaultNamespace;
148+
}
94149
}

operator/src/main/java/it/aboutbits/postgresql/core/PostgreSQLContextFactory.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ public CloseableDSLContext getDSLContext(
3434
ClusterConnection clusterConnection,
3535
String database
3636
) throws DataAccessException {
37-
var credentials = kubernetesService.getSecretRefCredentials(
37+
var credentials = kubernetesService.getAdminCredentials(
3838
kubernetesClient,
3939
clusterConnection
4040
);

operator/src/main/java/it/aboutbits/postgresql/crd/clusterconnection/ClusterConnectionSpec.java

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,13 @@
55
import io.fabric8.generator.annotation.Min;
66
import io.fabric8.generator.annotation.Required;
77
import io.fabric8.generator.annotation.ValidationRule;
8+
import it.aboutbits.postgresql.core.FileRef;
89
import it.aboutbits.postgresql.core.ResourceRef;
910
import it.aboutbits.postgresql.core.schema_customizer.HostCustomizer;
1011
import lombok.Getter;
1112
import lombok.Setter;
1213
import org.jspecify.annotations.NullMarked;
14+
import org.jspecify.annotations.Nullable;
1315

1416
import java.util.HashMap;
1517
import java.util.Map;
@@ -18,6 +20,10 @@
1820
@Setter
1921
@SchemaCustomizer(value = HostCustomizer.class, input = "host")
2022
@NullMarked
23+
@ValidationRule(
24+
value = "(has(self.adminSecretRef) ? 1 : 0) + (has(self.adminSecretFileRef) ? 1 : 0) == 1",
25+
message = "Exactly one of 'adminSecretRef' or 'adminSecretFileRef' must be provided"
26+
)
2127
public class ClusterConnectionSpec {
2228
@Required
2329
@ValidationRule(
@@ -38,8 +44,11 @@ public class ClusterConnectionSpec {
3844
)
3945
private String database = "postgres";
4046

41-
@Required
42-
private ResourceRef adminSecretRef = new ResourceRef();
47+
@io.fabric8.generator.annotation.Nullable
48+
private @Nullable ResourceRef adminSecretRef;
49+
50+
@io.fabric8.generator.annotation.Nullable
51+
private @Nullable FileRef adminSecretFileRef;
4352

4453
@io.fabric8.generator.annotation.Nullable
4554
private Map<String, String> parameters = new HashMap<>();

operator/src/test/java/it/aboutbits/postgresql/_support/testdata/persisted/creator/ClusterConnectionCreate.java

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import io.fabric8.kubernetes.client.KubernetesClient;
55
import it.aboutbits.postgresql._support.testdata.base.TestDataCreator;
66
import it.aboutbits.postgresql._support.testdata.persisted.Given;
7+
import it.aboutbits.postgresql.core.FileRef;
78
import it.aboutbits.postgresql.core.ResourceRef;
89
import it.aboutbits.postgresql.crd.clusterconnection.ClusterConnection;
910
import it.aboutbits.postgresql.crd.clusterconnection.ClusterConnectionSpec;
@@ -41,6 +42,11 @@ public class ClusterConnectionCreate extends TestDataCreator<ClusterConnection>
4142

4243
private @Nullable ResourceRef withAdminSecretRef;
4344

45+
private @Nullable FileRef withAdminSecretFileRef;
46+
47+
@Setter(AccessLevel.NONE)
48+
private boolean withoutAdminSecret = false;
49+
4450
private @Nullable String withApplicationName;
4551

4652
public ClusterConnectionCreate(
@@ -61,6 +67,16 @@ public ClusterConnectionCreate withoutNamespace() {
6167
return this;
6268
}
6369

70+
public ClusterConnectionCreate withAdminSecretFileRef(FileRef fileRef) {
71+
this.withAdminSecretFileRef = fileRef;
72+
return this;
73+
}
74+
75+
public ClusterConnectionCreate withoutAdminSecret() {
76+
this.withoutAdminSecret = true;
77+
return this;
78+
}
79+
6480
@Override
6581
protected ClusterConnection create(int index) {
6682
// given
@@ -81,6 +97,9 @@ protected ClusterConnection create(int index) {
8197
spec.setPort(getPort());
8298
spec.setDatabase(getDatabase());
8399
spec.setAdminSecretRef(getAdminSecretRef());
100+
if (withAdminSecretFileRef != null) {
101+
spec.setAdminSecretFileRef(withAdminSecretFileRef);
102+
}
84103
spec.setParameters(getParameters());
85104

86105
item.setSpec(spec);
@@ -154,7 +173,11 @@ private String getDatabase() {
154173
return withDatabase;
155174
}
156175

157-
private ResourceRef getAdminSecretRef() {
176+
private @Nullable ResourceRef getAdminSecretRef() {
177+
if (withoutAdminSecret) {
178+
return null;
179+
}
180+
158181
if (withAdminSecretRef != null) {
159182
return withAdminSecretRef;
160183
}

0 commit comments

Comments
 (0)