# shdctl secret commands

> Generate and deploy Kubernetes Secret manifests from a secrets.import.yaml input, and recover that input from a cluster.

Use the `secret` command group to render Kubernetes `Secret` manifests from your import file, apply them to your cluster, and recover that import file from a cluster.

## Generate secrets

Create Kubernetes secret YAML files from your secret definitions. For the import file format, refer to the [annotated example import file](./secret.md#import-file-format).

Generate the secrets from your import file without prompting:

```sh
shdctl secret generate --import secrets.import.yaml --use-defaults
```

**Parameters:**

* `--import`: Path to the secrets import file, in the [import file format](./secret.md#import-file-format). Any value in the file the release's schema does not ask for is not read, and each one is warned about, naming the file and the entry — a secret the schema does not define, a key that secret does not declare, or a key naming a field the schema derives from a default. Read those before deploying: where the schema generates the field you meant to supply, a fresh random value is generated in place of yours.
* `--use-defaults`: Use default values or auto-generate values without prompts (non-interactive mode). If a required field has no default or auto-generate option, the tool writes `TBD` and logs a warning so you can update it before deployment. With `--persist`, a placeholder is saved like any other value, and runs that read that file back reuse it without repeating the warning: replace it in the file.
* `--output`: Output file path (defaults to `secrets.yaml`). An existing file is replaced on every run
* `--extracted-release`: Path to the extracted release directory (defaults to `./extracted-release`)
* `--name`: Write only the secret with this name — one this release and manifest produce, or the image pull secret. The secrets derive from one another, so every generated value has to come from your input: pass `--import` with the file `shdctl secret export` rebuilds from the cluster, or your `--persist` file. A run that would mint one afresh is refused, naming it; so is an unknown name, listing the ones that would work, and the pull secret's name when that secret would be skipped. Nothing is written in any of these cases
* `--clean-output`: Remove the output file before generating, so a run that fails does not leave the previous one behind (default: false). Nothing else in its directory is touched
* `--skip-version-check`: Skip the shdctl version compatibility check against the release package
* `--persist <path>`: Save the values of this run back to an import-format file at `<path>` for reuse on subsequent runs — every value you supplied or shdctl generated, not the ones derived from them. On re-runs, the persisted file is auto-loaded and existing values are reused without regeneration — but only when no `--import` is given: with `--import`, that file supplies the values and `<path>` is only written. The path is required (no implicit default).

Example:

```sh
shdctl secret generate --import secrets.import.yaml --use-defaults --output ./generated-secrets.yaml
```

**Keep generated values stable across runs.** Any value your import file does not carry is generated afresh on every run: a database password, an encryption key, the storage abstraction's RSA key. Deploying a regenerated value over the one a running service holds breaks that service. So once a deployment exists, generate from a file that carries every value — the one `--persist` writes, or the one `shdctl secret export` rebuilds from the cluster. When an upgrade adds a secret, pass `--persist` on the run whose output you deploy, so the file records the value the cluster gets: a later run would mint a different one. If you missed it, `shdctl secret export` rebuilds the file from the cluster.

## Object store credentials

With `configuration.objectStore.provider: s3` in your manifest, the object store's credentials are yours to supply — `secret generate` prompts for them instead of generating them, because a generated key could never authenticate against your store:

```text
Enter the access key ID for your S3-compatible object store (needs read, write and delete on every bucket named in configuration.objectStore.buckets, including studioAssets):
Enter the secret access key for your S3-compatible object store (needs read, write and delete on every bucket named in configuration.objectStore.buckets, including studioAssets):
```

One pair has to cover **every** bucket named in `configuration.objectStore.buckets` — the three required ones, plus `buckets.postgresBackups` if your manifest names it. Delete is required, not just read and write, because backup retention deletes the dumps it ages out and pgBackRest deletes the backups and WAL it expires — a key without it works until the first one ages out, then fails on every retention pass. A key scoped to only some of the buckets completes this command cleanly and fails much later, the first time a backup or a transformation runs.

When the manifest names `buckets.postgresBackups`, the run also writes a second secret, `pgbackrest-s3-credentials`, holding the pgBackRest config file that carries this same pair — pgBackRest reads credentials from a config file rather than from `AWS_*` environment variables. It is derived from the pair above rather than prompted for, so it asks you for nothing extra, and it is re-derived on every run rather than being something you can supply or persist independently.

Non-interactive runs need the pair in the `--import` file, under `s3-api-storage-credentials` as `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY`. A `--use-defaults` run without it leaves both at the `TBD` placeholder and only logs a warning, so check the generated file before deploying it.

On the in-cluster store, a `--persist` file carries the generated `GARAGE_*` keys rather than a pair of yours. An `s3` run does not read them — it warns that they, and `garage-rpc-secret`, are not used — and prompts for your pair instead. A persist file written by an `s3` run no longer carries the `GARAGE_*` keys, so switching back regenerates them.

On this provider the in-cluster store's `garage-rpc-secret` is not generated either — there is no in-cluster store to hand it to. With `provider: garage` (the default) nothing about this command changes.

## The image pull secret

The generated file also carries the image pull secret named by `configuration.kubernetes.imagePullSecret.name`, so deploying it creates that secret too and no hand-written `kubectl create secret docker-registry` is needed. It is built from:

* **the registry** `configuration.kubernetes.docker.repository` — which, when you pull straight from Unity's registry, is that registry;
* **the credentials** you supply for `acr-oras-credentials` (`ACR_USERNAME` / `ACR_PASSWORD`) in the same run. Those authenticate against the same registry, so they are the credentials the pull secret has to carry.

The credential is a point-in-time snapshot. Where the registry issues short-lived tokens — ECR's expire after 12 hours — something else has to keep the secret current; leave that owner in charge and opt out:

```yaml
configuration:
  kubernetes:
    imagePullSecret:
      name: ecr-regcred
      generate: false   # something else creates and refreshes it
```

The older spelling `imagePullSecret: ecr-regcred` — the bare secret name — is still accepted and behaves exactly as before, except that the secret is now generated for you. shdctl logs a notice pointing at the block form when it reads it; move to the block form whenever it suits you.

Nothing is generated when the manifest names no pull secret. Nothing is generated either, with the reason logged, when the manifest opts out, gives it the name of a secret the release already defines, resolves no registry, or when the registry credentials were not supplied — a `--use-defaults` run with no import file leaves them at `TBD`. shdctl never builds the pull secret from missing or placeholder credentials, so neither a pull secret already in the cluster nor another secret in this file is replaced by such a snapshot — but it cannot tell whether credentials you supply actually work.

## Deploy secrets to Kubernetes

Apply the generated secrets to your Kubernetes cluster:

```sh
shdctl secret deploy
```

**Common options:**

* `--file`, `-f`: Path to the secrets YAML file (defaults to `secrets.yaml`)
* `--dry-run`: Preview the kubectl command without executing it
* `--context`: Kubernetes context to use
* `--namespace`, `-n`: passed to `kubectl` as `-n`. It cannot move the secrets: every one already names its namespace, and kubectl refuses a `-n` that differs. To deploy them elsewhere, change `configuration.kubernetes.namespace` and regenerate

It runs `kubectl apply -f <file>`, so the kubeconfig needs `get`, `create` and `patch` on secrets in the namespace. `secret generate` stamps that namespace into every secret from `configuration.kubernetes.namespace`, which is why `--namespace` is never needed.

Example:

```sh
shdctl secret deploy --file ./generated-secrets.yaml --dry-run
```

## Recover the import file from a cluster

When the import file you deployed with is gone — it went out with an ephemeral bastion, or somebody else ran the deploy and never shared it — rebuild it from the secrets already in the cluster:

```sh
shdctl secret export --output secrets.import.yaml
```

The release's secrets schema drives the read: for every value the schema expects you to supply, the matching value is read out of the cluster and written back in import format. The values derived from those — connection strings and the like — are left out, because `shdctl secret generate` derives them again from the file. The result is the file `--persist` would have written — except that a value still at the `TBD` placeholder is listed below rather than copied — so you can pass it straight back to `--import`.

You need the `manifest.yaml` the deployment was generated from — it sets the namespace, and which of the conditional secrets (the object store's, for one) are read — and an extracted release for the schema (`shdctl release pull` if you no longer have one).

A namespace holding none of the secrets the schema describes — nothing deployed there yet, or the wrong namespace or context — is an error rather than an empty file: the command exits non-zero with `recovered no secret values from namespace "<namespace>"` and writes nothing. Any other failure means the cluster's secrets went unread, not that there were none, so do not generate from an empty file on the strength of it.

**Common options:**

* `--output`, `-o`: Output file path (defaults to `secrets.import.yaml`)
* `--namespace`, `-n`: Namespace to read from (defaults to `configuration.kubernetes.namespace`)
* `--context`: Kubernetes context to read from (defaults to the current one)
* `--force`: Overwrite the output file when it already exists — without it, an existing file is left alone, since it may hold values this cluster does not
* `--dry-run`: Print the kubectl command and contact nothing
* `--extracted-release`: Path to extracted release (defaults to `./extracted-release`)
* `--skip-version-check`: Skip the shdctl version compatibility check against the release package

Values the cluster has nothing usable for are listed at the end — a secret the deployment predates, a key that was never set, or a field still carrying the `TBD` placeholder. Only the ones you have to act on appear: a field the schema gives a default is left out, because generation resolves it from that default rather than inventing a new value.

```text
NOT RECOVERED (2) — supply these by hand before 'shdctl secret generate',
or it will generate new values and deploying those will break whatever
still uses the old ones:

  pixyz-license:
    pixyz.lic                                        (cluster holds the TBD placeholder, so no real value is set for it)
  acr-oras-credentials:
    ACR_PASSWORD                                     (cluster holds the TBD placeholder, so no real value is set for it)
```

Fill those in before generating. To confirm the recovered file reproduces what is deployed, regenerate from it and ask the cluster what applying it would change — `kubectl diff` exits 0 when nothing would:

```sh
shdctl secret generate --import secrets.import.yaml --use-defaults --output /tmp/check.yaml
kubectl diff -f /tmp/check.yaml -n <namespace>
```

**Access this needs.** It reads every secret in the namespace in one collection read — Helm's release records excepted, which the API server filters out — so the kubeconfig needs the **`list`** verb on secrets there — not `get`. An unnamed `kubectl get secret` is a `list` request, and a Role granting only `get` fails with `secrets is forbidden: ... cannot list resource "secrets"`:

```yaml
rules:
  - apiGroups: [""]
    resources: ["secrets"]
    verbs: ["list"]
```

That is more than the `get`/`create`/`patch` a deployment pipeline needs. Run it as an operator; do not widen a CI role for it. The file it writes holds your secrets in the clear (mode `0600`): keep it in your secret store, not in Git.

## Import file format

An example import file for `shdctl secret generate`. Its comments say which values you supply and which ones shdctl generates:

```yaml
# Example import file for shdctl secret generate
# This file contains secret values organized by secret name
# Each secret name maps to a dictionary of field keys and values
# Values are plain text and base64-encoded for you — except the license files
# (pixyz.lic, plasticd.lic), which are read as base64: encode the file's content
#
# A value you leave out is generated only where the schema generates that field, and
# then afresh on every run. A required value with no default — the registry
# credentials, the licenses — is prompted for, or written as TBD under --use-defaults,
# and a --persist file keeps that TBD: supply those values here, and check the
# generated file for TBD before deploying it. Once a deployment exists, generate from
# a file that carries every value: the one `shdctl secret generate --persist` writes,
# or the one `shdctl secret export` rebuilds.
#
# A value you supply for a generated field is checked like a generated one: passwords
# are letters and digits only unless the schema allows more, novu-api's
# STORE_ENCRYPTION_KEY is exactly 32 characters, and the UVCS passwords at least 18.
# An RSA key you supply, such as asset-cloud-storage-abstraction's storage-key.pem,
# must be a PKCS#1 key (-----BEGIN RSA PRIVATE KEY-----); convert one with
# `openssl rsa -traditional -in key.pem -out key-pkcs1.pem`.
#
# Format:
#   secret-name:
#     field-key: value
#     another-field: value
#   another-secret:
#     field-key: value

# Credentials for the registry your cluster pulls images from
# (configuration.kubernetes.docker.repository): your own registry's once you mirror
# into it with `shdctl artifact sync`, or Unity's when you pull from it directly.
# They make the image pull secret, and the automation manager pulls the automation
# apps with them.
acr-oras-credentials:
  ACR_USERNAME: "<your-registry-username>"
  ACR_PASSWORD: "<your-registry-password>"

# UVCS SSL Certificate PEM
custom-ca-certificate-pem:
  ssl-certificate.pem: "<your-certificate-pem>"

# Pixyz License (used by builtins workflow templates)
pixyz-license:
  pixyz.lic: "<your-pixyz-license-content>"

# Pixyz License for 3D Data Streaming
pixyz-license-3dds:
  pixyz.lic: "<your-pixyz-3dds-license-content>"

# Elasticsearch Secrets
elasticsearch-apps-credentials:
  password: "<your-password>"

# Mini-usf Secrets
mini-usf:
  MINIUSF_Keycloak__ClientSecret: "<your-client-secret>"

# Novu API Secrets
novu-api:
  JWT_SECRET: "<your-jwt-secret>"
  STORE_ENCRYPTION_KEY: "<your-encryption-key>"

# PSMDB Secrets - MongoDB/Percona Server for MongoDB secrets
psmdb-secrets:
  MONGODB_BACKUP_PASSWORD: "<your-password>"
  MONGODB_CLUSTER_ADMIN_PASSWORD: "<your-password>"
  MONGODB_CLUSTER_MONITOR_PASSWORD: "<your-password>"
  MONGODB_DATABASE_ADMIN_PASSWORD: "<your-password>"
  MONGODB_USER_ADMIN_PASSWORD: "<your-password>"

# PMM Admin Password (optional only if PMM is deployed)
# pmm-admin-password:
#   GF_SECURITY_ADMIN_PASSWORD: "<your-password>"

# PSMDB Asset Manager Password
psmdb-asset-manager-password:
  password: "<your-password>"

# PostgreSQL Secrets
pg-db-pguser-assetsolutions:
  password: "<your-password>"

# RabbitMQ Admin Username
rabbitmq-asset-solutions-import-admin-user:
  password: "<your-password>"

# S3 API Storage Credentials
# Which key names apply depends on configuration.objectStore.provider in your manifest.
# With the default in-cluster store (provider: garage), supply GARAGE_*; leave them out
# and shdctl generates them for you.
s3-api-storage-credentials:
  GARAGE_ACCESS_KEY: "<your-access-key>"
  GARAGE_SECRET_KEY: "<your-secret-key>"
# With provider: s3, supply AWS_* instead — the credentials for your own object
# store, able to read, write AND DELETE objects in every bucket named in
# configuration.objectStore.buckets, including studioAssets, plus postgresBackups
# when you set it (backup retention deletes the backups and dumps it ages out).
# These cannot be generated, and GARAGE_* values are ignored on that provider.
#  AWS_ACCESS_KEY_ID: "<your-object-store-access-key-id>"
#  AWS_SECRET_ACCESS_KEY: "<your-object-store-secret-access-key>"

# UVCS License
uvcs-license:
  plasticd.lic: "<your-license-content>"

# UVCS configuration
uvcs-configuration:
  UVCS_USER_PASSWORD: "<your-password>"
  UVCS_ADMIN_PASSWORD: "<your-password>"

# Valkey Users ACL
valkey-users:
  users.acl.password: "<your-password>"
```
