Skip to content
Open
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
75 changes: 73 additions & 2 deletions USAGEGUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,9 @@
4. [Logging level](#logging-level)
5. [Usage examples](#usage-examples)
6. [How 1Password Items Map to Kubernetes Secrets](#how-1password-items-map-to-kubernetes-secrets)
7. [Configuring Automatic Rolling Restarts of Deployments](#configuring-automatic-rolling-restarts-of-deployments)
8. [Development](#development)
7. [Secret Templates](#secret-templates)
8. [Configuring Automatic Rolling Restarts of Deployments](#configuring-automatic-rolling-restarts-of-deployments)
9. [Development](#development)


---
Expand Down Expand Up @@ -126,6 +127,76 @@ Titles and field names that include white space and other characters that are no

---

## Secret Templates

By default, each field in a 1Password item maps directly to a key in the
Kubernetes Secret. **Secret templates** let you transform item data into custom
formats using [Go templates](https://pkg.go.dev/text/template) so that a
single `OnePasswordItem` can produce exactly the secret layout your application
expects.

### Basic example

```yaml
apiVersion: onepassword.com/v1
kind: OnePasswordItem
metadata:
name: my-database-config
spec:
itemPath: "vaults/my-vault/items/my-db-item"
template:
data:
DSN: "postgresql://{{ .Fields.username }}:{{ .Fields.password }}@{{ .Fields.host }}:{{ .Fields.port }}/{{ .Fields.database }}"
```

Instead of creating a secret with individual keys for `username`, `password`,
`host`, `port`, and `database`, the operator creates a single `DSN` key whose
value is the rendered connection string.

### Multiple keys

You can define as many output keys as you need:

```yaml
spec:
itemPath: "vaults/my-vault/items/my-item"
template:
data:
config.yaml: |
server:
username: {{ .Fields.username }}
password: {{ .Fields.password }}
DB_HOST: "{{ .Fields.host }}"
```

### Template context

The following data is available inside templates:

| Expression | Description |
|---|---|
| `{{ .Fields.<label> }}` | Value of a field by its label (works when the label is a valid Go identifier). |
| `{{ index .Fields "<label>" }}` | Value of a field by its label. Required for labels that contain hyphens or other special characters, e.g. `{{ index .Fields "api-key" }}`. |
| `{{ .Sections.<title>.<label> }}` | Value of a field within a named section, e.g. `{{ .Sections.Database.username }}`. |
| `{{ index .Sections "<title>" "<label>" }}` | Same, using `index` for special-character titles/labels. |
| `{{ .FieldsByID.<id> }}` | Value of a field by its unique 1Password field ID. Use this when labels are duplicated across sections. |

### Behaviour notes

- When a `template` is specified, **only** the keys defined in `template.data`
appear in the Kubernetes Secret. Individual item fields are **not** added as
separate keys.
- If a template fails to render (e.g. syntax error or missing field), that key
is skipped and an error is logged. Other keys in the same template are still
rendered.
- If `template` is omitted (or its `data` map is empty), the operator falls
back to the default behaviour of mapping fields, URLs and files directly.
- All standard [Go template functions](https://pkg.go.dev/text/template#hdr-Functions)
are available (`index`, `printf`, `len`, `eq`, conditional blocks, ranges,
etc.).

---

## Configuring Automatic Rolling Restarts of Deployments

If a 1Password Item that is linked to a Kubernetes Secret is updated, any deployments configured to `auto-restart` AND are using that secret will be given a rolling restart the next time 1Password Connect is polled for updates.
Expand Down
17 changes: 17 additions & 0 deletions api/v1/onepassworditem_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,29 @@ import (
// EDIT THIS FILE! THIS IS SCAFFOLDING FOR YOU TO OWN!
// NOTE: json tags are required. Any new fields you add must have json tags for the fields to be serialized.

// SecretTemplate defines Go templates for generating secret data keys.
// Each key in Data is a secret data key, and its value is a Go template string
// that will be rendered using the 1Password item's fields as context.
type SecretTemplate struct {
// Data is a map of secret data key names to Go template strings.
// Templates can access fields via .Fields (flat map), .Sections (nested by section),
// or .FieldsByID (by field ID).
// +optional
Data map[string]string `json:"data,omitempty"`
}

// OnePasswordItemSpec defines the desired state of OnePasswordItem
type OnePasswordItemSpec struct {
// INSERT ADDITIONAL SPEC FIELDS - desired state of cluster
// Important: Run "make" to regenerate code after modifying this file

ItemPath string `json:"itemPath,omitempty"`

// Template defines Go templates for generating custom secret data.
// When set, the secret data will be generated by rendering the templates
// instead of using the default 1:1 field-to-key mapping.
// +optional
Template *SecretTemplate `json:"template,omitempty"`
}

type OnePasswordItemConditionType string
Expand Down
29 changes: 28 additions & 1 deletion api/v1/zz_generated.deepcopy.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

15 changes: 15 additions & 0 deletions config/crd/bases/onepassword.com_onepassworditems.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,21 @@ spec:
properties:
itemPath:
type: string
template:
description: |-
Template defines Go templates for generating custom secret data.
When set, the secret data will be generated by rendering the templates
instead of using the default 1:1 field-to-key mapping.
properties:
data:
additionalProperties:
type: string
description: |-
Data is a map of secret data key names to Go template strings.
Templates can access fields via .Fields (flat map), .Sections (nested by section),
or .FieldsByID (by field ID).
type: object
type: object
type: object
status:
description: OnePasswordItemStatus defines the observed state of OnePasswordItem
Expand Down
2 changes: 1 addition & 1 deletion internal/controller/deployment_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -223,5 +223,5 @@ func (r *DeploymentReconciler) handleApplyingDeployment(ctx context.Context, dep
UID: deployment.GetUID(),
}

return kubeSecrets.CreateKubernetesSecretFromItem(ctx, r.Client, secretName, namespace, item, annotations[op.AutoRestartWorkloadAnnotation], secretLabels, annotations, secretType, ownerRef, r.Config.AllowEmptyValues)
return kubeSecrets.CreateKubernetesSecretFromItem(ctx, r.Client, secretName, namespace, item, annotations[op.AutoRestartWorkloadAnnotation], secretLabels, annotations, secretType, ownerRef, r.Config.AllowEmptyValues, nil)
}
5 changes: 4 additions & 1 deletion internal/controller/onepassworditem_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,9 @@ func (r *OnePasswordItemReconciler) handleOnePasswordItem(ctx context.Context, r
return fmt.Errorf("failed to retrieve item: %w", err)
}

// Extract template config from spec.
secretTemplate := resource.Spec.Template

// Create owner reference.
gvk, err := apiutil.GVKForObject(resource, r.Scheme)
if err != nil {
Expand All @@ -188,7 +191,7 @@ func (r *OnePasswordItemReconciler) handleOnePasswordItem(ctx context.Context, r
UID: resource.GetUID(),
}

return kubeSecrets.CreateKubernetesSecretFromItem(ctx, r.Client, secretName, resource.Namespace, item, autoRestart, labels, annotations, secretType, ownerRef, r.Config.AllowEmptyValues)
return kubeSecrets.CreateKubernetesSecretFromItem(ctx, r.Client, secretName, resource.Namespace, item, autoRestart, labels, annotations, secretType, ownerRef, r.Config.AllowEmptyValues, secretTemplate)
}

func (r *OnePasswordItemReconciler) updateStatus(ctx context.Context, resource *onepasswordv1.OnePasswordItem, err error) error {
Expand Down
109 changes: 109 additions & 0 deletions internal/controller/onepassworditem_controller_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,115 @@ var _ = Describe("OnePasswordItem controller", func() {
})
})

Context("Template support", func() {
It("Should create a K8s secret with templated data from a OnePasswordItem", func() {
ctx := context.Background()
spec := onepasswordv1.OnePasswordItemSpec{
ItemPath: item1.Path,
Template: &onepasswordv1.SecretTemplate{
Data: map[string]string{
"config.yaml": "user: {{ .Fields.username }}\npass: {{ .Fields.password }}",
},
},
}

key := types.NamespacedName{
Name: "templated-secret",
Namespace: namespace,
}

toCreate := &onepasswordv1.OnePasswordItem{
ObjectMeta: metav1.ObjectMeta{
Name: key.Name,
Namespace: key.Namespace,
},
Spec: spec,
}

By("Creating a new OnePasswordItem with template")
Expect(k8sClient.Create(ctx, toCreate)).Should(Succeed())

created := &onepasswordv1.OnePasswordItem{}
Eventually(func() bool {
err := k8sClient.Get(ctx, key, created)
return err == nil
}, timeout, interval).Should(BeTrue())

By("Creating the K8s secret with templated data")
createdSecret := &v1.Secret{}
Eventually(func() bool {
err := k8sClient.Get(ctx, key, createdSecret)
return err == nil
}, timeout, interval).Should(BeTrue())

expectedConfig := fmt.Sprintf("user: %s\npass: %s", username, password)
Expect(createdSecret.Data).Should(HaveKeyWithValue("config.yaml", []byte(expectedConfig)))

By("Ensuring individual fields are NOT present as separate keys")
Expect(createdSecret.Data).ShouldNot(HaveKey("username"))
Expect(createdSecret.Data).ShouldNot(HaveKey("password"))

By("Deleting the OnePasswordItem successfully")
Eventually(func() error {
f := &onepasswordv1.OnePasswordItem{}
err := k8sClient.Get(ctx, key, f)
if err != nil {
return err
}
return k8sClient.Delete(ctx, f)
}, timeout, interval).Should(Succeed())
})

It("Should create a K8s secret with multiple templated keys", func() {
ctx := context.Background()
spec := onepasswordv1.OnePasswordItemSpec{
ItemPath: item1.Path,
Template: &onepasswordv1.SecretTemplate{
Data: map[string]string{
"DSN": "postgresql://{{ .Fields.username }}:{{ .Fields.password }}@localhost:5432/mydb",
"USER": "{{ .Fields.username }}",
},
},
}

key := types.NamespacedName{
Name: "multi-template-secret",
Namespace: namespace,
}

toCreate := &onepasswordv1.OnePasswordItem{
ObjectMeta: metav1.ObjectMeta{
Name: key.Name,
Namespace: key.Namespace,
},
Spec: spec,
}

By("Creating a new OnePasswordItem with multiple template keys")
Expect(k8sClient.Create(ctx, toCreate)).Should(Succeed())

createdSecret := &v1.Secret{}
Eventually(func() bool {
err := k8sClient.Get(ctx, key, createdSecret)
return err == nil
}, timeout, interval).Should(BeTrue())

expectedDSN := fmt.Sprintf("postgresql://%s:%s@localhost:5432/mydb", username, password)
Expect(createdSecret.Data).Should(HaveKeyWithValue("DSN", []byte(expectedDSN)))
Expect(createdSecret.Data).Should(HaveKeyWithValue("USER", []byte(username)))

By("Deleting the OnePasswordItem successfully")
Eventually(func() error {
f := &onepasswordv1.OnePasswordItem{}
err := k8sClient.Get(ctx, key, f)
if err != nil {
return err
}
return k8sClient.Delete(ctx, f)
}, timeout, interval).Should(Succeed())
})
})

Context("Unhappy path", func() {
It("Should throw an error if K8s Secret type is changed", func() {
ctx := context.Background()
Expand Down
Loading