diff --git a/.gitea/workflows/deploy.yaml b/.gitea/workflows/deploy.yaml new file mode 100644 index 0000000..b69b3d0 --- /dev/null +++ b/.gitea/workflows/deploy.yaml @@ -0,0 +1,155 @@ +name: Validate, publish, and deploy Brave Sync + +on: + pull_request: + push: + branches: [main] + +env: + HELM_VERSION: v4.2.2 + HELM_SHA256: 9adafecab4d406853bba163a70e9f104f47dbbf65ce24b7653bae7e36150bcb6 + KUBECTL_VERSION: v1.36.3 + KUBERNETES_API: https://host.containers.internal:6443 + KUBERNETES_TLS_SERVER_NAME: 192.168.0.2 + IMAGE: registry.brunner.ninja/feedc0de/brave-sync + +jobs: + validate: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + - name: Install pinned Helm + run: | + curl -fsSL -o "${RUNNER_TEMP}/helm.tgz" "https://get.helm.sh/helm-${HELM_VERSION}-linux-amd64.tar.gz" + printf '%s %s\n' "${HELM_SHA256}" "${RUNNER_TEMP}/helm.tgz" | sha256sum --check + tar -xzf "${RUNNER_TEMP}/helm.tgz" -C "${RUNNER_TEMP}" --strip-components=1 linux-amd64/helm + echo "${RUNNER_TEMP}" >> "${GITHUB_PATH}" + - name: Lint and render the packaged chart + run: ./test.sh + + image: + if: gitea.event_name == 'push' && gitea.ref == 'refs/heads/main' + needs: validate + runs-on: linux_amd64 + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + - name: Install pinned Helm for the runtime smoke test + run: | + curl -fsSL -o "${RUNNER_TEMP}/helm.tgz" "https://get.helm.sh/helm-${HELM_VERSION}-linux-amd64.tar.gz" + printf '%s %s\n' "${HELM_SHA256}" "${RUNNER_TEMP}/helm.tgz" | sha256sum --check + tar -xzf "${RUNNER_TEMP}/helm.tgz" -C "${RUNNER_TEMP}" --strip-components=1 linux-amd64/helm + echo "${RUNNER_TEMP}" >> "${GITHUB_PATH}" + - name: Build and smoke test the pinned upstream server + env: + COMMIT_SHA: ${{ gitea.sha }} + run: | + set -euo pipefail + upstream_commit=$(> "${GITHUB_PATH}" + - name: Publish matching chart to public Helm repository + env: + COMMIT_SHA: ${{ gitea.sha }} + RUN_NUMBER: ${{ gitea.run_number }} + PACKAGE_USERNAME: ${{ secrets.PACKAGE_USERNAME }} + PACKAGE_TOKEN: ${{ secrets.PACKAGE_TOKEN }} + run: | + set -euo pipefail + test -n "${PACKAGE_USERNAME}" + test -n "${PACKAGE_TOKEN}" + package=$(./package.sh "${RUNNER_TEMP}" "sha-${COMMIT_SHA:0:12}" "r${RUN_NUMBER}") + helm lint "${package}" --strict + helm template brave-sync "${package}" --namespace brave-sync > "${RUNNER_TEMP}/rendered.yaml" + curl --fail --silent --show-error --request POST \ + --user "${PACKAGE_USERNAME}:${PACKAGE_TOKEN}" \ + --upload-file "${package}" \ + https://code.brunner.ninja/api/packages/feedc0de/helm/api/charts + + deploy: + if: gitea.ref == 'refs/heads/main' + needs: chart + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + - name: Install pinned clients + run: | + curl -fsSL -o "${RUNNER_TEMP}/helm.tgz" "https://get.helm.sh/helm-${HELM_VERSION}-linux-amd64.tar.gz" + printf '%s %s\n' "${HELM_SHA256}" "${RUNNER_TEMP}/helm.tgz" | sha256sum --check + tar -xzf "${RUNNER_TEMP}/helm.tgz" -C "${RUNNER_TEMP}" --strip-components=1 linux-amd64/helm + curl -fsSL -o "${RUNNER_TEMP}/kubectl" "https://dl.k8s.io/release/${KUBECTL_VERSION}/bin/linux/amd64/kubectl" + curl -fsSL -o "${RUNNER_TEMP}/kubectl.sha256" "https://dl.k8s.io/release/${KUBECTL_VERSION}/bin/linux/amd64/kubectl.sha256" + printf '%s %s\n' "$(cat "${RUNNER_TEMP}/kubectl.sha256")" "${RUNNER_TEMP}/kubectl" | sha256sum --check + chmod 0700 "${RUNNER_TEMP}/kubectl" + echo "${RUNNER_TEMP}" >> "${GITHUB_PATH}" + - name: Configure limited Kubernetes access + env: + KUBE_CONFIG_BASE64: ${{ secrets.KUBE_CONFIG_BASE64 }} + run: | + set -euo pipefail + test -n "${KUBE_CONFIG_BASE64}" + printf '%s' "${KUBE_CONFIG_BASE64}" | base64 --decode > "${RUNNER_TEMP}/kubeconfig" + chmod 0600 "${RUNNER_TEMP}/kubeconfig" + export KUBECONFIG="${RUNNER_TEMP}/kubeconfig" + kubectl config set-cluster cluster --server="${KUBERNETES_API}" \ + --tls-server-name="${KUBERNETES_TLS_SERVER_NAME}" >/dev/null + - name: Update named resources and verify rollout + env: + COMMIT_SHA: ${{ gitea.sha }} + RUN_NUMBER: ${{ gitea.run_number }} + run: | + set -euo pipefail + export KUBECONFIG="${RUNNER_TEMP}/kubeconfig" + if ! existing=$(kubectl --namespace brave-sync get deployment/brave-sync 2>&1); then + if [[ "${existing}" == *NotFound* ]]; then + echo "Initial administrator install is required; published image and chart are ready." + exit 0 + fi + echo "${existing}" >&2 + exit 1 + fi + package=$(./package.sh "${RUNNER_TEMP}" "sha-${COMMIT_SHA:0:12}" "r${RUN_NUMBER}") + helm template brave-sync "${package}" --namespace brave-sync > "${RUNNER_TEMP}/rendered.yaml" + kubectl --namespace brave-sync apply --filename "${RUNNER_TEMP}/rendered.yaml" + if ! kubectl --namespace brave-sync rollout status statefulset/brave-sync-dynamodb --timeout=5m \ + || ! kubectl --namespace brave-sync rollout status deployment/brave-sync-valkey --timeout=5m \ + || ! kubectl --namespace brave-sync rollout status deployment/brave-sync --timeout=5m; then + kubectl --namespace brave-sync get statefulset/brave-sync-dynamodb deployment/brave-sync-valkey deployment/brave-sync -o wide + exit 1 + fi + image=$(kubectl --namespace brave-sync get deployment/brave-sync -o jsonpath='{.spec.template.spec.containers[0].image}') + test "${image}" = "${IMAGE}:sha-${COMMIT_SHA:0:12}" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b988180 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +*.tgz +*.rendered.yaml +*.kubeconfig diff --git a/.helmignore b/.helmignore new file mode 100644 index 0000000..2237e1f --- /dev/null +++ b/.helmignore @@ -0,0 +1,7 @@ +.git/ +.gitea/ +image/ +*.sh +*.md +*.txt +ci-deployer.yaml diff --git a/Chart.yaml b/Chart.yaml new file mode 100644 index 0000000..0cb593c --- /dev/null +++ b/Chart.yaml @@ -0,0 +1,7 @@ +apiVersion: v2 +name: brave-sync +description: Self-hosted Brave Sync v2 with local DynamoDB and Valkey +type: application +# Packaging substitutes chart-version.txt and the exact image tag. +version: 0.0.0 +appVersion: unbuilt diff --git a/README.md b/README.md index bf84123..8e338be 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,30 @@ -# brave-sync +# Brave Sync at sync.brunner.ninja -My kubernetes configuration files to run brave-sync in my kubernetes cluster \ No newline at end of file +This chart runs the [official Brave Sync v2 server](https://github.com/brave/go-sync) from the commit in `upstream-commit.txt`. The app image is built by Gitea Actions because Brave does not publish an official container image. DynamoDB Local holds the encrypted sync entities on a `rook-ceph-block` PVC. Valkey provides the server cache. The Sync API is published through Traefik with a cert-manager certificate. + +The service has no web UI or interactive login. `https://sync.brunner.ninja/` returns a simple HTTP heartbeat; browsers use the `/v2/command/` API. Brave authenticates with a Sync Chain code and encrypts sync data before upload. Keep that code private and back it up separately. The server still sees metadata such as device information and item IDs. Brave Sync supports passwords, bookmarks, history, and other enabled sync types, but it is not a complete browser profile backup; check the options on every device and keep independent backups of valuable data. + +## Versioning + +Edit only `chart-version.txt` for a new base chart version. CI packages a unique version `-r` on each push to `main`, with `appVersion` set to the exact immutable `sha-` image tag from that run. `Chart.yaml` contains a fixed placeholder because Helm requires that field in source charts; `package.sh` replaces it when packaging. `values.yaml` contains only the HTTPS hostname; fixed image versions and homelab defaults live in the templates. To update Brave server source, set `upstream-commit.txt` to a reviewed full upstream commit and push the change. + +## Initial setup + +1. Create the Gitea repository yourself, push this local repository's `main` branch, and configure `QUAY_USERNAME`, `QUAY_TOKEN`, `PACKAGE_USERNAME`, `PACKAGE_TOKEN`, and later `KUBE_CONFIG_BASE64` as Gitea Actions repository secrets. The Quay repository path is `registry.brunner.ninja/feedc0de/brave-sync`. +2. The initial rollout created a Cloudflare CNAME from `sync.brunner.ninja` to `brunner.ninja` and cert-manager issued `brave-sync-tls`. For a fresh installation elsewhere, create equivalent DNS before connecting browsers. +3. Wait for the first `main` CI run to publish the image and chart. Its image tag is `sha-`. +4. Bootstrap the namespace and copy the existing homelab Quay pull credentials with `./bootstrap.sh`. This copy stays within the homelab cluster. The initial rollout has already completed this step. +5. Run `IMAGE_TAG=sha- ./install.sh`. The script checks the default context, lints/renders the chart, performs server dry-runs, then runs `helm upgrade --install --wait`. The initial rollout has already installed the chart. +6. The limited deployer ServiceAccount and Role already exist. Run `./create-ci-kubeconfig.sh` and store its single-line output only in the `KUBE_CONFIG_BASE64` Gitea secret. Do not commit it. Then rerun the workflow or push the next change. CI can update only the named workload objects; it cannot read Kubernetes Secrets or create/delete workloads. + +The published chart is available via `helm repo add brunner https://brunner.ninja/charts && helm repo update brunner`. `install.sh` is kept for manual deployments. CI renders the same chart and patches the named existing resources, since Helm release storage would require CI to read Kubernetes Secrets. A later manual Helm upgrade reconciles Helm's stored release revision with the latest chart. + +## Connect a Brave browser + +On each supported Brave device, set `brave://flags/#brave-override-sync-server-url` to `https://sync.brunner.ninja/v2`, relaunch, and confirm the endpoint in `brave://sync-internals/`. Then create or join the Sync Chain at `brave://settings/braveSync/setup`. Enable the data types you want on each device. A compatibility route handles Brave versions that strip `/v2` from the custom URL after relaunch. Do not put Authentik forward authentication on this API; browser sync requests cannot complete its interactive login. On platforms where the flag is unavailable, a client policy or `--sync-url=https://sync.brunner.ninja/v2` may be required. + +Avoid moving an existing production Sync Chain blindly. Export passwords and bookmarks before switching its endpoint and confirm sync on a second test profile first. Each device in a chain must point to the same server. + +## Check health and backups + +`./test.sh` lints and renders the chart without duplicating deployment values. `./smoke-test.sh IMAGE` runs a real server with DynamoDB Local and Valkey in Podman and verifies its HTTP command path. After installation, check `kubectl -n brave-sync get pods,ingress,pvc,certificate`, `helm -n brave-sync status brave-sync`, and `brave://sync-internals/`. Back up the `data-brave-sync-dynamodb-0` PVC regularly; the server stores the encrypted Sync Chain data there. Test restoring it before relying on this instance as the only copy of credentials. diff --git a/bootstrap.sh b/bootstrap.sh new file mode 100755 index 0000000..47f74de --- /dev/null +++ b/bootstrap.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +set -euo pipefail + +project_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +[[ "$(kubectl config current-context)" == kubernetes-admin@kubernetes ]] || { + echo "Expected homelab context kubernetes-admin@kubernetes" >&2 + exit 1 +} +kubectl apply --filename "${project_dir}/namespace.yaml" +kubectl --namespace default get secret quay-pull-secret --output=json \ + | jq 'del(.metadata.uid, .metadata.resourceVersion, .metadata.creationTimestamp, .metadata.managedFields, .metadata.annotations, .metadata.ownerReferences) | .metadata.namespace = "brave-sync"' \ + | kubectl apply --filename - >/dev/null +echo "brave-sync namespace and Quay pull secret are ready" diff --git a/chart-version.txt b/chart-version.txt new file mode 100644 index 0000000..6e8bf73 --- /dev/null +++ b/chart-version.txt @@ -0,0 +1 @@ +0.1.0 diff --git a/ci-deployer.yaml b/ci-deployer.yaml new file mode 100644 index 0000000..dbb798b --- /dev/null +++ b/ci-deployer.yaml @@ -0,0 +1,65 @@ +# One-time administrator bootstrap. CI cannot read Secrets or create workloads. +apiVersion: v1 +kind: ServiceAccount +metadata: + name: brave-sync-deployer + namespace: brave-sync +automountServiceAccountToken: false +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: brave-sync-deployer + namespace: brave-sync +rules: + - apiGroups: [""] + resources: [configmaps] + resourceNames: [brave-sync-schema] + verbs: [get, patch, update] + - apiGroups: [""] + resources: [services] + resourceNames: [brave-sync, brave-sync-dynamodb, brave-sync-valkey] + verbs: [get, patch, update] + - apiGroups: [apps] + resources: [deployments] + resourceNames: [brave-sync, brave-sync-valkey] + verbs: [get, patch, update, watch] + - apiGroups: [apps] + resources: [statefulsets] + resourceNames: [brave-sync-dynamodb] + verbs: [get, patch, update, watch] + - apiGroups: [networking.k8s.io] + resources: [ingresses] + resourceNames: [brave-sync, brave-sync-compat] + verbs: [get, patch, update] + - apiGroups: [networking.k8s.io] + resources: [networkpolicies] + resourceNames: [brave-sync-backend] + verbs: [get, patch, update] + - apiGroups: [traefik.io] + resources: [middlewares] + resourceNames: [brave-sync-compat] + verbs: [get, patch, update] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: brave-sync-deployer + namespace: brave-sync +subjects: + - kind: ServiceAccount + name: brave-sync-deployer + namespace: brave-sync +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: brave-sync-deployer +--- +apiVersion: v1 +kind: Secret +metadata: + name: brave-sync-deployer-token + namespace: brave-sync + annotations: + kubernetes.io/service-account.name: brave-sync-deployer +type: kubernetes.io/service-account-token diff --git a/create-ci-kubeconfig.sh b/create-ci-kubeconfig.sh new file mode 100755 index 0000000..b1a925a --- /dev/null +++ b/create-ci-kubeconfig.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +set -euo pipefail + +project_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +[[ "$(kubectl config current-context)" == kubernetes-admin@kubernetes ]] || { + echo "Expected homelab context kubernetes-admin@kubernetes" >&2 + exit 1 +} +kubectl apply --filename "${project_dir}/namespace.yaml" >&2 +kubectl apply --filename "${project_dir}/ci-deployer.yaml" >&2 + +for attempt in {1..30}; do + token=$(kubectl --namespace brave-sync get secret brave-sync-deployer-token \ + --output=jsonpath='{.data.token}' 2>/dev/null || true) + [[ -n "${token}" ]] && break + sleep 1 +done +[[ -n "${token:-}" ]] || { echo "Token was not issued" >&2; exit 1; } + +workdir=$(mktemp --directory) +trap 'rm -rf -- "${workdir}"' EXIT +server=$(kubectl config view --minify --output=jsonpath='{.clusters[0].cluster.server}') +kubectl --namespace brave-sync get secret brave-sync-deployer-token \ + --output=jsonpath='{.data.ca\.crt}' | base64 --decode > "${workdir}/ca.crt" +export KUBECONFIG="${workdir}/config" +kubectl config set-cluster cluster --server="${server}" \ + --certificate-authority="${workdir}/ca.crt" --embed-certs=true >/dev/null +kubectl config set-credentials brave-sync-deployer \ + --token="$(printf '%s' "${token}" | base64 --decode)" >/dev/null +kubectl config set-context brave-sync --cluster=cluster --user=brave-sync-deployer \ + --namespace=brave-sync >/dev/null +kubectl config use-context brave-sync >/dev/null + +echo "Store the next line as Gitea secret KUBE_CONFIG_BASE64; do not commit it." >&2 +base64 --wrap=0 "${KUBECONFIG}" +printf '\n' diff --git a/image/Dockerfile b/image/Dockerfile new file mode 100644 index 0000000..526d943 --- /dev/null +++ b/image/Dockerfile @@ -0,0 +1,15 @@ +FROM golang:1.26-alpine AS build +RUN apk add --no-cache git ca-certificates +ARG UPSTREAM_COMMIT +RUN test -n "$UPSTREAM_COMMIT" && git clone https://github.com/brave/go-sync.git /src +WORKDIR /src +RUN git checkout --detach "$UPSTREAM_COMMIT" && test "$(git rev-parse HEAD)" = "$UPSTREAM_COMMIT" +RUN go test ./auth ./middleware +RUN CGO_ENABLED=0 go build -trimpath -ldflags='-s -w' -o /go-sync . + +FROM alpine:3.23 +RUN apk add --no-cache ca-certificates && addgroup -S bravesync && adduser -S -G bravesync bravesync +COPY --from=build /go-sync /usr/local/bin/go-sync +USER bravesync +EXPOSE 8295 +ENTRYPOINT ["/usr/local/bin/go-sync"] diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..f0ae94a --- /dev/null +++ b/install.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +set -euo pipefail + +project_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +image_tag=${IMAGE_TAG:?Set IMAGE_TAG to the immutable published image tag} +[[ "$(kubectl config current-context)" == kubernetes-admin@kubernetes ]] || { + echo "Expected homelab context kubernetes-admin@kubernetes" >&2 + exit 1 +} +"${project_dir}/test.sh" + +workdir=$(mktemp --directory) +trap 'rm -rf -- "${workdir}"' EXIT +package=$("${project_dir}/package.sh" "${workdir}" "${image_tag}") +helm template brave-sync "${package}" --namespace brave-sync > "${workdir}/rendered.yaml" +kubectl apply --dry-run=server --filename "${project_dir}/namespace.yaml" +kubectl apply --filename "${project_dir}/namespace.yaml" +kubectl apply --dry-run=server --filename "${workdir}/rendered.yaml" +helm upgrade --install brave-sync "${package}" --namespace brave-sync \ + --wait --timeout 10m --history-max 5 +kubectl --namespace brave-sync rollout status statefulset/brave-sync-dynamodb --timeout=5m +kubectl --namespace brave-sync rollout status deployment/brave-sync-valkey --timeout=5m +kubectl --namespace brave-sync rollout status deployment/brave-sync --timeout=5m +kubectl --namespace brave-sync get pods,ingress,pvc diff --git a/namespace.yaml b/namespace.yaml new file mode 100644 index 0000000..439bfa8 --- /dev/null +++ b/namespace.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: brave-sync diff --git a/package.sh b/package.sh new file mode 100755 index 0000000..be1723b --- /dev/null +++ b/package.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +set -euo pipefail + +project_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +output_dir=${1:?usage: package.sh OUTPUT_DIR IMAGE_TAG [VERSION_SUFFIX]} +image_tag=${2:?IMAGE_TAG is required} +suffix=${3:-} +base_version=$(<"${project_dir}/chart-version.txt") +[[ "${base_version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || { + echo "chart-version.txt must contain a three-part numeric version" >&2 + exit 1 +} +[[ "${image_tag}" =~ ^[A-Za-z0-9][A-Za-z0-9_.-]*$ ]] || { + echo "Invalid image tag" >&2 + exit 1 +} +version=${base_version} +if [[ -n "${suffix}" ]]; then + [[ "${suffix}" =~ ^[A-Za-z0-9][A-Za-z0-9.-]*$ ]] || exit 1 + version="${base_version}-${suffix}" +fi +mkdir -p -- "${output_dir}" +helm package "${project_dir}" --destination "${output_dir}" \ + --version "${version}" --app-version "${image_tag}" >/dev/null +printf '%s/brave-sync-%s.tgz\n' "${output_dir%/}" "${version}" diff --git a/schema/table.json b/schema/table.json new file mode 100644 index 0000000..e607737 --- /dev/null +++ b/schema/table.json @@ -0,0 +1,22 @@ +{ + "TableName": "client-entity-dev", + "KeySchema": [ + {"KeyType": "HASH", "AttributeName": "ClientID"}, + {"KeyType": "RANGE", "AttributeName": "ID"} + ], + "GlobalSecondaryIndexes": [{ + "IndexName": "ClientIDDataTypeMtimeIndex", + "KeySchema": [ + {"KeyType": "HASH", "AttributeName": "ClientID"}, + {"KeyType": "RANGE", "AttributeName": "DataTypeMtime"} + ], + "Projection": {"ProjectionType": "INCLUDE", "NonKeyAttributes": ["Folder"]}, + "ProvisionedThroughput": {"ReadCapacityUnits": 1, "WriteCapacityUnits": 1} + }], + "AttributeDefinitions": [ + {"AttributeName": "ClientID", "AttributeType": "S"}, + {"AttributeName": "ID", "AttributeType": "S"}, + {"AttributeName": "DataTypeMtime", "AttributeType": "S"} + ], + "ProvisionedThroughput": {"ReadCapacityUnits": 1, "WriteCapacityUnits": 1} +} diff --git a/smoke-test.sh b/smoke-test.sh new file mode 100755 index 0000000..b80ba02 --- /dev/null +++ b/smoke-test.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +set -euo pipefail + +project_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +image=${1:?usage: smoke-test.sh IMAGE} +authfile=$(mktemp) +rendered=$(mktemp) +printf '{"auths":{}}' > "${authfile}" +export REGISTRY_AUTH_FILE="${authfile}" +unique="brave-sync-smoke-$$" +network="${unique}" +volume="${unique}-data" +db="${unique}-db" +cache="${unique}-cache" +server="${unique}-server" +helm template brave-sync "${project_dir}" --namespace brave-sync > "${rendered}" +image_from_template() { + local template=$1 + awk -v source="# Source: brave-sync/templates/${template}.yaml" \ + '$0 == source {section=1; next} /^# Source:/ {section=0} section && /^[[:space:]]+image:/ {gsub(/"/, "", $2); print $2; exit}' \ + "${rendered}" +} +db_image=$(image_from_template dynamodb) +cli_image=$(image_from_template sync) +cache_image=$(image_from_template valkey) +[[ -n "${db_image}" && -n "${cli_image}" && -n "${cache_image}" ]] + +cleanup() { + podman rm -f "${server}" "${cache}" "${db}" >/dev/null 2>&1 || true + podman volume rm "${volume}" >/dev/null 2>&1 || true + podman network rm "${network}" >/dev/null 2>&1 || true + rm -f -- "${authfile}" "${rendered}" +} +trap cleanup EXIT +podman network create "${network}" >/dev/null +podman volume create "${volume}" >/dev/null +podman run -d --name "${db}" --network "${network}" -v "${volume}:/data" "${db_image}" \ + -jar DynamoDBLocal.jar -sharedDb -dbPath /data >/dev/null +podman run -d --name "${cache}" --network "${network}" "${cache_image}" \ + --save '' --appendonly no >/dev/null + +export AWS_ACCESS_KEY_ID=GOSYNC AWS_SECRET_ACCESS_KEY=GOSYNC AWS_REGION=us-west-2 +for attempt in {1..30}; do + if podman run --rm --network "${network}" \ + -e AWS_ACCESS_KEY_ID -e AWS_SECRET_ACCESS_KEY -e AWS_REGION \ + "${cli_image}" dynamodb list-tables --endpoint-url "http://${db}:8000" >/dev/null 2>&1; then + break + fi + [[ "${attempt}" == 30 ]] && { echo "DynamoDB did not start" >&2; exit 1; } + sleep 2 +done +podman run --rm --network "${network}" \ + -e AWS_ACCESS_KEY_ID -e AWS_SECRET_ACCESS_KEY -e AWS_REGION \ + -v "${project_dir}/schema/table.json:/schema/table.json:ro" \ + "${cli_image}" dynamodb create-table --cli-input-json file:///schema/table.json \ + --endpoint-url "http://${db}:8000" >/dev/null +podman run -d --name "${server}" --network "${network}" \ + -e ENV=local -e AWS_ACCESS_KEY_ID -e AWS_SECRET_ACCESS_KEY -e AWS_REGION \ + -e AWS_ENDPOINT="http://${db}:8000" -e TABLE_NAME=client-entity-dev \ + -e REDIS_URL="${cache}:6379" "${image}" >/dev/null + +for attempt in {1..30}; do + if podman run --rm --network "${network}" docker.io/curlimages/curl:8.16.0 \ + --silent --fail "http://${server}:8295/health-check" >/dev/null 2>&1; then + break + fi + [[ "${attempt}" == 30 ]] && { podman logs "${server}"; exit 1; } + sleep 2 +done +status=$(podman run --rm --network "${network}" docker.io/curlimages/curl:8.16.0 \ + --silent --output /dev/null --write-out '%{http_code}' \ + --request POST "http://${server}:8295/v2/command/") +[[ "${status}" == 401 || "${status}" == 400 ]] || { + echo "Unexpected unauthenticated command status: ${status}" >&2 + exit 1 +} +echo "Live Sync endpoint smoke test passed" diff --git a/templates/configmap.yaml b/templates/configmap.yaml new file mode 100644 index 0000000..4ccdca6 --- /dev/null +++ b/templates/configmap.yaml @@ -0,0 +1,7 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: brave-sync-schema +data: + table.json: |- +{{ .Files.Get "schema/table.json" | indent 4 }} diff --git a/templates/dynamodb.yaml b/templates/dynamodb.yaml new file mode 100644 index 0000000..f8f7142 --- /dev/null +++ b/templates/dynamodb.yaml @@ -0,0 +1,67 @@ +apiVersion: v1 +kind: Service +metadata: + name: brave-sync-dynamodb +spec: + selector: + app.kubernetes.io/name: brave-sync-dynamodb + ports: + - name: http + port: 8000 + targetPort: http +--- +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: brave-sync-dynamodb +spec: + serviceName: brave-sync-dynamodb + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/name: brave-sync-dynamodb + template: + metadata: + labels: + app.kubernetes.io/name: brave-sync-dynamodb + spec: + securityContext: + fsGroup: 1000 + containers: + - name: dynamodb + image: amazon/dynamodb-local:3.1.0 + args: ["-jar", "DynamoDBLocal.jar", "-sharedDb", "-dbPath", "/data"] + ports: + - name: http + containerPort: 8000 + volumeMounts: + - name: data + mountPath: /data + resources: + requests: + cpu: 100m + memory: 512Mi + limits: + memory: 1Gi + startupProbe: + tcpSocket: + port: http + failureThreshold: 30 + periodSeconds: 5 + readinessProbe: + tcpSocket: + port: http + periodSeconds: 10 + livenessProbe: + tcpSocket: + port: http + periodSeconds: 20 + volumeClaimTemplates: + - metadata: + name: data + spec: + accessModes: [ReadWriteOnce] + storageClassName: rook-ceph-block + resources: + requests: + storage: 2Gi diff --git a/templates/ingress.yaml b/templates/ingress.yaml new file mode 100644 index 0000000..ae25785 --- /dev/null +++ b/templates/ingress.yaml @@ -0,0 +1,65 @@ +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: brave-sync + annotations: + cert-manager.io/cluster-issuer: letsencrypt-dns +spec: + ingressClassName: traefik + tls: + - hosts: + - {{ .Values.host | quote }} + secretName: brave-sync-tls + rules: + - host: {{ .Values.host | quote }} + http: + paths: + - path: / + pathType: Exact + backend: + service: + name: brave-sync + port: + name: http + - path: /v2 + pathType: Prefix + backend: + service: + name: brave-sync + port: + name: http +--- +# Brave 1.82 could strip /v2 from a custom URL on relaunch. Keep this +# single-path compatibility route for existing affected clients. +apiVersion: traefik.io/v1alpha1 +kind: Middleware +metadata: + name: brave-sync-compat +spec: + replacePathRegex: + regex: ^/command(/.*)?$ + replacement: /v2/command${1} +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: brave-sync-compat + annotations: + traefik.ingress.kubernetes.io/router.middlewares: {{ .Release.Namespace }}-brave-sync-compat@kubernetescrd +spec: + ingressClassName: traefik + tls: + - hosts: + - {{ .Values.host | quote }} + secretName: brave-sync-tls + rules: + - host: {{ .Values.host | quote }} + http: + paths: + - path: /command + pathType: Prefix + backend: + service: + name: brave-sync + port: + name: http diff --git a/templates/networkpolicy.yaml b/templates/networkpolicy.yaml new file mode 100644 index 0000000..5b7896b --- /dev/null +++ b/templates/networkpolicy.yaml @@ -0,0 +1,21 @@ +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: brave-sync-backend +spec: + podSelector: + matchExpressions: + - key: app.kubernetes.io/name + operator: In + values: [brave-sync-dynamodb, brave-sync-valkey] + policyTypes: [Ingress] + ingress: + - from: + - podSelector: + matchLabels: + app.kubernetes.io/name: brave-sync + ports: + - protocol: TCP + port: 8000 + - protocol: TCP + port: 6379 diff --git a/templates/sync.yaml b/templates/sync.yaml new file mode 100644 index 0000000..47512ba --- /dev/null +++ b/templates/sync.yaml @@ -0,0 +1,106 @@ +apiVersion: v1 +kind: Service +metadata: + name: brave-sync +spec: + selector: + app.kubernetes.io/name: brave-sync + ports: + - name: http + port: 8295 + targetPort: http +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: brave-sync +spec: + replicas: 1 + strategy: + type: Recreate + selector: + matchLabels: + app.kubernetes.io/name: brave-sync + template: + metadata: + labels: + app.kubernetes.io/name: brave-sync + annotations: + checksum/schema: {{ .Files.Get "schema/table.json" | sha256sum }} + spec: + imagePullSecrets: + - name: quay-pull-secret + initContainers: + - name: initialize-table + image: amazon/aws-cli:2.31.9 + command: ["/bin/sh", "-ec"] + args: + - | + until aws dynamodb list-tables --endpoint-url "$AWS_ENDPOINT" >/dev/null 2>&1; do sleep 2; done + if ! aws dynamodb describe-table --table-name "$TABLE_NAME" --endpoint-url "$AWS_ENDPOINT" >/dev/null 2>&1; then + aws dynamodb create-table --cli-input-json file:///schema/table.json --endpoint-url "$AWS_ENDPOINT" + aws dynamodb update-time-to-live --table-name "$TABLE_NAME" --time-to-live-specification 'Enabled=true,AttributeName=ExpirationTime' --endpoint-url "$AWS_ENDPOINT" + fi + env: + - name: AWS_ACCESS_KEY_ID + value: GOSYNC + - name: AWS_SECRET_ACCESS_KEY + value: GOSYNC + - name: AWS_REGION + value: us-west-2 + - name: AWS_ENDPOINT + value: http://brave-sync-dynamodb:8000 + - name: TABLE_NAME + value: client-entity-dev + volumeMounts: + - name: schema + mountPath: /schema + readOnly: true + containers: + - name: sync + image: {{ printf "registry.brunner.ninja/feedc0de/brave-sync:%s" .Chart.AppVersion | quote }} + imagePullPolicy: IfNotPresent + env: + - name: ENV + value: local + - name: AWS_ACCESS_KEY_ID + value: GOSYNC + - name: AWS_SECRET_ACCESS_KEY + value: GOSYNC + - name: AWS_REGION + value: us-west-2 + - name: AWS_ENDPOINT + value: http://brave-sync-dynamodb:8000 + - name: TABLE_NAME + value: client-entity-dev + - name: REDIS_URL + value: brave-sync-valkey:6379 + ports: + - name: http + containerPort: 8295 + resources: + requests: + cpu: 50m + memory: 96Mi + limits: + memory: 256Mi + startupProbe: + httpGet: + path: /health-check + port: http + failureThreshold: 24 + periodSeconds: 5 + readinessProbe: + httpGet: + path: /health-check + port: http + periodSeconds: 10 + livenessProbe: + httpGet: + path: /health-check + port: http + periodSeconds: 20 + volumes: + - name: schema + configMap: + name: brave-sync-schema diff --git a/templates/valkey.yaml b/templates/valkey.yaml new file mode 100644 index 0000000..992d519 --- /dev/null +++ b/templates/valkey.yaml @@ -0,0 +1,54 @@ +apiVersion: v1 +kind: Service +metadata: + name: brave-sync-valkey +spec: + selector: + app.kubernetes.io/name: brave-sync-valkey + ports: + - name: redis + port: 6379 + targetPort: redis +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: brave-sync-valkey +spec: + replicas: 1 + strategy: + type: Recreate + selector: + matchLabels: + app.kubernetes.io/name: brave-sync-valkey + template: + metadata: + labels: + app.kubernetes.io/name: brave-sync-valkey + spec: + containers: + - name: valkey + image: valkey/valkey:9.0.1-alpine + args: ["--save", "", "--appendonly", "no"] + ports: + - name: redis + containerPort: 6379 + resources: + requests: + cpu: 10m + memory: 32Mi + limits: + memory: 128Mi + startupProbe: + exec: + command: ["valkey-cli", "ping"] + failureThreshold: 12 + periodSeconds: 5 + readinessProbe: + exec: + command: ["valkey-cli", "ping"] + periodSeconds: 10 + livenessProbe: + exec: + command: ["valkey-cli", "ping"] + periodSeconds: 20 diff --git a/test.sh b/test.sh new file mode 100755 index 0000000..d9f259d --- /dev/null +++ b/test.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +set -euo pipefail + +project_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +workdir=$(mktemp --directory) +trap 'rm -rf -- "${workdir}"' EXIT + +upstream_commit=$(<"${project_dir}/upstream-commit.txt") +[[ "${upstream_commit}" =~ ^[0-9a-f]{40}$ ]] || { + echo "upstream-commit.txt must contain a full Git commit" >&2 + exit 1 +} + +helm lint "${project_dir}" --strict +package=$("${project_dir}/package.sh" "${workdir}" sha-test) +helm lint "${package}" --strict +helm template brave-sync "${package}" --namespace brave-sync > "${workdir}/rendered.yaml" +test -s "${workdir}/rendered.yaml" +actual_version=$(helm show chart "${package}" | awk '/^version:/ {print $2}') +test "${actual_version}" = "$(<"${project_dir}/chart-version.txt")" +actual_image=$(helm show chart "${package}" | awk '/^appVersion:/ {gsub(/"/, "", $2); print $2}') +test "${actual_image}" = sha-test +rg -q 'image:.*:sha-test' "${workdir}/rendered.yaml" + +echo "Chart lint, package, and render passed" diff --git a/upstream-commit.txt b/upstream-commit.txt new file mode 100644 index 0000000..97abf67 --- /dev/null +++ b/upstream-commit.txt @@ -0,0 +1 @@ +e51290d32228c0f6613a6b13c9881893c13eac6b diff --git a/values.yaml b/values.yaml new file mode 100644 index 0000000..2c8117d --- /dev/null +++ b/values.yaml @@ -0,0 +1 @@ +host: sync.brunner.ninja