Skip to content
This repository was archived by the owner on Jul 26, 2022. It is now read-only.

Commit 6639553

Browse files
authored
feat(poller): lodash template preprocess for externalsecret.spec.template field (#626)
1 parent 66da5e0 commit 6639553

9 files changed

+369
-384
lines changed

.gitignore

+1
Original file line numberDiff line numberDiff line change
@@ -60,4 +60,5 @@ typings/
6060
# e2e test stuff
6161
e2e/**/.kubeconfig
6262

63+
# IDE
6364
.idea/

README.md

+127
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,133 @@ spec:
236236
name: .dockerconfigjson
237237
```
238238

239+
### Templating
240+
241+
Kubernetes External Secrets supports templating in `ExternalSecret` using [lodash.template](https://lodash.com/docs/4.17.15#template).
242+
243+
Template is applied to all `ExternalSecret.template` section of manifest.
244+
Data retrieved from secure backend is available via `data` variable.
245+
Additonal object `yaml` of instance of [js-yaml](https://github.com/nodeca/js-yaml) is available in `lodash` templates.
246+
It can be leveraged for easier YAML content manipulation.
247+
248+
Templating can be used for:
249+
250+
* Generating K8S `Secret` keys:
251+
* upserting plain text via `ExternalSecret.template.stringData`
252+
* upserting base64 encoded content `ExternalSecret.template.data`
253+
* For creating dynamic labels, annotations and other fields available in K8S `Secret` object.
254+
255+
To demonstrate templating functionality let's assume the secure backend, e.g. Hashicorp Vaule, contains following data
256+
257+
<table>
258+
<tr>
259+
<th>kv/extsec/secret1</th>
260+
<th>kv/extsec/secret2</th
261+
</tr>
262+
<tr>
263+
<td>
264+
265+
```json
266+
{
267+
"intKey": 11,
268+
"objKey": {
269+
"strKey": "hello world"
270+
}
271+
}
272+
```
273+
274+
</td>
275+
<td>
276+
277+
```json
278+
{
279+
"arrKey": [
280+
1,
281+
2,
282+
3
283+
]
284+
}
285+
```
286+
287+
</td>
288+
</tr>
289+
</table>
290+
291+
Then, one could create following `ExternalSecret`
292+
293+
````yaml
294+
apiVersion: kubernetes-client.io/v1
295+
kind: ExternalSecret
296+
metadata:
297+
name: tmpl-ext-sec
298+
spec:
299+
backendType: vault
300+
data:
301+
- key: kv/data/extsec/secret1
302+
name: s1
303+
- key: kv/data/extsec/secret2
304+
name: s2
305+
kvVersion: 2
306+
template:
307+
data:
308+
file.txt: |
309+
<%= Buffer.from(JSON.stringify(JSON.parse(data.s1).objKey)).toString("base64") %>
310+
metadata:
311+
labels:
312+
label1: <%= JSON.parse(data.s1).intKey %>
313+
label2: <%= JSON.parse(data.s1).objKey.strKey.replace(" ", "-") %>
314+
stringData:
315+
file.yaml: |
316+
<%= yaml.dump(JSON.parse(data.s1)) %>
317+
<% let s2 = JSON.parse(data.s2) %><% s2.arrKey.forEach((e, i) => { %>arr_<%= i %>: <%= e %>
318+
<% }) %>`
319+
vaultMountPoint: kubernetes
320+
vaultRole: demo
321+
````
322+
323+
After applying this `ExternalSecret` to K8S cluster, the operator will generate following `Secret`
324+
325+
````yaml
326+
apiVersion: v1
327+
data:
328+
file.txt: eyJzdHJLZXkiOiJoZWxsbyB3b3JsZCJ9
329+
file.yaml: aW50S2V5OiAxMQpvYmpLZXk6CiAgc3RyS2V5OiBoZWxsbyB3b3JsZAoKYXJyXzA6IDEKYXJyXzE6IDIKYXJyXzI6IDMKYAo=
330+
s1: eyJpbnRLZXkiOjExLCJvYmpLZXkiOnsic3RyS2V5IjoiaGVsbG8gd29ybGQifX0=
331+
s2: eyJhcnJLZXkiOlsxLDIsM119
332+
kind: Secret
333+
metadata:
334+
name: tmpl-ext-sec
335+
labels:
336+
label1: "11"
337+
label2: hello-world
338+
type: Opaque
339+
````
340+
341+
Resulting `Secret` could be inspected to see that result is generated by `lodash` templating engine
342+
343+
````bash
344+
$ kubectl get secret/tmpl-ext-sec -ogo-template='{{ index .data "s1" | base64decode }}'
345+
{"intKey":11,"objKey":{"strKey":"hello world"}}
346+
347+
$ kubectl get secret/tmpl-ext-sec -ogo-template='{{ index .data "s2" | base64decode }}'
348+
{"arrKey":[1,2,3]}
349+
350+
$ kubectl get secret/tmpl-ext-sec -ogo-template='{{ index .data "file.txt" | base64decode }}'
351+
{"strKey":"hello world"}
352+
353+
$ kubectl get secret/tmpl-ext-sec -ogo-template='{{ index .data "file.yaml" | base64decode }}'
354+
intKey: 11
355+
objKey:
356+
strKey: hello world
357+
358+
arr_0: 1
359+
arr_1: 2
360+
arr_2: 3
361+
362+
$ kubectl get secret/tmpl-ext-sec -ogo-template='{{ .metadata.labels }}'
363+
map[label1:11 label2:hello-world]
364+
````
365+
239366
## Scoping access
240367

241368
### Using Namespace annotation

e2e/tests/secrets-manager.test.js

+47
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,53 @@ describe('secretsmanager', async () => {
7777
expect(secret.body.data.password).to.equal('MTIzNA==')
7878
})
7979

80+
it('should pull existing secret from secretsmanager and create a secret using templating', async () => {
81+
let result = await createSecret({
82+
Name: `e2e/${uuid}/template`,
83+
SecretString: '{"secretData":"foo123"}'
84+
}).catch(err => {
85+
expect(err).to.equal(null)
86+
})
87+
88+
result = await kubeClient
89+
.apis[customResourceManifest.spec.group]
90+
.v1.namespaces('default')[customResourceManifest.spec.names.plural]
91+
.post({
92+
body: {
93+
apiVersion: 'kubernetes-client.io/v1',
94+
kind: 'ExternalSecret',
95+
metadata: {
96+
name: `e2e-secretmanager-template-${uuid}`
97+
},
98+
spec: {
99+
template: {
100+
metadata: {
101+
labels: {
102+
secretLabel: '<%= "Hello".concat(data.secretData) %>'
103+
}
104+
}
105+
},
106+
backendType: 'secretsManager',
107+
data: [
108+
{
109+
key: `e2e/${uuid}/template`,
110+
property: 'secretData',
111+
name: 'secretData'
112+
}
113+
]
114+
}
115+
}
116+
})
117+
118+
expect(result).to.not.equal(undefined)
119+
expect(result.statusCode).to.equal(201)
120+
121+
const secret = await waitForSecret('default', `e2e-secretmanager-template-${uuid}`)
122+
expect(secret).to.not.equal(undefined)
123+
expect(secret.body.data.secretData).to.equal('Zm9vMTIz') // foo123 is base64 Zm9vMTIz
124+
expect(secret.body.metadata.labels.secretLabel).to.equal('Hellofoo123')
125+
})
126+
80127
it('should pull TLS secret from secretsmanager', async () => {
81128
let result = await createSecret({
82129
Name: `e2e/${uuid}/tls/cert`,
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
apiVersion: 'kubernetes-client.io/v1'
2+
kind: ExternalSecret
3+
metadata:
4+
name: hello-service
5+
spec:
6+
backendType: vault
7+
vaultMountPoint: my-kubernetes-vault-mount-point
8+
vaultRole: my-vault-role
9+
kvVersion: 2
10+
data:
11+
- key: kv/data/test/secret1
12+
name: s1
13+
- key: kv/data/test/secret2
14+
name: s2
15+
template:
16+
metadata:
17+
labels:
18+
world: <% let content = JSON.parse(data.s1) %><%= content.f2.f22 %>
19+
stringData:
20+
file.yaml: |
21+
<%= yaml.dump(JSON.parse(data.s1)) %>
22+
<% let s2 = JSON.parse(data.s2) %><% s2.arr.forEach((e, i) => { %>arr_<%= i %>: <%= e %>
23+
<% }) %>

lib/poller.js

+9-1
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
const clonedeep = require('lodash.clonedeep')
44
const merge = require('lodash.merge')
5+
const mapValues = require('lodash.mapvalues')
6+
const { compileObjectTemplateKeys } = require('./utils')
57

68
/**
79
* Kubernetes secret descriptor.
@@ -85,14 +87,20 @@ class Poller {
8587
*/
8688
async _createSecretManifest () {
8789
const spec = this._spec
88-
const template = spec.template || {}
90+
let template = spec.template || {}
8991

9092
// spec.type for backwards compat
9193
const type = template.type || spec.type || 'Opaque'
9294

9395
const data = await this._backends[spec.backendType]
9496
.getSecretManifestData({ spec })
9597

98+
if (template && typeof template === 'object' && !Array.isArray(template)) {
99+
const decodedData = mapValues(data, value => Buffer.from(value, 'base64').toString('ascii'))
100+
101+
template = compileObjectTemplateKeys(template, decodedData)
102+
}
103+
96104
const secretManifest = {
97105
apiVersion: 'v1',
98106
kind: 'Secret',

lib/poller.test.js

+93
Original file line numberDiff line numberDiff line change
@@ -328,6 +328,99 @@ describe('Poller', () => {
328328
}
329329
})
330330
})
331+
332+
it('creates secret manifest - with lodash template', async () => {
333+
const poller = pollerFactory({
334+
type: 'dummy-test-type',
335+
backendType: 'fakeBackendType',
336+
name: 'fakeSecretName',
337+
template: {
338+
metadata: {
339+
labels: {
340+
world: '<% let content = JSON.parse(data.s1) %><%= content.f2.f22 %>'
341+
}
342+
},
343+
stringData: {
344+
'test.yaml': `
345+
<%= yaml.dump(JSON.parse(data.s1)) %>
346+
<% let s2 = JSON.parse(data.s2) %><% s2.arr.forEach((e, i) => { %>arr_<%= i %>: <%= e %>
347+
<% }) %>
348+
`
349+
}
350+
},
351+
data: [
352+
{ key: 'kv/data/test/secret1', name: 's1' },
353+
{ key: 'kv/data/test/secret2', name: 's2' }
354+
]
355+
})
356+
357+
backendMock.getSecretManifestData.resolves({
358+
s1: 'eyJmMSI6MTEsImYyIjp7ImYyMiI6ImhlbGxvIn19Cg==', // base 64 value
359+
s2: 'eyJhcnIiOlsxLDIsM119' // base 64 value
360+
})
361+
362+
const secretManifest = await poller._createSecretManifest()
363+
364+
expect(secretManifest).deep.equals({
365+
apiVersion: 'v1',
366+
kind: 'Secret',
367+
metadata: {
368+
ownerReferences: [getOwnerReference()],
369+
labels: {
370+
world: 'hello'
371+
},
372+
name: 'fakeSecretName'
373+
},
374+
type: 'dummy-test-type',
375+
data: {
376+
s1: 'eyJmMSI6MTEsImYyIjp7ImYyMiI6ImhlbGxvIn19Cg==', // base 64 value
377+
s2: 'eyJhcnIiOlsxLDIsM119' // base 64 value
378+
},
379+
stringData: {
380+
'test.yaml': '\n f1: 11\nf2:\n f22: hello\n\n arr_0: 1\n arr_1: 2\n arr_2: 3\n \n '
381+
}
382+
})
383+
})
384+
385+
it('creates secret manifest - with lodash template (without stringData)', async () => {
386+
const poller = pollerFactory({
387+
type: 'dummy-test-type',
388+
backendType: 'fakeBackendType',
389+
name: 'fakeSecretName',
390+
template: {
391+
metadata: {
392+
labels: {
393+
world: '<% let content = JSON.parse(data.s1) %><%= content.f2.f22 %>'
394+
}
395+
}
396+
},
397+
data: [
398+
{ key: 'kv/data/test/secret1', name: 's1' }
399+
]
400+
})
401+
402+
backendMock.getSecretManifestData.resolves({
403+
s1: 'eyJmMSI6MTEsImYyIjp7ImYyMiI6ImhlbGxvIn19Cg==' // base 64 value
404+
})
405+
406+
const secretManifest = await poller._createSecretManifest()
407+
408+
expect(secretManifest).deep.equals({
409+
apiVersion: 'v1',
410+
kind: 'Secret',
411+
metadata: {
412+
ownerReferences: [getOwnerReference()],
413+
labels: {
414+
world: 'hello'
415+
},
416+
name: 'fakeSecretName'
417+
},
418+
type: 'dummy-test-type',
419+
data: {
420+
s1: 'eyJmMSI6MTEsImYyIjp7ImYyMiI6ImhlbGxvIn19Cg==' // base 64 value
421+
}
422+
})
423+
})
331424
})
332425

333426
describe('_poll', () => {

lib/utils.js

+26
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
const yaml = require('js-yaml')
2+
const parseTemplate = require('lodash.template')
3+
const mapValues = require('lodash.mapvalues')
4+
5+
const compileTemplate = (template, data) => parseTemplate(template, { imports: { yaml }, variable: 'data' })(data)
6+
7+
const compileObjectTemplateKeys = (object, data) => {
8+
return mapValues(object, (value) => {
9+
if (value) {
10+
const valueType = typeof value
11+
12+
if (valueType === 'string') {
13+
return compileTemplate(value, data)
14+
} else if (valueType === 'object' && !Array.isArray(value)) {
15+
return compileObjectTemplateKeys(value, data)
16+
}
17+
}
18+
19+
return value
20+
})
21+
}
22+
23+
module.exports = {
24+
compileTemplate,
25+
compileObjectTemplateKeys
26+
}

0 commit comments

Comments
 (0)