diff --git a/CHANGELOG.md b/CHANGELOG.md index ee3543b05..dcd7393d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,20 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged. ## Unreleased +### The Helm chart pins by digest, mounts a tenant package, and takes a Composio key + +Three things a deployment on Kubernetes did by hand now have values of their own: + +- `image.digest` deploys exactly the image a release's `container-images.json` names, as + `repository:tag@digest`. It refuses anything that is not `sha256:` and 64 hex characters. +- `config.extraVolumes` and `config.extraVolumeMounts` mount volumes on the server and on the routines + and computer-culler CronJobs, the pods that load the configuration. A tenant package from a + ConfigMap no longer needs a custom image. +- `secrets.composioApiKey`, or `composio-api-key` named in `externalSecrets.data`, reaches the server + as `COMPOSIO_API_KEY`. Only the server receives it, not the CronJobs. + +With none of them set, every target renders exactly as before. + **Before upgrading.** Four things change for an existing deployment: - A hosted computer's browser is now headed by default. It uses a little more memory than headless did; set `COMPUTER_BROWSER_MODE=headless` (Helm: `computers.browserMode: headless`) to keep the old diff --git a/charts/openbot/README.md b/charts/openbot/README.md index cb1b35c34..441d25f21 100644 --- a/charts/openbot/README.md +++ b/charts/openbot/README.md @@ -157,6 +157,44 @@ helm upgrade --install openbot charts/openbot \ it in a file anybody commits. The chart marks the Secret it creates `helm.sh/resource-policy: keep`, so an uninstall does not take the key that every stored credential was encrypted with. +### Pin the image by digest + +A tag can be moved; a digest cannot. Each release's `container-images.json` names the digest CI +tested, and `image.digest` deploys exactly that: + +```sh +gh release download v0.2.0 --pattern container-images.json +helm upgrade --install openbot charts/openbot ... \ + --set image.tag=v0.2.0 \ + --set-string image.digest="$(jq -r '.images.openbot.reference | split("@")[1]' container-images.json)" +``` + +The image becomes `repository:tag@digest`, so the tag stays readable and the digest decides what is +pulled. Anything other than `sha256:` and 64 hex characters is refused at install. + +### Your own tenant package + +The tenant package is a directory (`config.tenantPackageDir`, `/app/examples/fintech` by default). +To run your own without building an image, put its files in a ConfigMap and mount it with +`config.extraVolumes` and `config.extraVolumeMounts`. They reach the server and the routines and +computer-culler CronJobs, the pods that load the package, so the package is the same wherever it is +read: + +```yaml +config: + tenantPackageDir: /tenant + extraVolumes: + - name: tenant + configMap: { name: my-tenant-package } + extraVolumeMounts: + - name: tenant + mountPath: /tenant + readOnly: true +``` + +A ConfigMap is flat, so a package that uses `agents/` needs `items` with `path: agents/.yaml`, +or keeps its coworkers in `agents.yaml`. + ## What the defaults assume **A plain cluster with no cloud features.** The cluster's own default StorageClass, no RuntimeClass, @@ -400,6 +438,7 @@ the key in the last column. | SCIM provisioning | `config.scim.enabled` | `connectionId` | `scim-bearer-token` (required), `scim-bearer-token-next` (optional) | | Inbound email triggers | `config.inboundEmail.domain` and `snsTopicArns` | both needed, or the route is not mounted | | | OpenTelemetry export | `config.otel.endpoint` or `logsEndpoint` | `serviceName`, `paused` | `otel-logs-headers`, `otel-headers` (optional) | +| Composio app catalogue | `secrets.composioApiKey` (the key is the switch) | | `composio-api-key` (server only; see [Composio](../../docs/plugins/composio.md)) | ```yaml config: diff --git a/charts/openbot/templates/_helpers.tpl b/charts/openbot/templates/_helpers.tpl index 9b964b4f8..c6a1f7593 100644 --- a/charts/openbot/templates/_helpers.tpl +++ b/charts/openbot/templates/_helpers.tpl @@ -190,8 +190,51 @@ actually exists. A named `image.tag` is used verbatim. */}} {{- define "openbot.image" -}} {{- $tag := .Values.image.tag | default (printf "v%s" .Chart.AppVersion) -}} +{{- with .Values.image.digest -}} +{{- if not (regexMatch "^sha256:[a-f0-9]{64}$" .) -}} +{{- fail (printf "image.digest must be sha256: followed by 64 hex characters, as in a release's container-images.json; got %q." .) -}} +{{- end -}} +{{- printf "%s:%s@%s" $.Values.image.repository $tag . -}} +{{- else -}} {{- printf "%s:%s" .Values.image.repository $tag -}} {{- end -}} +{{- end -}} + +{{/* +Where a key of this chart's Secret comes from, for an optional credential. + +`chart` and `store` mean the key is certainly there: this chart writes it from the literal, or +`externalSecrets.data` names it. `unknown` is `secrets.existingSecret`, which cannot be read at +template time, so the reference is made optional and a Secret without the key still starts. Empty +means nothing provides it and the variable is left out. + +Called with (dict "root" . "key" "composio-api-key" "literal" .Values.secrets.composioApiKey). +*/}} +{{- define "openbot.secretKeySource" -}} +{{- $root := .root -}} +{{- if .literal -}} +chart +{{- else if $root.Values.externalSecrets.enabled -}} +{{- $named := list -}} +{{- range $root.Values.externalSecrets.data }}{{- $named = append $named .secretKey }}{{- end -}} +{{- if has .key $named }}store{{ end -}} +{{- else if $root.Values.secrets.existingSecret -}} +unknown +{{- end -}} +{{- end -}} + +{{/* `config.extraVolumeMounts` and `config.extraVolumes`, for the pods that load the configuration. */}} +{{- define "openbot.extraVolumeMounts" -}} +{{- with .Values.config.extraVolumeMounts }} +{{ toYaml . }} +{{- end }} +{{- end -}} + +{{- define "openbot.extraVolumes" -}} +{{- with .Values.config.extraVolumes }} +{{ toYaml . }} +{{- end }} +{{- end -}} {{- define "openbot.secretName" -}} {{- default (printf "%s-secrets" (include "openbot.fullname" .)) .Values.secrets.existingSecret -}} diff --git a/charts/openbot/templates/computer/culler-cronjob.yaml b/charts/openbot/templates/computer/culler-cronjob.yaml index 8a61ebd16..d34d7ccfe 100644 --- a/charts/openbot/templates/computer/culler-cronjob.yaml +++ b/charts/openbot/templates/computer/culler-cronjob.yaml @@ -90,6 +90,9 @@ spec: - name: sandbox-template mountPath: /etc/openbot readOnly: true + {{- with (include "openbot.extraVolumeMounts" . | trim) }} + {{- . | nindent 16 }} + {{- end }} resources: requests: cpu: 50m @@ -100,4 +103,7 @@ spec: - name: sandbox-template configMap: name: {{ include "openbot.componentName" (dict "root" . "component" "computer-template") }} + {{- with (include "openbot.extraVolumes" . | trim) }} + {{- . | nindent 12 }} + {{- end }} {{- end }} diff --git a/charts/openbot/templates/routines/cronjob.yaml b/charts/openbot/templates/routines/cronjob.yaml index 4e5aa6c47..02bf2222d 100644 --- a/charts/openbot/templates/routines/cronjob.yaml +++ b/charts/openbot/templates/routines/cronjob.yaml @@ -71,10 +71,18 @@ spec: {{ include "openbot.commonEnv" . | indent 16 }} - name: SERVER_INTERNAL_URL value: http://{{ include "openbot.componentName" (dict "root" . "component" "server") }}:{{ .Values.server.service.port }} + {{- with .Values.config.extraVolumeMounts }} + volumeMounts: +{{ toYaml . | indent 16 }} + {{- end }} resources: requests: cpu: 50m memory: 128Mi limits: memory: 512Mi + {{- with .Values.config.extraVolumes }} + volumes: +{{ toYaml . | indent 12 }} + {{- end }} {{- end }} diff --git a/charts/openbot/templates/secret.yaml b/charts/openbot/templates/secret.yaml index 9e08cd384..a862f1094 100644 --- a/charts/openbot/templates/secret.yaml +++ b/charts/openbot/templates/secret.yaml @@ -25,6 +25,9 @@ stringData: {{- with .Values.secrets.licenseToken }} license-token: {{ . | quote }} {{- end }} + {{- with .Values.secrets.composioApiKey }} + composio-api-key: {{ . | quote }} + {{- end }} {{- with .Values.secrets.betterAuthSecret }} better-auth-secret: {{ . | quote }} {{- end }} diff --git a/charts/openbot/templates/server/deployment.yaml b/charts/openbot/templates/server/deployment.yaml index ee8be112c..bd8c577f5 100644 --- a/charts/openbot/templates/server/deployment.yaml +++ b/charts/openbot/templates/server/deployment.yaml @@ -104,6 +104,18 @@ spec: {{- with (include "openbot.learningServiceTokenEnv" . | trim) }} {{- . | nindent 12 }} {{- end }} + {{- /* Also the server's alone: only the API calls Composio, so the CronJobs are not handed the key. */}} + {{- $composio := include "openbot.secretKeySource" (dict "root" . "key" "composio-api-key" "literal" .Values.secrets.composioApiKey) }} + {{- if $composio }} + - name: COMPOSIO_API_KEY + valueFrom: + secretKeyRef: + name: {{ include "openbot.secretName" . }} + key: composio-api-key + {{- if eq $composio "unknown" }} + optional: true + {{- end }} + {{- end }} envFrom: - configMapRef: name: {{ include "openbot.configMapName" . }} @@ -140,20 +152,31 @@ spec: periodSeconds: {{ .Values.server.livenessProbe.periodSeconds }} timeoutSeconds: {{ .Values.server.livenessProbe.timeoutSeconds }} failureThreshold: {{ .Values.server.livenessProbe.failureThreshold }} - {{- if eq .Values.computers.mode "sandbox" }} - {{- /* The shape of a Bot's computer, as a file rather than as a permission to read one. */}} + {{- $sandbox := eq .Values.computers.mode "sandbox" }} + {{- if or $sandbox .Values.config.extraVolumeMounts }} volumeMounts: + {{- if $sandbox }} + {{- /* The shape of a Bot's computer, as a file rather than as a permission to read one. */}} - name: sandbox-template mountPath: /etc/openbot readOnly: true + {{- end }} + {{- with (include "openbot.extraVolumeMounts" . | trim) }} + {{- . | nindent 12 }} + {{- end }} {{- end }} {{- with .Values.server.resources }} resources: {{ toYaml . | indent 12 }} {{- end }} - {{- if eq .Values.computers.mode "sandbox" }} + {{- if or (eq .Values.computers.mode "sandbox") .Values.config.extraVolumes }} volumes: + {{- if eq .Values.computers.mode "sandbox" }} - name: sandbox-template configMap: name: {{ include "openbot.componentName" (dict "root" . "component" "computer-template") }} + {{- end }} + {{- with (include "openbot.extraVolumes" . | trim) }} + {{- . | nindent 8 }} + {{- end }} {{- end }} diff --git a/charts/openbot/values.yaml b/charts/openbot/values.yaml index 6e64141cf..dbba20650 100644 --- a/charts/openbot/values.yaml +++ b/charts/openbot/values.yaml @@ -15,6 +15,11 @@ image: # Empty means the chart's appVersion prefixed with `v` (the form published tags take), so an # upgrade of the chart moves the image with it. A value set here is used verbatim. tag: "" + # The image digest, `sha256:` and 64 hex characters, from the release's `container-images.json`. + # Set, the image becomes `repository:tag@digest` and Kubernetes pulls exactly those bytes, which is + # how docs/releasing.md says to deploy: a tag can be moved, a digest cannot. The tag stays in the + # reference for whoever reads it. Empty pulls by tag alone, as before. + digest: "" pullPolicy: IfNotPresent imagePullSecrets: [] @@ -304,6 +309,20 @@ config: # Free-form additions, for anything this chart has no opinion about. extraEnv: [] extraEnvFrom: [] + # Volumes, and where to mount them, on every pod that loads this deployment's configuration: the + # server and the routines and computer-culler CronJobs, the same pods `extraEnv` reaches. The usual + # reason is a tenant package of your own, from a ConfigMap, with `tenantPackageDir` pointed at its + # mount; every one of those pods reads the package, so mounting it on the server alone is not enough. + # + # extraVolumes: + # - name: tenant + # configMap: { name: my-tenant-package } + # extraVolumeMounts: + # - name: tenant + # mountPath: /tenant + # readOnly: true + extraVolumes: [] + extraVolumeMounts: [] # How a Bot gets a computer. # @@ -603,6 +622,10 @@ secrets: # Optional. Managed Intelligence issues no licence token; set this only for a self-hosted # Intelligence that has one, and it is passed to the server as COPILOTKIT_LICENSE_TOKEN. licenseToken: "" + # Optional. One key for the whole deployment, for Composio, the broker holding people's accounts for + # a few hundred apps (docs/plugins/composio.md). Passed to the server alone as COMPOSIO_API_KEY. + # From a store, name `composio-api-key` in `externalSecrets.data` instead. + composioApiKey: "" # Sent to `config.managedAgent.url` on every call. Required when that url is set. managedAgentToken: "" # Required when `routines.enabled`: what the CronJob presents to the API server to be recognised diff --git a/docs/plugins/composio.md b/docs/plugins/composio.md index 2cee5a65c..7b31b1a60 100644 --- a/docs/plugins/composio.md +++ b/docs/plugins/composio.md @@ -12,7 +12,7 @@ Setting it up takes three hands, and none of them can do another's: | Who | Does | Where | | ---------------- | -------------------------------------------------------------------------- | ----------------------------------------------------------------- | -| Whoever deploys | Sets `COMPOSIO_API_KEY` | The deployment's environment. There is no screen for it | +| Whoever deploys | Sets `COMPOSIO_API_KEY` | The deployment's environment, or `secrets.composioApiKey` in the Helm chart. There is no screen for it | | An administrator | Enables an app | `/admin/plugins/composio` | | An administrator | Grants its actions to a Bot | `/admin/plugins/composio-`, then that page's per-Bot screen | | Each person | Connects their own account to that app | `/settings/connected-accounts` | diff --git a/docs/releasing.md b/docs/releasing.md index 5433cd6b7..6145ed1c1 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -100,6 +100,9 @@ wants the `build` sections overridden away rather than this variable alone. A tag can be moved to point at a different image; a digest cannot. The same digest that CI smoke tested is the one that runs, and rolling back is the same command with an earlier version. +On Kubernetes the chart takes the same digest as `image.digest`; see +[charts/openbot/README.md](../charts/openbot/README.md#pin-the-image-by-digest). + Before deploying, you can check an image is the one this repository built. Every published image carries its own attestation: