Horopter

Install Horopter in Kubernetes

Install Horopter's Helm chart against your Postgres, and create its first tenant.

Horopter's chart runs its two daemons, ingress, which records changes, and egress, which serves them, against a Postgres you run. Each step below is tested against Horopter v0.14.2, chart 0.11.0.

Goal

Horopter running in your cluster, its ingress reachable by the producers that will record changes and its egress by you, and a tenant for them to record into.

Prerequisites

  • A Kubernetes cluster, and kubectl with access to it.
  • Helm.
  • jq.
  • Postgres 13 or later, or the CloudNativePG operator installed below.

Tested with Kubernetes v1.37.0 on kind 0.33.0, Helm 4.3.1, and CloudNativePG 1.30.1 with the Postgres it installs by default.

Steps

Choose the Versions

Set the chart release, and the two outside versions the steps below use, in the shell you will use for the rest of the page:

export HOROPTER_CHART_VERSION=0.11.0
export CNPG_VERSION=1.30.1
export CURL_VERSION=8.23.0

Choose Names

export HOROPTER_NAMESPACE=horopter
export HOROPTER_RELEASE=horopter
export HOROPTER_DB_CLUSTER=horopter-db
export HOROPTER_DATABASE_SECRET=$HOROPTER_DB_CLUSTER-app

Change them here and every later command follows; the output shown on this page uses these names.

Provide Postgres

Horopter needs a Secret in its namespace whose uri key holds a connection URI to a database its role owns. It needs no extension and no superuser. The chart runs Horopter's migrations against that database before every install and upgrade.

If you already run Postgres, create that Secret and skip to the next step. Otherwise, this installs the CloudNativePG operator and a one-instance cluster, whose generated -app Secret already has that shape:

kubectl apply --server-side \
  -f "https://github.com/cloudnative-pg/cloudnative-pg/releases/download/v$CNPG_VERSION/cnpg-$CNPG_VERSION.yaml"
kubectl rollout status deployment/cnpg-controller-manager --namespace cnpg-system --timeout 240s
kubectl create namespace "$HOROPTER_NAMESPACE"
kubectl apply --namespace "$HOROPTER_NAMESPACE" -f - << EOF
apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata:
  name: $HOROPTER_DB_CLUSTER
spec:
  instances: 1
  storage:
    size: 1Gi
EOF
kubectl wait cluster "$HOROPTER_DB_CLUSTER" --namespace "$HOROPTER_NAMESPACE" \
  --for condition=Ready --timeout 240s
namespace/horopter created
cluster.postgresql.cnpg.io/horopter-db created
cluster.postgresql.cnpg.io/horopter-db condition met

Check the Postgres it runs is 13 or later:

kubectl exec "$HOROPTER_DB_CLUSTER-1" --namespace "$HOROPTER_NAMESPACE" --container postgres -- \
  psql --tuples-only --no-align \
  --command "SELECT current_setting('server_version_num')::int >= 130000"
t

Raise instances for a standby that takes over when the primary fails. Backups and tuning are in CloudNativePG's documentation.

Install the Chart

helm install "$HOROPTER_RELEASE" oci://ghcr.io/horopter-dev/charts/horopter \
  --version "$HOROPTER_CHART_VERSION" \
  --namespace "$HOROPTER_NAMESPACE" \
  --set database.existingSecret="$HOROPTER_DATABASE_SECRET" \
  --wait
Pulled: ghcr.io/horopter-dev/charts/horopter:...
Digest: sha256:...
NAME: horopter
LAST DEPLOYED: ...
NAMESPACE: horopter
STATUS: deployed
REVISION: 1
DESCRIPTION: Install complete
TEST SUITE: None
NOTES:
Horopter v... is installed as release horopter.

In the cluster:
  ingress (writes): http://horopter-ingress.horopter.svc:8080
  egress (reads):   http://horopter-egress.horopter.svc:8081

Outside the cluster:
  ingress: not exposed (ingress.httpRoute or ingress.kubernetesIngress)
  egress:  not exposed (egress.httpRoute or egress.kubernetesIngress)
...

--wait returns once both daemons answer their readiness probes. The notes list each daemon's URL in the cluster, and how it is exposed outside it. The chart's README is its values reference; helm show readme oci://ghcr.io/horopter-dev/charts/horopter --version "$HOROPTER_CHART_VERSION" prints it.

Expose Ingress and Egress

Both daemons have a ClusterIP Service. Producers in the cluster, such as the Flux client, reach ingress through its Service. Expose a daemon outside the cluster by a Gateway API HTTPRoute or by an Ingress resource, whichever your cluster's controller serves, through the chart's values.

Exposing ingress puts a write endpoint on the internet. Every write needs a producer key, but ingress does not limit the rate of writes, so a leaked key or a producer retrying in a loop writes as fast as Postgres accepts. Expose ingress only for producers outside the cluster, such as GitHub-hosted runners.

Name the Gateway each route attaches to, and the hostname it serves:

ingress:
  httpRoute:
    enabled: true
    parentRefs:
      - name: my-gateway
        namespace: gateway-system
    hostnames: [horopter-ingress.example.com]
egress:
  httpRoute:
    enabled: true
    parentRefs:
      - name: my-gateway
        namespace: gateway-system
    hostnames: [horopter-egress.example.com]

Leave out the daemon you do not expose. Save the values as exposure.yaml, and apply them:

helm upgrade "$HOROPTER_RELEASE" oci://ghcr.io/horopter-dev/charts/horopter \
  --version "$HOROPTER_CHART_VERSION" \
  --namespace "$HOROPTER_NAMESPACE" \
  --reuse-values --values exposure.yaml --wait

Set Your URLs

Set ingress's URL as your producers reach it, and egress's as you reach it: the Service for a client in the cluster, and the route's or Ingress's hostname otherwise.

export HOROPTER_INGRESS_URL=http://horopter-ingress.horopter.svc:8080
export HOROPTER_EGRESS_URL=https://horopter-egress.example.com

With egress not exposed, forward a port to its Service and use export HOROPTER_EGRESS_URL=http://localhost:8081, keeping this running in another shell:

kubectl port-forward --namespace "$HOROPTER_NAMESPACE" service/horopter-egress 8081:8081

helm status "$HOROPTER_RELEASE" --namespace "$HOROPTER_NAMESPACE" lists both Services' URLs and how each daemon is exposed.

Create a Tenant

horoctl manages tenants and credentials directly in Horopter's database, so it runs in the cluster, in a pod that reads the database Secret. Define two functions: in_cluster runs one container, given as JSON in a pod spec's shape, until it exits, prints its log and deletes its pod; horoctl runs horoctl that way, at the release the chart installed.

in_cluster() {
  local pod overrides phase
  pod=in-cluster-$RANDOM
  overrides=$(jq -n --arg pod "$pod" --argjson container "$1" \
    '{spec: {containers: [$container + {name: $pod}]}}')
  kubectl run "$pod" --namespace "$HOROPTER_NAMESPACE" --restart Never \
    --image "$(jq -r .image <<< "$1")" --overrides "$overrides" > /dev/null
  for _ in $(seq 120); do
    phase=$(kubectl get pod "$pod" --namespace "$HOROPTER_NAMESPACE" -o jsonpath='{.status.phase}')
    [[ $phase == Succeeded || $phase == Failed ]] && break
    sleep 1
  done
  kubectl logs "$pod" --namespace "$HOROPTER_NAMESPACE"
  kubectl delete pod "$pod" --namespace "$HOROPTER_NAMESPACE" --wait=false > /dev/null
  [[ $phase == Succeeded ]]
}

horoctl() {
  local image
  image=ghcr.io/horopter-dev/horoctl:$(helm get metadata "$HOROPTER_RELEASE" \
    --namespace "$HOROPTER_NAMESPACE" --output json | jq -r .appVersion)
  in_cluster "$(printf '%s\n' "$@" | jq -Rn --arg image "$image" \
    --arg secret "$HOROPTER_DATABASE_SECRET" '{image: $image, args: [inputs],
      env: [{name: "DATABASE_URL", valueFrom: {secretKeyRef: {name: $secret, key: "uri"}}}]}')"
}

kubectl run --rm -i is not used: it attaches after a short-lived container has often exited, and then prints a warning and the output twice.

A pod's log holds its stdout and stderr together, in no fixed order, so take a value from it by its form. Create the tenant your producers record into, and keep its id, which starts tnt_:

TENANT_ID=$(horoctl admin tenant create --display-name acme | grep -x 'tnt_[0-9a-f]*')
export TENANT_ID
echo "$TENANT_ID"
tnt_...

Issue each producer and reader its credential with horoctl admin keys issue, run through the same function. Issue Credentials explains its flags.

Verification

Egress answers from your machine:

curl -fsS "$HOROPTER_EGRESS_URL/readyz"
{"status":"ready"}

Ingress answers from a throwaway pod in the cluster, which reaches it by either form of its URL:

in_cluster "$(jq -n --arg image "curlimages/curl:$CURL_VERSION" \
  --arg url "$HOROPTER_INGRESS_URL/readyz" '{image: $image, args: ["-fsS", $url]}')"
{"status":"ready"}

What's Next?

Keep six values for the producers you set up next: HOROPTER_NAMESPACE, HOROPTER_RELEASE, HOROPTER_DATABASE_SECRET, TENANT_ID, HOROPTER_INGRESS_URL and HOROPTER_EGRESS_URL.

Upgrading

Install a newer chart release by the same helm upgrade command with its --version and --reuse-values. The chart runs the new release's migrations before its daemons start. Read the chart's CHANGELOG first: a breaking change is listed there. Upgrading is not tested here.

On this page