Updating a Secret in Kubernetes with the Java Client
While migrating a number of Java services to Kubernetes, I needed to replace handling of secrets on the filesystem with moving them into a secret management solution.
Instead of needing to learn how to operate and maintain a secret management tool like Hashicorp Vault securely, I decided to use Kubernetes' built in secret management.
I found that it wasn't super straightforward how to use it with the Java SDK, nor to set up a service account for the service to write the secret.
The below has been tested with Kubernetes Java SDK v13.0.1 and Kubernetes server API v1.21.
Service Account
Note This still allows access to the whole namespace, so is not very secure - I've not looked into it very much, but if you have improvements please let me know!
To make sure that only this specific application can write to the secrets, we create a new service account:
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: post-deploy-sa
namespace: www-api
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: post-deploy-role-secret-rw
namespace: www-api
rules:
- apiGroups:
- ''
resources:
- secrets
verbs:
- 'get'
- 'update'
- 'patch'
- 'create'
- 'list'
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: post-deploy-secret-rw-binding
namespace: www-api
subjects:
- kind: ServiceAccount
name: post-deploy-sa
namespace: www-api
roleRef:
kind: Role
name: post-deploy-role-secret-rw
apiGroup: rbac.authorization.k8s.io
And then in the deployment configuration for the application itself, we need to make sure we bind it to the deployment through serviceAccountName
:
apiVersion: apps/v1
kind: Deployment
metadata:
labels:
app: post-deploy
name: post-deploy
namespace: www-api
spec:
replicas: 1
selector:
matchLabels:
app: post-deploy
strategy: {}
template:
metadata:
labels:
app: post-deploy
spec:
serviceAccountName: post-deploy-sa
containers:
# ...
SDK Code
The Kubernetes SDK has the handy patchNamespacedSecret
method, which requires a V1Patch
, but it's not super clear that the argument to the constructor needs to be a raw JSON Patch:
// assuming we have an API client prepared
CoreV1Api api = new CoreV1Api();
// encode the secret into Base64
String rawValue = "this is a super secret value";
String encoded = Base64.getEncoder().encodeToString(rawValue.getBytes(StandardCharsets.UTF_8));
V1Patch patch = new V1Patch("[{\"op\": \"replace\", \"path\": \"/data/foo\", \"value\": \"" + encoded "\"}]");
V1Secret secret =
api.patchNamespacedSecret(
"post-deploy-secrets", "www-api", patch, null, null, "ThisClassName.class", null);
// `secret` has the updated data
You'll notice that this isn't super readable, nor will it be super fun to maintain as it's escaped JSON.
Also notice that we've got the whole JSON Patch element wrapped in an array, as there may be many operations.
The solution I ended up with was a helper class that can handle the encoding to Base64, and then serialised more easily:
import java.nio.charset.StandardCharsets;
import java.util.Base64;
public class PatchBody {
private static final String REPLACE_OPERATION = "replace";
private final String encodedSecret;
private final String path;
public PatchBody(String rawValue, String path) {
this.encodedSecret =
Base64.getEncoder().encodeToString(rawValue.getBytes(StandardCharsets.UTF_8));
this.path = "/data/" + path;
}
public String getOp() {
return REPLACE_OPERATION;
}
public String getPath() {
return path;
}
public String getValue() {
return encodedSecret;
}
}
This then allows us to do something like:
// assuming we have an API client prepared
CoreV1Api api = new CoreV1Api();
ObjectMapper mapper = new ObjectMapper();
// encode the secret into Base64
PatchBody patch = new PatchBody("this is a super secret value", "foo");
V1Patch patch = new V1Patch(mapper.writeValueAsString(List.of(patch)));
V1Secret secret =
api.patchNamespacedSecret(
"post-deploy-secrets", "www-api", patch, null, null, "ThisClassName.class", null);
// `secret` has the updated data