diff --git a/.github/agents/developer.agent.md b/.github/agents/developer.agent.md index 175406c..f0ab524 100644 --- a/.github/agents/developer.agent.md +++ b/.github/agents/developer.agent.md @@ -70,6 +70,48 @@ Follow these patterns exactly as they exist in the codebase: - Use `fmt.Errorf("context: %w", err)` for error wrapping - Return errors from `Reconcile` to trigger requeue with backoff +**Go formatting — control flow spacing**: +- Always add a blank line **before** `if`, `for`, `return`, and `select` statements when they follow other statements in the same block. This applies inside function bodies, closures, and loop bodies. +- Always add a blank line **after** a closure body (the closing `}`) before the next statement in the outer block. +- Example — correct: + ```go + cmds := make([]*redis.MapStringStringCmd, len(keys)) + _, err := client.Pipelined(ctx, func(pipe redis.Pipeliner) error { + for i, k := range keys { + ns, kind, name, ok := splitResourceKey(k) + if !ok { + continue + } + + cmds[i] = pipe.HGetAll(ctx, keyResourceHash(ns, kind, name)) + } + + return nil + }) + + if err != nil && err != redis.Nil { + return nil, fmt.Errorf("stats: batch resource stats: %w", err) + } + ``` +- Example — incorrect (no breathing room): + ```go + cmds := make([]*redis.MapStringStringCmd, len(keys)) + _, err := client.Pipelined(ctx, func(pipe redis.Pipeliner) error { + for i, k := range keys { + ns, kind, name, ok := splitResourceKey(k) + if !ok { + continue + } + cmds[i] = pipe.HGetAll(ctx, keyResourceHash(ns, kind, name)) + } + return nil + }) + if err != nil && err != redis.Nil { + return nil, fmt.Errorf("stats: batch resource stats: %w", err) + } + ``` +- The rule does not apply to the first statement in a block, or to single-statement blocks. + **Types and packages**: - CRD types live in `api/v1alpha1/` — never define new types elsewhere - Storage backends are in `pkg/storage/` diff --git a/.github/agents/security-review.agent.md b/.github/agents/security-review.agent.md index 18918d6..4dadc76 100644 --- a/.github/agents/security-review.agent.md +++ b/.github/agents/security-review.agent.md @@ -1,39 +1,70 @@ --- -description: "Use when: reviewing security of Go code, Helm charts, or Kubernetes manifests; running Trivy scans; validating OIDC or OAuth2 authentication flows; checking for secrets in code; auditing GroupBinding expressions; reviewing RBAC configurations; or approving/blocking a change on security grounds in the OpenDepot project." +description: "Use when: reviewing security of Go code, TypeScript/React UI, NGINX config, Helm charts, or Kubernetes manifests; running Trivy scans; validating OIDC or OAuth2 authentication flows; checking for secrets in code; auditing GroupBinding expressions; reviewing RBAC configurations; or approving/blocking a change on security grounds in the OpenDepot project." name: "OpenDepot Security Review" model: "Claude Sonnet 4.6 (copilot)" -tools: [read, search, execute, agent, todo, browser] +tools: [read, search, execute, agent, todo, browser, github/issue_read, github/issue_write, github/list_issues, github/add_issue_comment] agents: ["OpenDepot Developer"] argument-hint: "Branch or set of files to security review" --- -You are a security engineer specializing in cloud-native infrastructure security. You review Go code, Helm charts, and Kubernetes manifests for security issues, run Trivy container and IaC scans, and validate authentication flows (OIDC, OAuth2). You **never** fix code yourself — you report findings to the **OpenDepot Developer** agent and only approve when all issues are resolved. +You are a security engineer specializing in cloud-native infrastructure security. You review Go code, TypeScript/React UI code, NGINX configuration, Helm charts, and Kubernetes manifests for security issues, run Trivy container and IaC scans, run npm/yarn audits, and validate authentication flows (OIDC, OAuth2, iron-session). You **never** fix code yourself — you report findings to the **OpenDepot Developer** agent and only approve when all issues are resolved. ## Approval Policy You issue a **PASS** only when ALL of the following are true: -1. Zero CRITICAL or HIGH Trivy CVEs remain unmitigated +1. Zero CRITICAL or HIGH Trivy CVEs remain unmitigated **and** any finding with no available fix has a corresponding GitHub Issue open to track it 2. Zero OIDC/OAuth2 security issues (token validation, issuer pinning, scope enforcement, PKCE, redirect URI validation) 3. Zero hardcoded secrets, credentials, or tokens in any file 4. Zero overly-permissive RBAC or GroupBinding expressions (e.g. `expression: "true"` must be flagged for production paths) 5. Zero Kubernetes security misconfigurations (privileged containers, hostPath without justification, missing resource limits, missing security contexts) 6. Zero Helm chart misconfigurations (secrets in values, missing `securityContext`, world-readable mounts) +7. Zero HIGH or CRITICAL npm/yarn dependency vulnerabilities with an available fix — unfixable vulnerabilities must have a GitHub Issue open to track them +8. Zero `NEXT_PUBLIC_` environment variables that expose secrets or internal configuration to the browser +9. Zero Valkey ACL misconfigurations in production contexts (password must be sourced from a Kubernetes Secret, not plaintext) A **FAIL** on any single criterion blocks the change regardless of the others. +**Warnings (do not block but must be noted in the report):** +- `proxy_ssl_verify off` in NGINX config — acceptable for e2e test environments; flag with a note if it appears in production-targeted configuration +- `dex.config.staticPasswords` entries in Helm values — acceptable for local dev and e2e tests; warn if present in a production-targeted values file +- Missing HSTS header in NGINX when TLS is not enabled — note only; required when TLS is enabled + +## GitHub Issue Policy + +When a CRITICAL or HIGH CVE or npm vulnerability has **no available fix** (e.g. Trivy reports "No fix available" or `yarn npm audit` shows no patched version): + +1. Search existing GitHub Issues on `tonedefdev/opendepot` for the CVE ID or package name before creating a new one +2. If no issue exists, use the `mcp_github_issue_write` tool to create one with: + - Title: `[Security] : ` + - Body: CVE ID, severity, affected component/image, Trivy/audit output snippet, and a note that no fix is currently available + - Labels: `security`, `dependencies` (add whichever exist on the repo) +3. Record the issue number in your final report +4. On subsequent reviews, check whether the issue has been resolved or the fix has become available + ## Workflow ### 1. Identify Scope -Run `git diff main..HEAD --name-only` to get the list of changed files. Build a todo list grouped by category: Go code, Helm chart, Kubernetes manifests, auth code. +Run `git diff main..HEAD --name-only` to get the list of changed files. Build a todo list grouped by category: Go code, TypeScript/React UI, NGINX config, Helm chart, Kubernetes manifests, auth code, Valkey/storage credentials. ### 2. Run Trivy Scans -**Container images** (for each service with changed code): +**Container images** (for each service with changed code, including the UI): ```bash -trivy image --severity CRITICAL,HIGH --exit-code 0 : +trivy image --severity CRITICAL,HIGH --exit-code 0 ghcr.io/tonedefdev/opendepot/server: +trivy image --severity CRITICAL,HIGH --exit-code 0 ghcr.io/tonedefdev/opendepot/ui: +trivy image --severity CRITICAL,HIGH --exit-code 0 ghcr.io/tonedefdev/opendepot/version-controller: +trivy image --severity CRITICAL,HIGH --exit-code 0 ghcr.io/tonedefdev/opendepot/module-controller: +trivy image --severity CRITICAL,HIGH --exit-code 0 ghcr.io/tonedefdev/opendepot/depot-controller: +trivy image --severity CRITICAL,HIGH --exit-code 0 ghcr.io/tonedefdev/opendepot/provider-controller: +# Scan the Valkey subchart image at its pinned version +trivy image --severity CRITICAL,HIGH --exit-code 0 valkey/valkey: ``` +Only scan images whose service code changed, but **always** scan the UI image when any file under `services/ui/` changes. + +For each finding, note whether a fix is available. If no fix exists, follow the **GitHub Issue Policy** above. + **IaC scan** (Helm chart and Kubernetes manifests): ```bash trivy config --severity CRITICAL,HIGH chart/opendepot/ @@ -47,7 +78,18 @@ trivy fs --scanners secret,misconfig --severity CRITICAL,HIGH . Collect all findings into a structured list before proceeding. -### 3. Review Authentication Code +### 3. Run npm/yarn Audit (UI) + +For any change touching `services/ui/`: +```bash +cd services/ui && yarn npm audit --severity high --recursive +``` + +- **HIGH or CRITICAL with a fix available** → FAIL; hand off to developer for `yarn upgrade` or a patch +- **HIGH or CRITICAL with no fix available** → follow the **GitHub Issue Policy**; note in the report but do not block +- **MODERATE and below** → advisory only + +### 4. Review Authentication Code For any change touching `services/server/auth.go`, `services/server/discovery.go`, or OIDC/OAuth2 configuration: @@ -58,7 +100,15 @@ For any change touching `services/server/auth.go`, `services/server/discovery.go - **Redirect URIs**: Confirm they are an explicit allowlist — no wildcard or open redirects - **Groups claim**: Confirm the `groups` claim is extracted from the verified ID/access token, not from user-supplied input -### 4. Review Go Code +For any change touching `services/ui/` auth code or iron-session: + +- **Session secret**: Confirm `SESSION_PASSWORD` is sourced from a Kubernetes Secret (via `secretKeyRef`), never a plaintext Helm value +- **Session secret length**: Confirm the secret is at least 32 characters +- **Cookie attributes**: Confirm `httpOnly`, `secure` (in production), and `sameSite` are set on the session cookie +- **OIDC callback**: Confirm the callback path is registered in the Dex/IdP static client and not user-controllable +- **Token storage**: Confirm OIDC tokens are stored server-side in the encrypted session and never exposed in the HTML or `NEXT_PUBLIC_` vars + +### 5. Review Go Code Check changed `.go` files for: - SQL/command injection via `fmt.Sprintf` into queries or shell commands @@ -67,8 +117,28 @@ Check changed `.go` files for: - HTTP handlers that skip authentication middleware - Use of `math/rand` instead of `crypto/rand` for security-sensitive values - `#nosec` annotations — each must be justified with a comment +- GPG private key material — must never be logged; must be sourced from a Kubernetes Secret referenced by `server.gpg.secretName` + +### 6. Review TypeScript / React UI Code -### 5. Review Helm Chart & Kubernetes Manifests +Check changed files under `services/ui/` for: +- **`NEXT_PUBLIC_` variables**: Must never contain tokens, secrets, internal hostnames, or credentials — these are embedded into the browser bundle at build time and visible to all users +- **`dangerouslySetInnerHTML`**: Flag any usage; it must have an explicit comment justifying why it is safe and confirming the content is sanitised +- **User-controlled redirects**: Confirm `next/navigation` redirects use an allowlist and do not follow arbitrary user-supplied URLs (open redirect) +- **API routes**: Confirm all Next.js API routes (`app/api/` or `pages/api/`) validate the session before returning data +- **Dependency confusion**: Check `package.json` for any scoped packages (`@org/pkg`) that could be hijacked via a public registry + +### 7. Review NGINX Configuration + +Check `chart/opendepot/templates/ui-configmap.yaml` (the NGINX config rendered into the UI pod) for: +- **`server_tokens off`** — must be present to suppress the NGINX version header +- **`proxy_ssl_verify off`** — acceptable in e2e test environments; **warn** if it appears without a comment noting it is test-only +- **Security headers**: `X-Content-Type-Options: nosniff`, `X-Frame-Options: SAMEORIGIN`, and `Referrer-Policy: strict-origin-when-cross-origin` must be present; additionally verify `Strict-Transport-Security` is set when TLS is enabled on the server +- **Upstream SSRF**: Confirm the `opendepot_server` upstream hostname is derived from a fixed Helm template value (e.g. `server..svc.cluster.local`) and is never user-supplied input +- **Request smuggling**: Confirm `proxy_http_version 1.1` and appropriate `Connection` header handling is set for WebSocket/upgrade paths +- **Client max body size**: Confirm a reasonable `client_max_body_size` is set to prevent large-upload DoS + +### 8. Review Helm Chart & Kubernetes Manifests Check `chart/opendepot/` and any manifest changes for: - `securityContext.runAsNonRoot: true` present on all containers @@ -79,16 +149,21 @@ Check `chart/opendepot/` and any manifest changes for: - Resource `limits` set on all containers - RBAC `ClusterRole` verbs — `*` or `escalate`/`impersonate` must be flagged -### 6. Review GroupBinding Expressions +**Valkey-specific checks:** +- `valkey.auth.enabled: true` must be set in production contexts +- The Valkey ACL password must be referenced via `server.stats.valkeyPasswordSecretName` pointing to a pre-existing Kubernetes Secret — the password must never appear as a plaintext Helm value +- Confirm the Valkey Service is of type `ClusterIP` (not `LoadBalancer` or `NodePort`) so it is not externally reachable + +### 9. Review GroupBinding Expressions For any `GroupBinding` resource or `oidc-test-resources` Makefile target: - `expression: "true"` — flag as overly permissive if it appears in any non-local-dev path - Expressions must use `in` operator against a named group, not an empty string check - Confirm `moduleResources` or `providerResources` is scoped, not a bare `["*"]` in production contexts -### 7. Report or Approve +### 10. Report or Approve -**If issues found**: Compile a structured report with severity, file, line (where applicable), description, and recommended fix. Hand off to the **OpenDepot Developer** agent with the full report and wait for a fix. Re-run the relevant scan/check after the developer reports back. +**If issues found**: Compile a structured report with severity, file, line (where applicable), description, recommended fix, and — for unfixable CVEs — the GitHub Issue number created to track it. Hand off to the **OpenDepot Developer** agent with the full report and wait for a fix. Re-run the relevant scan/check after the developer reports back. **If clean**: Reply with: @@ -97,13 +172,18 @@ SECURITY REVIEW: PASS Scans run: Findings: none -Approval: all CRITICAL/HIGH CVEs resolved, no auth or configuration issues found. Ready for Documentation handoff. +Open tracking issues: +Approval: all CRITICAL/HIGH CVEs resolved or tracked, no auth or configuration issues found. Ready for Documentation handoff. ``` ## Constraints - DO NOT write or edit any code, charts, or manifests -- DO NOT approve with any unresolved CRITICAL or HIGH CVE +- DO NOT approve with any unresolved CRITICAL or HIGH CVE that has an available fix +- DO NOT approve with any HIGH or CRITICAL npm vulnerability that has an available fix - DO NOT approve with `expression: "true"` in a non-local-dev GroupBinding in production code paths +- DO NOT approve with plaintext secrets or passwords in `values.yaml` or any Helm template - DO NOT skip Trivy scans — they are mandatory for every review +- DO NOT skip the npm/yarn audit when `services/ui/` files have changed - ONLY interact with the **OpenDepot Developer** agent for fixes; do not escalate to Planner or Documentation +- ALWAYS create a GitHub Issue for unfixable CVEs before issuing a PASS diff --git a/.github/workflows/e2e.yaml b/.github/workflows/e2e.yaml index 74afbac..b31896c 100644 --- a/.github/workflows/e2e.yaml +++ b/.github/workflows/e2e.yaml @@ -133,7 +133,7 @@ jobs: - name: Install Helm uses: azure/setup-helm@v4 with: - version: "v3.14.0" + version: "v4.0.0" - name: Install OpenTofu uses: opentofu/setup-opentofu@v1 @@ -192,7 +192,7 @@ jobs: - name: Install Helm uses: azure/setup-helm@v4 with: - version: "v3.14.0" + version: "v4.0.0" - name: Install OpenTofu uses: opentofu/setup-opentofu@v1 @@ -249,7 +249,7 @@ jobs: - name: Install Helm uses: azure/setup-helm@v4 with: - version: "v3.14.0" + version: "v4.0.0" - name: Create kind cluster run: kind create cluster --name opendepot-test-e2e @@ -297,7 +297,7 @@ jobs: - name: Install Helm uses: azure/setup-helm@v4 with: - version: "v3.14.0" + version: "v4.0.0" - name: Install OpenTofu uses: opentofu/setup-opentofu@v1 @@ -354,7 +354,7 @@ jobs: - name: Install Helm uses: azure/setup-helm@v4 with: - version: "v3.14.0" + version: "v4.0.0" - name: Create kind cluster run: kind create cluster --name opendepot-test-e2e diff --git a/.trivyignore b/.trivyignore index 14a02b6..dd7e25e 100644 --- a/.trivyignore +++ b/.trivyignore @@ -30,3 +30,38 @@ CVE-2026-39825 CVE-2026-39826 CVE-2026-39836 CVE-2026-42499 + +# ────────────────────────────────────────────────────────── +# valkey/valkey:8 container image — OS-level packages +# These packages are not invoked at runtime by Valkey and are +# present only as transitive dependencies of the base OS layer. +# Revisit when upstream fixes become available or the base image is updated. +# Last reviewed: 2026-05-31 +# ────────────────────────────────────────────────────────── + +# perl-base — Heap buffer overflow compiling regex (no upstream fix as of 2026-05-31) +# perl-base is not used by the Valkey binary at runtime. +CVE-2026-8376 + +# perl-base / Archive::Tar — symlink extraction path traversal (no upstream fix) +CVE-2026-42496 + +# perl-base / Archive::Tar — hardlink extraction path traversal (no upstream fix) +CVE-2026-42497 + +# perl-base / perl-IO-Compress — arbitrary code execution via output glob (no fix) +CVE-2026-48962 + +# perl-base — memory exhaustion in Archive::Tar (no upstream fix) +CVE-2026-9538 + +# libtinfo6 / ncurses — buffer overflow (no upstream fix as of 2026-05-31) +# ncurses is not invoked by the Valkey binary; present in the Debian base image only. +CVE-2025-69720 + +# libcap2 — privilege escalation via TOCTOU race in cap_set_file() (fix exists in deb13u1) +# A patched Debian package is available (1:2.75-10+deb13u1) but the valkey/valkey:8 image +# has not yet been rebuilt with it. cap_set_file() is not called by the Valkey binary at +# runtime. Suppression will be removed when the valkey:8 image is rebuilt. +# Tracking: https://github.com/tonedefdev/opendepot/issues/68 +CVE-2026-4878 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a2f11e1..e419cef 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -73,6 +73,16 @@ kind create cluster --name kind > [!TIP] > If you already have a `kind` cluster from a previous run it can be reused. The suites use `helm upgrade --install` so they are safe to run repeatedly. +### Chart Dependencies + +OpenDepot uses Helm subcharts for Dex and Valkey. The tarballs are committed to `chart/opendepot/charts/`, so no internet access is required during e2e test runs. If you add or update a subchart dependency, regenerate the lock file and tarballs with: + +```bash +make chart-deps +``` + +`make ui-setup` and `make ui-setup-oidc` call `chart-deps` automatically, so you only need to run it manually after cloning or after editing `chart/opendepot/Chart.yaml`. + --- ## Running the E2E Tests diff --git a/Makefile b/Makefile index 5fcc817..1be7486 100644 --- a/Makefile +++ b/Makefile @@ -365,7 +365,12 @@ UI_OIDC_CLIENT_ID ?= opendepot-ui # Static client secret used for the UI Dex client in local Kind testing only. UI_OIDC_SECRET ?= ui-local-test-secret -.PHONY: ui-session-secret ui-gpg-secret ui-deploy-anon ui-deploy ui-forward ui-stop ui-tofurc ui-setup ui-setup-oidc ui-dev ui-dev-stop +.PHONY: ui-session-secret ui-gpg-secret ui-deploy-anon ui-deploy ui-forward ui-stop ui-tofurc ui-setup ui-setup-oidc ui-dev ui-dev-stop chart-deps + +## Download and cache Helm chart dependencies (Dex, Valkey). Run once after cloning. +## make ui-setup and make ui-setup-oidc call this automatically. +chart-deps: + helm dependency update $(CHART_PATH) ## Generate a throwaway GPG keypair and create the provider signing secret for local testing. ## Idempotent — skips if the secret already exists. @@ -418,6 +423,7 @@ ui-deploy-anon: ui-session-secret --set ui.image.repository=$(REGISTRY)/ui \ --set ui.image.tag=$(TAG) \ --set ui.sessionPasswordSecretName=ui-session-secret \ + --set ui.nginx.preserveHostPort=true \ --set storage.filesystem.enabled=true \ --set storage.filesystem.hostPath=/tmp/opendepot-modules \ --set provider.enabled=true \ @@ -425,7 +431,6 @@ ui-deploy-anon: ui-session-secret --set scanning.providerScanning=true \ --set scanning.cache.storageClassName="standard" \ --set scanning.cache.accessMode=ReadWriteOnce \ - --set server.stats.emptyDir=true \ --set version.zapLogLevel=5 \ --wait \ kubectl create job trivy-cache-db from=cronjob/trivy-db-updater -n $(OIDC_NAMESPACE) -w @@ -514,8 +519,6 @@ endif ' clientId: "$(OIDC_CLIENT_ID)"' \ ' clientSecret: "$(OIDC_SECRET)"' \ ' groupsClaim: "groups"' \ - ' stats:' \ - ' emptyDir: true' \ 'provider:' \ ' enabled: true' \ ' image:' \ @@ -548,6 +551,8 @@ endif " authzUrl: \"$(OIDC_DEX_AUTHZ_URL)\"" \ ' clientId: "$(UI_OIDC_CLIENT_ID)"' \ ' clientSecretName: ui-oidc-secret' \ + ' nginx:' \ + ' preserveHostPort: true' \ > "$$tmpfile"; \ echo "=== Deploying full e2e stack: UI + OIDC + scanning (dex issuer: $(OIDC_DEX_INCLUSTER_URL)) ==="; \ helm upgrade --install $(OIDC_RELEASE_NAME) $(CHART_PATH) \ @@ -608,11 +613,11 @@ ui-tofurc: ## Build all images, deploy the UI in anonymous-auth mode, and start the port-forward. ## Usage: make ui-setup -ui-setup: deploy build-version-controller-scanning load-version-controller-scanning ui-deploy-anon restart ui-forward +ui-setup: chart-deps deploy build-version-controller-scanning load-version-controller-scanning ui-deploy-anon restart ui-forward ## Build all images, deploy the full e2e stack (UI + OIDC + scanning), and start port-forwards. ## Usage: make ui-setup-oidc PASS=yourpassword -ui-setup-oidc: deploy build-version-controller-scanning load-version-controller-scanning ui-deploy restart ui-forward ui-tofurc +ui-setup-oidc: chart-deps deploy build-version-controller-scanning load-version-controller-scanning ui-deploy restart ui-forward ui-tofurc ## One-shot local UI development against a running kind cluster server. ## - Starts server API port-forward: localhost:$(UI_API_PORT) -> svc/server:80 diff --git a/chart/opendepot/Chart.lock b/chart/opendepot/Chart.lock index 66aaf15..3c93447 100644 --- a/chart/opendepot/Chart.lock +++ b/chart/opendepot/Chart.lock @@ -1,6 +1,9 @@ dependencies: - name: dex repository: https://charts.dexidp.io - version: 0.24.0 -digest: sha256:f0733bf05978367406c91036105ef9b9f0666872e2b38a4742d4b150d77fde68 -generated: "2026-05-15T01:02:12.62587-07:00" + version: 0.24.1 +- name: valkey + repository: https://valkey-io.github.io/valkey-helm/ + version: 0.9.4 +digest: sha256:62aae976f7ac11346b805ffa6ce89105622e4a3bd51debdbfae56a2930d072b0 +generated: "2026-05-31T15:21:41.134715-07:00" diff --git a/chart/opendepot/Chart.yaml b/chart/opendepot/Chart.yaml index 4d3407b..0cc7b0b 100644 --- a/chart/opendepot/Chart.yaml +++ b/chart/opendepot/Chart.yaml @@ -2,8 +2,8 @@ apiVersion: v2 name: opendepot description: A Helm chart for deploying OpenDepot, a cloud-native OpenTofu Registry type: application -version: 0.5.0 -appVersion: "0.5.0" +version: 0.6.0 +appVersion: "0.6.0" keywords: - terraform - opentofu @@ -18,3 +18,7 @@ dependencies: version: "0.24.*" repository: https://charts.dexidp.io condition: dex.enabled + - name: valkey + version: "0.9.*" + repository: https://valkey-io.github.io/valkey-helm/ + condition: server.enabled diff --git a/chart/opendepot/charts/dex-0.24.0.tgz b/chart/opendepot/charts/dex-0.24.0.tgz deleted file mode 100644 index 5f58895..0000000 Binary files a/chart/opendepot/charts/dex-0.24.0.tgz and /dev/null differ diff --git a/chart/opendepot/charts/dex-0.24.1.tgz b/chart/opendepot/charts/dex-0.24.1.tgz new file mode 100644 index 0000000..35695a7 Binary files /dev/null and b/chart/opendepot/charts/dex-0.24.1.tgz differ diff --git a/chart/opendepot/charts/valkey-0.9.4.tgz b/chart/opendepot/charts/valkey-0.9.4.tgz new file mode 100644 index 0000000..765e9ad Binary files /dev/null and b/chart/opendepot/charts/valkey-0.9.4.tgz differ diff --git a/chart/opendepot/templates/server-deployment.yaml b/chart/opendepot/templates/server-deployment.yaml index 689641e..1db5c87 100644 --- a/chart/opendepot/templates/server-deployment.yaml +++ b/chart/opendepot/templates/server-deployment.yaml @@ -78,9 +78,7 @@ spec: {{- if and .Values.storage.filesystem.enabled .Values.storage.filesystem.mountPath }} - --filesystem-mount-path={{ .Values.storage.filesystem.mountPath }} {{- end }} - {{- if or .Values.server.stats.persistence.enabled .Values.server.stats.emptyDir }} - - --stats-db-path=/data/stats/stats.db - {{- end }} + - --stats-valkey-addr=valkey.{{ .Values.global.namespace }}.svc.cluster.local:6379 {{- if .Values.server.tls.enabled }} - --tls-cert-path={{ .Values.server.tls.certPath }} - --tls-cert-key={{ .Values.server.tls.keyPath }} @@ -90,6 +88,14 @@ spec: - secretRef: name: {{ .Values.server.gpg.secretName }} {{- end }} + {{- if .Values.server.stats.valkeyPasswordSecretName }} + env: + - name: OPENDEPOT_VALKEY_PASSWORD + valueFrom: + secretKeyRef: + name: {{ .Values.server.stats.valkeyPasswordSecretName }} + key: default + {{- end }} ports: - name: http containerPort: {{ .Values.server.service.targetPort }} @@ -101,7 +107,7 @@ spec: {{- end }} resources: {{- toYaml .Values.server.resources | nindent 10 }} - {{- if or .Values.server.tls.enabled .Values.storage.filesystem.enabled .Values.server.stats.persistence.enabled .Values.server.stats.emptyDir }} + {{- if or .Values.server.tls.enabled .Values.storage.filesystem.enabled }} volumeMounts: {{- if .Values.server.tls.enabled }} - name: tls @@ -112,12 +118,8 @@ spec: - name: modules mountPath: {{ .Values.storage.filesystem.mountPath }} {{- end }} - {{- if or .Values.server.stats.persistence.enabled .Values.server.stats.emptyDir }} - - name: stats-db - mountPath: /data/stats - {{- end }} {{- end }} - {{- if or .Values.server.tls.enabled .Values.storage.filesystem.enabled .Values.server.stats.persistence.enabled .Values.server.stats.emptyDir }} + {{- if or .Values.server.tls.enabled .Values.storage.filesystem.enabled }} volumes: {{- if .Values.server.tls.enabled }} - name: tls @@ -135,14 +137,6 @@ spec: claimName: opendepot-modules {{- end }} {{- end }} - {{- if .Values.server.stats.persistence.enabled }} - - name: stats-db - persistentVolumeClaim: - claimName: server-stats - {{- else if .Values.server.stats.emptyDir }} - - name: stats-db - emptyDir: {} - {{- end }} {{- end }} {{- with .Values.server.nodeSelector }} nodeSelector: diff --git a/chart/opendepot/templates/server-stats-pvc.yaml b/chart/opendepot/templates/server-stats-pvc.yaml deleted file mode 100644 index 7c747ed..0000000 --- a/chart/opendepot/templates/server-stats-pvc.yaml +++ /dev/null @@ -1,18 +0,0 @@ -{{- if and .Values.server.enabled .Values.server.stats.persistence.enabled }} -apiVersion: v1 -kind: PersistentVolumeClaim -metadata: - name: server-stats - namespace: {{ .Values.global.namespace }} - labels: - app: server -spec: - accessModes: - - {{ .Values.server.stats.persistence.accessMode }} - {{- if .Values.server.stats.persistence.storageClassName }} - storageClassName: {{ .Values.server.stats.persistence.storageClassName }} - {{- end }} - resources: - requests: - storage: {{ .Values.server.stats.persistence.size }} -{{- end }} diff --git a/chart/opendepot/templates/ui-configmap.yaml b/chart/opendepot/templates/ui-configmap.yaml index 5f05b79..c91411e 100644 --- a/chart/opendepot/templates/ui-configmap.yaml +++ b/chart/opendepot/templates/ui-configmap.yaml @@ -22,6 +22,7 @@ data: sendfile on; keepalive_timeout 65; server_tokens off; + client_max_body_size 10m; access_log /dev/stdout combined; @@ -40,13 +41,19 @@ data: add_header X-Content-Type-Options nosniff; add_header X-Frame-Options SAMEORIGIN; add_header Referrer-Policy strict-origin-when-cross-origin; + {{- if .Values.server.tls.enabled }} + add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always; + {{- end }} location /opendepot/ { proxy_pass http{{- if .Values.server.tls.enabled }}s{{- end }}://opendepot_server; {{- if .Values.server.tls.enabled }} + # proxy_ssl_verify is disabled because the server uses a self-signed or + # cluster-internal certificate. Replace with a CA bundle reference if using + # a publicly trusted cert on the server service. proxy_ssl_verify off; {{- end }} - proxy_set_header Host $http_host; + proxy_set_header Host {{ if .Values.ui.nginx.preserveHostPort }}$http_host{{ else }}$host{{ end }}; proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Request-ID $request_id; @@ -56,9 +63,12 @@ data: location /.well-known/terraform.json { proxy_pass http{{- if .Values.server.tls.enabled }}s{{- end }}://opendepot_server; {{- if .Values.server.tls.enabled }} + # proxy_ssl_verify is disabled because the server uses a self-signed or + # cluster-internal certificate. Replace with a CA bundle reference if using + # a publicly trusted cert on the server service. proxy_ssl_verify off; {{- end }} - proxy_set_header Host $http_host; + proxy_set_header Host {{ if .Values.ui.nginx.preserveHostPort }}$http_host{{ else }}$host{{ end }}; proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Request-ID $request_id; diff --git a/chart/opendepot/templates/ui-deployment.yaml b/chart/opendepot/templates/ui-deployment.yaml index 294f34e..d196e9b 100644 --- a/chart/opendepot/templates/ui-deployment.yaml +++ b/chart/opendepot/templates/ui-deployment.yaml @@ -59,13 +59,11 @@ spec: - name: DEV_TOKEN_INPUT_ENABLED value: "true" {{- end }} - {{- if .Values.ui.sessionPasswordSecretName }} - name: SESSION_PASSWORD valueFrom: secretKeyRef: - name: {{ .Values.ui.sessionPasswordSecretName }} + name: {{ required "ui.sessionPasswordSecretName must be set when ui.enabled=true" .Values.ui.sessionPasswordSecretName }} key: sessionPassword - {{- end }} securityContext: allowPrivilegeEscalation: false readOnlyRootFilesystem: true diff --git a/chart/opendepot/values.yaml b/chart/opendepot/values.yaml index a32ac6f..dd6d9d9 100644 --- a/chart/opendepot/values.yaml +++ b/chart/opendepot/values.yaml @@ -172,6 +172,12 @@ server: # The secret must have keys: OPENDEPOT_PROVIDER_GPG_KEY_ID, OPENDEPOT_PROVIDER_GPG_ASCII_ARMOR, # OPENDEPOT_PROVIDER_GPG_PRIVATE_KEY_BASE64, and optionally OPENDEPOT_PROVIDER_GPG_SOURCE_URL. secretName: "" + stats: + # Name of a pre-existing Kubernetes Secret whose "default" key holds the Valkey ACL + # password. Must match valkey.auth.usersExistingSecret. When set, the password is + # injected into the server container via the OPENDEPOT_VALKEY_PASSWORD environment + # variable. Leave empty when valkey.auth.enabled=false. + valkeyPasswordSecretName: "" resources: requests: cpu: 100m @@ -181,17 +187,6 @@ server: nodeSelector: {} tolerations: [] affinity: {} - stats: - # emptyDir enables stats tracking using an in-pod ephemeral volume. Data is - # lost on pod restart — suitable for local development and Kind clusters. - emptyDir: false - persistence: - # enabled controls whether a PersistentVolumeClaim is created for the stats - # SQLite database. When false (and emptyDir is false) download tracking is disabled. - enabled: false - storageClassName: "" - size: 1Gi - accessMode: ReadWriteOnce # Pod Disruption Budget podDisruptionBudget: enabled: false @@ -354,6 +349,13 @@ ui: # container will proxy registry protocol requests to. # Defaults to server..svc.cluster.local:80 when empty. serverHost: "" + nginx: + # preserveHostPort passes the client Host header including the port ($http_host) + # to the upstream server service. Enable this for local development environments + # where the UI is accessed on a non-standard port (e.g. opendepot.localtest.me:8080) + # and the server needs the port for correct registry URL construction. + # Must be false in production. + preserveHostPort: false oidc: # enabled toggles OIDC login support in the UI. enabled: false @@ -411,3 +413,42 @@ ui: nodeSelector: {} tolerations: [] affinity: {} + +## Valkey (Redis-compatible) statistics store +## Deployed via the official valkey-io/valkey-helm subchart when server.enabled=true. +valkey: + fullnameOverride: valkey # Forces Service name to exactly "valkey" — do not change + # Pin to the Valkey 8 LTS tag. Tag "8" receives rolling security patches from + # the Valkey project and avoids unmitigated CVEs present in the 9.x branch. + image: + tag: "8" + auth: + # Set to true to require ACL password authentication. + # Recommended for production environments (finance, banking, DoD, etc.). + # When enabled, also set usersExistingSecret and server.stats.valkeyPasswordSecretName + # to the name of the same pre-existing Kubernetes Secret. Use External Secrets + # Operator or HashiCorp Vault to provision the Secret rather than storing the + # password in plain text. + enabled: false + # Name of a pre-existing Secret whose keys are ACL usernames and values are passwords. + # The Secret must contain at minimum: key "default" = . + usersExistingSecret: "" + aclUsers: + default: + # Minimal ACL: access only stats:* keys, deny all commands except those + # used by the server stats subsystem. Operators MUST use this default + # unless they have a specific requirement for broader access. + permissions: "~stats:* &* -@all +HSET +HINCRBY +HGET +HGETALL +INCR +GET +ZINCRBY +ZREVRANGEBYSCORE +ZREVRANGE +EXPIREAT" + dataStorage: + enabled: true + requestedSize: 1Gi + className: "" + resources: + requests: + cpu: 50m + memory: 64Mi + limits: + memory: 256Mi + nodeSelector: {} + tolerations: [] + affinity: {} diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md index c56d47c..d246467 100644 --- a/docs/getting-started/installation.md +++ b/docs/getting-started/installation.md @@ -126,22 +126,28 @@ helm install opendepot opendepot/opendepot \ | `server.oidc.authzUrl` | `""` | Override `login.v1.authz` URL advertised in service discovery | | `server.oidc.tokenUrl` | `""` | Override `login.v1.token` URL advertised in service discovery | -### Server Stats Persistence +### Valkey Stats Store + +Download statistics are persisted in a bundled [Valkey](https://valkey.io/) (Redis-compatible) instance that is always deployed as part of the chart. No extra configuration is required to enable stats — they are always on. | Value | Default | Description | |-------|---------|-------------| -| `server.stats.emptyDir` | `false` | Mount an ephemeral in-pod volume for the stats SQLite database. Data is lost on pod restart — suitable for local development and Kind clusters where no StorageClass is available. | -| `server.stats.persistence.enabled` | `false` | Create a PVC for the download-events SQLite database. Recommended for production deployments. | -| `server.stats.persistence.storageClassName` | `""` | StorageClass for the stats PVC. Leave blank to use the cluster default. | -| `server.stats.persistence.size` | `1Gi` | PVC storage size | -| `server.stats.persistence.accessMode` | `ReadWriteOnce` | PVC access mode. Do not change unless your storage class supports multi-writer access. | - -When either `server.stats.emptyDir: true` or `server.stats.persistence.enabled: true`, the chart mounts a volume at `/data/stats/` in the server deployment and starts the server with `--stats-db-path=/data/stats/stats.db`. When both are `false` (the default), download tracking is disabled entirely — no events are recorded. - -Use `server.stats.emptyDir: true` for local Kind clusters. Use `server.stats.persistence.enabled: true` for production deployments where stats must survive pod restarts. - -!!! warning "Single replica" - The stats PVC uses `ReadWriteOnce`. Keep `server.replicaCount: 1` when stats persistence is enabled. +| `valkey.resources` | see values.yaml | Resource requests and limits for the Valkey pod | +| `valkey.dataStorage.enabled` | `true` | Create a PVC for Valkey data. When `false`, stats are stored on an ephemeral in-pod volume and lost on restart | +| `valkey.dataStorage.className` | `""` | StorageClass for the PVC. Leave blank to use the cluster default | +| `valkey.dataStorage.requestedSize` | `1Gi` | PVC storage size | +| `valkey.auth.enabled` | `false` | Enable Valkey ACL password authentication | +| `valkey.auth.usersExistingSecret` | `""` | Name of a pre-existing Secret whose keys are ACL usernames. Required when auth is enabled | +| `valkey.auth.aclUsers.default.permissions` | `~stats:* &* -@all +HSET +HINCRBY +HGET +HGETALL +INCR +GET +ZINCRBY +ZREVRANGEBYSCORE +ZREVRANGE +EXPIREAT` | ACL permissions string for the default user. Scoped to `stats:*` keys and only the commands used by the server — do not widen to `+@all` in production | +| `server.stats.valkeyPasswordSecretName` | `""` | Name of the Secret injected as `OPENDEPOT_VALKEY_PASSWORD` into the server. Must match `valkey.auth.usersExistingSecret` | +| `valkey.nodeSelector` | `{}` | Node selector for the Valkey pod | +| `valkey.tolerations` | `[]` | Tolerations for the Valkey pod | +| `valkey.affinity` | `{}` | Affinity rules for the Valkey pod | + +Set `valkey.dataStorage.enabled: false` for local Kind clusters or ephemeral environments where no StorageClass is available. For production, leave persistence enabled (the default) so stats survive pod restarts. + +!!! warning "Production Security" + Valkey ACL authentication is **disabled by default**. For production deployments, create a Kubernetes Secret containing the password, then set `valkey.auth.enabled: true`, `valkey.auth.usersExistingSecret`, and `server.stats.valkeyPasswordSecretName` to match. Use [External Secrets Operator](https://external-secrets.io/) or HashiCorp Vault to provision the Secret in regulated environments. ## Controllers diff --git a/docs/guides/migration.md b/docs/guides/migration.md index a8c133f..49fff84 100644 --- a/docs/guides/migration.md +++ b/docs/guides/migration.md @@ -25,3 +25,27 @@ tags: 4. Delete the Depot — all `Provider` and `Version` resources remain untouched This pattern lets you adopt OpenDepot incrementally without disrupting existing workflows. The Depot bridges the gap between the public registries and a fully self-hosted solution. + +## Upgrading to v0.6.0 + +v0.6.0 replaces the SQLite download-stats backend with a bundled Valkey instance. + +### Breaking changes + +- **`--stats-db-path` is removed.** The server flag no longer exists. Any custom Helm values overrides that reference `server.stats.*` must be removed — the chart will reject unknown values. +- **`server.stats` values block is removed.** Remove `server.stats.emptyDir`, `server.stats.persistence.*`, or any `server.stats` key from your `values.yaml` before upgrading. +- **Stats history is not migrated.** Valkey starts with a clean slate — download counts accumulated in the previous SQLite database are not carried over. Historic data can be discarded or archived manually before upgrading. + +### Upgrade steps + +1. Apply the updated CRDs: + ```bash + helm show crds opendepot/opendepot | kubectl apply --server-side -f - + ``` +2. Remove any `server.stats` keys from your `values.yaml`. +3. Upgrade the chart: + ```bash + helm upgrade opendepot opendepot/opendepot -n opendepot-system -f my-values.yaml + ``` + +Valkey is deployed automatically as part of the chart. Download tracking resumes immediately after the server pod becomes ready. For production clusters, `valkey.dataStorage.enabled: true` (the default) ensures stats survive pod restarts — no additional configuration is required. diff --git a/docs/guides/registry-explorer.md b/docs/guides/registry-explorer.md index 7708359..db3eeaa 100644 --- a/docs/guides/registry-explorer.md +++ b/docs/guides/registry-explorer.md @@ -365,29 +365,24 @@ The dashboard shows: | Storage distribution | Per-backend version counts | | Most downloaded | Table of the top 10 most-downloaded resources, with version, namespace, download count, and last-downloaded timestamp | -Summary counts (modules, providers, versions, sync health, security posture, storage distribution) are computed on-demand from Kubernetes CRDs and are always current. Download counts and the most-downloaded table require a persistent stats DB — see [Enabling download tracking](#enabling-download-tracking) below. +Summary counts (modules, providers, versions, sync health, security posture, storage distribution) are computed on-demand from Kubernetes CRDs and are always current. Download counts and the most-downloaded table are sourced from the bundled Valkey stats store and are always active. !!! note "Visibility" The stats page applies the same visibility rules as the rest of the Registry Explorer. Unauthenticated visitors see statistics derived only from publicly-labelled resources. OIDC-authenticated users with a matching `GroupBinding` additionally see statistics for the resources allowed by that binding. -## Enabling Download Tracking +## Download Tracking -By default, download events are tracked in memory and lost on server restart. To persist them across restarts, enable the stats PVC in your Helm values: +Download events are recorded automatically in the bundled Valkey instance that is deployed alongside the server. No extra configuration is required to enable tracking. + +By default, Valkey persists data to a PVC so stats survive pod restarts. For local development or Kind clusters without a StorageClass, disable persistence in your Helm values: ```yaml -server: - stats: - persistence: - enabled: true - storageClassName: "" # leave blank to use the cluster default - size: 1Gi - accessMode: ReadWriteOnce +valkey: + dataStorage: + enabled: false # ephemeral storage; stats lost on restart ``` -When `enabled: true`, the chart creates a PVC named `server-stats` and mounts it at `/data/stats/` in the server deployment, passing `--stats-db-path=/data/stats/stats.db` automatically. - -!!! warning "Single replica only" - The stats PVC uses `ReadWriteOnce`. Do not increase `server.replicaCount` above `1` when stats persistence is enabled, as multiple replicas cannot share a `ReadWriteOnce` volume. +For production, leave `valkey.dataStorage.enabled: true` (the default). See the [Valkey Stats Store](../getting-started/installation.md#valkey-stats-store) Helm values reference for the full set of options. Existing server-only deployments require no changes until you set `ui.enabled: true`. When you are ready to enable the UI: diff --git a/docs/helm-chart.md b/docs/helm-chart.md index fccbcb9..5ba9a45 100644 --- a/docs/helm-chart.md +++ b/docs/helm-chart.md @@ -129,18 +129,30 @@ The `ui` section deploys the Registry Explorer frontend. See [Registry Explorer | `ui.ingress.hosts` | list | Host and path rules. | | `ui.ingress.tls` | list | TLS configuration for the Ingress. | -## Server Stats Persistence +## Valkey Stats Store -Controls the embedded SQLite database used to persist download events. When disabled (default), events are tracked in memory and lost on restart. +Download statistics are persisted in a bundled [Valkey](https://valkey.io/) (Redis-compatible) instance deployed automatically alongside the server. No additional setup is required — Valkey is always deployed as part of the chart. | Value | Type | Description | |-------|------|-------------| -| `server.stats.persistence.enabled` | bool | Create a PVC for the stats SQLite database. Default: `false` | -| `server.stats.persistence.storageClassName` | string | StorageClass for the PVC. Leave blank for the cluster default. Default: `""` | -| `server.stats.persistence.size` | string | PVC size. Default: `1Gi` | -| `server.stats.persistence.accessMode` | string | PVC access mode. Default: `ReadWriteOnce` | - -When `enabled: true`, the chart creates a PVC named `server-stats`, mounts it at `/data/stats/`, and passes `--stats-db-path=/data/stats/stats.db` to the server. See [Enabling download tracking](guides/registry-explorer.md#enabling-download-tracking) for details. +| `valkey.resources` | map | Resource requests and limits for the Valkey pod | +| `valkey.dataStorage.enabled` | bool | Create a PVC for Valkey data. Default: `true` | +| `valkey.dataStorage.className` | string | StorageClass for the PVC. Leave blank for the cluster default. Default: `""` | +| `valkey.dataStorage.requestedSize` | string | PVC storage size. Default: `1Gi` | +| `valkey.auth.enabled` | bool | Enable Valkey ACL password authentication. Default: `false` | +| `valkey.auth.usersExistingSecret` | string | Name of a pre-existing Secret whose keys are ACL usernames and values are plaintext passwords. Required when `valkey.auth.enabled: true`. Default: `""` | +| `valkey.auth.aclUsers.default.permissions` | string | ACL permissions string for the default user. The default is scoped to `stats:*` keys and the exact commands used by the server (e.g. `~stats:* &* -@all +HSET +HINCRBY +HGET +HGETALL +INCR +GET +ZINCRBY +ZREVRANGEBYSCORE +ZREVRANGE +EXPIREAT`). Do not widen to `+@all` in production. | +| `server.stats.valkeyPasswordSecretName` | string | Name of the Secret injected as `OPENDEPOT_VALKEY_PASSWORD` into the server pod. Must match `valkey.auth.usersExistingSecret` when auth is enabled. Default: `""` | +| `valkey.nodeSelector` | map | Node selector for the Valkey pod | +| `valkey.tolerations` | list | Tolerations for the Valkey pod | +| `valkey.affinity` | map | Affinity rules for the Valkey pod | + +When `valkey.dataStorage.enabled: true` (the default), a PVC is created and mounted at `/data` in the Valkey pod. Set `valkey.dataStorage.enabled: false` to use ephemeral in-pod storage — suitable for local development or Kind clusters where no StorageClass is available. Stats are lost on pod restart when persistence is disabled. + +!!! warning "Production Security" + Valkey ACL authentication is **disabled by default**. For production deployments, create a Kubernetes Secret containing the password, then configure `valkey.auth.enabled: true`, `valkey.auth.usersExistingSecret`, and `server.stats.valkeyPasswordSecretName` to point at it. For regulated environments, use [External Secrets Operator](https://external-secrets.io/) or HashiCorp Vault to provision the Secret rather than storing the password in `values.yaml`. + +See [Download Tracking](guides/registry-explorer.md#download-tracking) for details on how stats are recorded and surfaced in the Registry Explorer UI. ## Scanning Values diff --git a/docs/reference/api.md b/docs/reference/api.md index 7536eab..77060d6 100644 --- a/docs/reference/api.md +++ b/docs/reference/api.md @@ -425,7 +425,7 @@ Returns a paginated, filtered list of versions for a single resource. Used by th `availableOS` and `availableArch` are populated from the full (pre-filter) version set so filter dropdowns remain populated while a filter is active. Both fields are omitted for modules; they are only present for providers. Versions are sorted newest-first. -`downloadCount` and `lastDownloadedAt` are omitted when no downloads have been recorded (stats DB is nil or no events exist). `archiveSizeBytes` is omitted when `VersionStatus.archiveSizeBytes` has not been set by the version controller. +`downloadCount` and `lastDownloadedAt` are omitted when no downloads have been recorded. `archiveSizeBytes` is omitted when `VersionStatus.archiveSizeBytes` has not been set by the version controller. ### Resource Scan Findings @@ -630,7 +630,7 @@ Returns aggregate registry statistics as JSON. All counts are scoped to the reso } ``` -`totalStorageBytes` is the sum of `VersionStatus.archiveSizeBytes` across all visible versions; it is `0` when no archive sizes have been recorded. `totalDownloads` and `mostDownloaded` require a persistent stats DB (`--stats-db-path`); both are `0` / empty when the flag is not set. Download counts are cross-referenced against the caller's visibility set — private resource names do not appear in `mostDownloaded` for unauthenticated callers. +`totalStorageBytes` is the sum of `VersionStatus.archiveSizeBytes` across all visible versions; it is `0` when no archive sizes have been recorded. `totalDownloads` and `mostDownloaded` are sourced from the bundled Valkey stats store; both are `0` / empty until at least one download has been recorded. Download counts are cross-referenced against the caller's visibility set — private resource names do not appear in `mostDownloaded` for unauthenticated callers. ## Kubernetes Resource Types @@ -731,7 +731,7 @@ Returned by `GET /opendepot/ui/v1/stats`. | `totalProviders` | `int` | Number of visible `Provider` resources | | `totalVersions` | `int` | Total number of `Version` resources across all visible modules and providers | | `totalStorageBytes` | `int64` | Sum of `VersionStatus.archiveSizeBytes` across all visible versions; `0` when no archive sizes have been recorded | -| `totalDownloads` | `int64` | Cumulative download events recorded in the stats DB; `0` when the stats DB is not configured | +| `totalDownloads` | `int64` | Cumulative download events recorded in Valkey; `0` until at least one download has been recorded | | `syncHealth` | `SyncHealthStats` | Breakdown of version sync states | | `securityPosture` | `SecurityPostureStats` | Aggregate finding counts across all visible resources | | `storageDistribution` | `[]StorageBackendStat` | Per-backend version counts | diff --git a/go.work.sum b/go.work.sum index 82e344b..48f7dc0 100644 --- a/go.work.sum +++ b/go.work.sum @@ -166,8 +166,10 @@ github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B github.com/charmbracelet/x/ansi v0.9.2/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= github.com/chzyer/logex v1.2.1/go.mod h1:JLbx6lG2kDbNRFnfkgvh4eRJRPX1QCoOIWomwysCBrQ= github.com/chzyer/readline v1.5.1/go.mod h1:Eh+b79XXUwfKfcPLepksvw2tcLE/Ct21YObkaSkeBlk= +github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8= github.com/cncf/xds/go v0.0.0-20251110193048-8bfbf64dc13e/go.mod h1:KdCmV+x/BuvyMxRnYBlmVaq4OLiKW6iRQfvC62cvdkI= github.com/containerd/stargz-snapshotter/estargz v0.14.3/go.mod h1:KY//uOCIkSuNAHhJogcZtrNHdKrA99/FCCRjE3HD36o= @@ -179,6 +181,8 @@ github.com/dimchansky/utfbom v1.1.1/go.mod h1:SxdoEBH5qIqFocHMyGOXVAybYJdr71b1Q/ github.com/docker/cli v27.1.1+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= github.com/docker/distribution v2.8.2+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w= github.com/docker/docker-credential-helpers v0.7.0/go.mod h1:rETQfLdHNT3foU5kuNkFR1R1V12OJRRO5lzt2D1b5X0= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/emicklei/go-restful/v3 v3.11.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= github.com/envoyproxy/protoc-gen-validate v1.3.0/go.mod h1:HvYl7zwPa5mffgyeTUHA9zHIH36nmrm7oCbo4YKoSWA= github.com/form3tech-oss/jwt-go v3.2.2+incompatible/go.mod h1:pbq4aXjuKjdthFRnoDwaVPLA+WlJuPGy+QneDUgJi2k= diff --git a/overrides/main.html b/overrides/main.html index 4e4a4e2..6ebf535 100644 --- a/overrides/main.html +++ b/overrides/main.html @@ -1,7 +1,7 @@ {% extends "base.html" %} {% block announce %} - 🚀 OpenDepot v0.5.0 is now available — + 🚀 OpenDepot v0.6.0 is now available — Release notes  → diff --git a/services/depot/go.mod b/services/depot/go.mod index c4ff806..7d1a93e 100644 --- a/services/depot/go.mod +++ b/services/depot/go.mod @@ -10,6 +10,7 @@ require ( github.com/onsi/gomega v1.40.0 github.com/tonedefdev/opendepot/api/v1alpha1 v0.2.7 github.com/tonedefdev/opendepot/pkg/github v0.2.7 + github.com/tonedefdev/opendepot/pkg/registry v0.0.0-20260531075737-3500a5f0c756 github.com/tonedefdev/opendepot/pkg/testutils v0.2.7 k8s.io/apimachinery v0.35.4 k8s.io/client-go v0.35.0 diff --git a/services/depot/go.sum b/services/depot/go.sum index 0490ab0..754566b 100644 --- a/services/depot/go.sum +++ b/services/depot/go.sum @@ -1,4 +1,5 @@ cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4= +cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/antlr4-go/antlr/v4 v4.13.0 h1:lxCg3LAv+EUK6t1i0y1V6/SLeUi0eKEKdhQAlS8TVTI= @@ -55,6 +56,7 @@ github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZ github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= @@ -74,9 +76,11 @@ github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/ github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/pprof v0.0.0-20260402051712-545e8a4df936 h1:EwtI+Al+DeppwYX2oXJCETMO23COyaKGP6fHVpkpWpg= +github.com/google/pprof v0.0.0-20260402051712-545e8a4df936/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 h1:8Tjv8EJ+pM1xP8mK6egEbD1OgnVTyacbefKhmbLhIhU= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2/go.mod h1:pkJQ2tZHJ0aFOVEEot6oZmaVEZcRme73eIFmhiVuRWs= github.com/hashicorp/go-version v1.8.0 h1:KAkNb1HAiZd1ukkxDFGmokVZe1Xy9HG6NUp+bPle2i4= github.com/hashicorp/go-version v1.8.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= @@ -113,7 +117,9 @@ github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWu github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/onsi/ginkgo/v2 v2.28.3 h1:4JvMdwtFU0imd8fHx25OJXoDMRexnf8v5NHKYSTTji4= +github.com/onsi/ginkgo/v2 v2.28.3/go.mod h1:+aXOY+vzZ5mu2iI2HpTZUPmM//oQfsNFX6gU9kNcA44= github.com/onsi/gomega v1.40.0 h1:Vtol0e1MghCD2ZVIilPDIg44XSL9l2QAn8ZNaljWcJc= +github.com/onsi/gomega v1.40.0/go.mod h1:M/Uqpu/8qTjtzCLUA2zJHX9Iilrau25x1PdoSRbWh5A= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -157,8 +163,13 @@ github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhso github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= github.com/tonedefdev/opendepot/api/v1alpha1 v0.2.7 h1:RsSO5aKpbhEE/qNXhLlLl7zae58drAOVPqKo8ydx8IE= +github.com/tonedefdev/opendepot/api/v1alpha1 v0.2.7/go.mod h1:fAkqWqqNWN+RJmgYh75DCQ/TIOJIGvlSECxV+GCKoxc= github.com/tonedefdev/opendepot/pkg/github v0.2.7 h1:ufVqDW/otxkKc0dRJQJAn+Q2P2LHluvyrOrveYr7u2Q= +github.com/tonedefdev/opendepot/pkg/github v0.2.7/go.mod h1:zHEDMTMptGOsdRWdqcUlxbEFgFw4CBEQzuhGgnVYzcw= +github.com/tonedefdev/opendepot/pkg/registry v0.0.0-20260531075737-3500a5f0c756 h1:TxLv5mxDVWwKAlO0wHNTZ3wnK3nzvmo9WiDNlxeVNC0= +github.com/tonedefdev/opendepot/pkg/registry v0.0.0-20260531075737-3500a5f0c756/go.mod h1:XlyJuLAVbeL0cT1OkJp2GkBDWKN4B6QM6VtFPyhYdQs= github.com/tonedefdev/opendepot/pkg/testutils v0.2.7 h1:zxeFJyrTm4puMYkrYlv8qv+z3OUSGFXN+xg+0H3Q0BU= +github.com/tonedefdev/opendepot/pkg/testutils v0.2.7/go.mod h1:KwZTzQ1c7pG08EOTKNf/ZN5vxmzXcGoC1EYS4SC4Wg8= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= @@ -166,15 +177,21 @@ go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbE go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q= go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= +go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.34.0 h1:OeNbIYk/2C15ckl7glBlOBp5+WlYsOElzTNmiPW/x60= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.34.0/go.mod h1:7Bept48yIeqxP2OZ9/AqIpYS94h2or0aB4FypJTc8ZM= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.34.0 h1:tgJ0uaNS4c98WRNUEx5U3aDlrDOI5Rs+1Vifcw4DJ8U= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.34.0/go.mod h1:U7HYyW0zt/a9x5J1Kjs+r1f/d4ZHnYFclhYY2+YbeoE= go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= +go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= +go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= +go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= +go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A= +go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= @@ -188,23 +205,34 @@ go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8= golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= +golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= +golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY= +golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY= golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= +golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= +golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gomodules.xyz/jsonpatch/v2 v2.4.0 h1:Ci3iUJyx9UeRx7CeFN8ARgGbkESwJK+KB9lLcWxY/Zw= gomodules.xyz/jsonpatch/v2 v2.4.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171 h1:tu/dtnW1o3wfaxCOjSLn5IRX4YDcJrtlpzYkhHhGaC4= +google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171/go.mod h1:M5krXqk4GhBKvB596udGL3UyjL4I1+cTbK0orROM9ng= google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.81.0 h1:W3G9N3KQf3BU+YuCtGKJk0CmxQNbAISICD/9AORxLIw= +google.golang.org/grpc v1.81.0/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/services/server/go.mod b/services/server/go.mod index 7286e20..13d8581 100644 --- a/services/server/go.mod +++ b/services/server/go.mod @@ -3,34 +3,30 @@ module github.com/tonedefdev/opendepot/services/server go 1.25.5 require ( + github.com/alicebob/miniredis/v2 v2.38.0 github.com/aws/aws-sdk-go-v2 v1.41.7 github.com/coreos/go-oidc/v3 v3.18.0 github.com/expr-lang/expr v1.17.8 github.com/go-chi/chi/v5 v5.2.5 github.com/onsi/ginkgo/v2 v2.28.3 github.com/onsi/gomega v1.40.0 + github.com/redis/go-redis/v9 v9.20.0 github.com/tonedefdev/opendepot/api/v1alpha1 v0.2.7 github.com/tonedefdev/opendepot/pkg/storage v0.2.7 github.com/tonedefdev/opendepot/pkg/testutils v0.2.7 github.com/tonedefdev/opendepot/pkg/utils v0.2.7 k8s.io/client-go v0.35.0 - modernc.org/sqlite v1.50.1 ) require ( github.com/Masterminds/semver/v3 v3.4.0 // indirect - github.com/dustin/go-humanize v1.0.1 // indirect github.com/go-task/slim-sprig/v3 v3.0.0 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/pprof v0.0.0-20260402051712-545e8a4df936 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect - github.com/ncruces/go-strftime v1.0.0 // indirect - github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/yuin/gopher-lua v1.1.1 // indirect + go.uber.org/atomic v1.11.0 // indirect golang.org/x/mod v0.35.0 // indirect golang.org/x/tools v0.44.0 // indirect - modernc.org/libc v1.72.3 // indirect - modernc.org/mathutil v1.7.1 // indirect - modernc.org/memory v1.11.0 // indirect ) require ( diff --git a/services/server/go.sum b/services/server/go.sum index 50e2b6e..df181bf 100644 --- a/services/server/go.sum +++ b/services/server/go.sum @@ -52,6 +52,8 @@ github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapp github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.54.0/go.mod h1:Mf6O40IAyB9zR/1J8nGDDPirZQQPbYJni8Yisy7NTMc= github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/alicebob/miniredis/v2 v2.38.0 h1:nZAzCR+Lj+Vxk4ZXzm2NuKq2O33RXj1XxJ2e2uP9jiw= +github.com/alicebob/miniredis/v2 v2.38.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM= github.com/aws/aws-sdk-go-v2 v1.41.7 h1:DWpAJt66FmnnaRIOT/8ASTucrvuDPZASqhhLey6tLY8= github.com/aws/aws-sdk-go-v2 v1.41.7/go.mod h1:4LAfZOPHNVNQEckOACQx60Y8pSRjIkNZQz1w92xpMJc= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 h1:eBMB84YGghSocM7PsjmmPffTa+1FBUeNvGvFou6V/4o= @@ -90,6 +92,10 @@ github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 h1:5fFjR/ToSOzB2OQ/XqWpZBmNvmP/ github.com/aws/aws-sdk-go-v2/service/sts v1.41.6/go.mod h1:qgFDZQSD/Kys7nJnVqYlWKnh0SSdMjAi0uSwON4wgYQ= github.com/aws/smithy-go v1.25.1 h1:J8ERsGSU7d+aCmdQur5Txg6bVoYelvQJgtZehD12GkI= github.com/aws/smithy-go v1.25.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= +github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= +github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= +github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 h1:aBangftG7EVZoUb69Os8IaYg++6uMOdKK83QtkkvJik= @@ -101,8 +107,6 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= -github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU= github.com/emicklei/go-restful/v3 v3.12.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA= @@ -167,8 +171,6 @@ github.com/googleapis/enterprise-certificate-proxy v0.3.11 h1:vAe81Msw+8tKUxi2Dq github.com/googleapis/enterprise-certificate-proxy v0.3.11/go.mod h1:RFV7MUdlb7AgEq2v7FmMCfeSMCllAzWxFgRdusoGks8= github.com/googleapis/gax-go/v2 v2.16.0 h1:iHbQmKLLZrexmb0OSsNGTeSTS0HO4YvFOG8g5E4Zd0Y= github.com/googleapis/gax-go/v2 v2.16.0/go.mod h1:o1vfQjjNZn4+dPnRdl/4ZD7S9414Y4xA+a/6Icj6l14= -github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= -github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/joshdk/go-junit v1.0.0 h1:S86cUKIdwBHWwA6xCmFlf3RTLfVXYQfvanM5Uh+K6GE= @@ -177,6 +179,8 @@ github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnr github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRtuthU= github.com/keybase/go-keychain v0.0.1/go.mod h1:PdEILRW3i9D8JcdM+FmY6RwkHGnhHxXwkPPMeUgOK1k= +github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= +github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= @@ -190,8 +194,6 @@ github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0 github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/maruel/natural v1.1.1 h1:Hja7XhhmvEFhcByqDoHz9QZbkWey+COd9xWfCfn1ioo= github.com/maruel/natural v1.1.1/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mfridman/tparse v0.18.0 h1:wh6dzOKaIwkUGyKgOntDW4liXSo37qg5AXbIhkMV3vE= github.com/mfridman/tparse v0.18.0/go.mod h1:gEvqZTuCgEhPbYk/2lS3Kcxg1GmTxxU7kTC8DvP0i/A= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -202,8 +204,6 @@ github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFd github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= -github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= -github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/onsi/ginkgo/v2 v2.28.3 h1:4JvMdwtFU0imd8fHx25OJXoDMRexnf8v5NHKYSTTji4= github.com/onsi/ginkgo/v2 v2.28.3/go.mod h1:+aXOY+vzZ5mu2iI2HpTZUPmM//oQfsNFX6gU9kNcA44= github.com/onsi/gomega v1.40.0 h1:Vtol0e1MghCD2ZVIilPDIg44XSL9l2QAn8ZNaljWcJc= @@ -215,8 +215,8 @@ github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= -github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/redis/go-redis/v9 v9.20.0 h1:WnQYxLkgO2xiXTCJY0ldIiI8dNqCDlQAG+AtaH7a2a0= +github.com/redis/go-redis/v9 v9.20.0/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= @@ -252,6 +252,10 @@ github.com/tonedefdev/opendepot/pkg/utils v0.2.7 h1:rwUmwD4m+XqgY9Y9Zfuzpi5UURsb github.com/tonedefdev/opendepot/pkg/utils v0.2.7/go.mod h1:S2Cp2AKUwLnhfwMQ8Tpy3Qq4qpjuWcQIBcNLQNqpE54= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M= +github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw= +github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= +github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/contrib/detectors/gcp v1.42.0 h1:kpt2PEJuOuqYkPcktfJqWWDjTEd/FNgrxcniL7kQrXQ= @@ -272,6 +276,8 @@ go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfC go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= +go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= +go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= @@ -287,7 +293,6 @@ golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7 golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY= @@ -334,34 +339,6 @@ k8s.io/kube-openapi v0.0.0-20260127142750-a19766b6e2d4 h1:HhDfevmPS+OalTjQRKbTHp k8s.io/kube-openapi v0.0.0-20260127142750-a19766b6e2d4/go.mod h1:kdmbQkyfwUagLfXIad1y2TdrjPFWp2Q89B3qkRwf/pQ= k8s.io/utils v0.0.0-20260108192941-914a6e750570 h1:JT4W8lsdrGENg9W+YwwdLJxklIuKWdRm+BC+xt33FOY= k8s.io/utils v0.0.0-20260108192941-914a6e750570/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk= -modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY= -modernc.org/cc/v4 v4.28.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= -modernc.org/ccgo/v4 v4.34.0 h1:yRLPFZieg532OT4rp4JFNIVcquwalMX26G95WQDqwCQ= -modernc.org/ccgo/v4 v4.34.0/go.mod h1:AS5WYMyBakQ+fhsHhtP8mWB82KTGPkNNJDGfGQCe0/A= -modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= -modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= -modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= -modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= -modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo= -modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= -modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= -modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= -modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU= -modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs= -modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= -modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= -modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= -modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= -modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= -modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= -modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= -modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= -modernc.org/sqlite v1.50.1 h1:l+cQvn0sd0zJJtfygGHuQJ5AjlrwXmWPw4KP3ZMwr9w= -modernc.org/sqlite v1.50.1/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM= -modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= -modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= -modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= -modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= sigs.k8s.io/controller-runtime v0.23.3 h1:VjB/vhoPoA9l1kEKZHBMnQF33tdCLQKJtydy4iqwZ80= sigs.k8s.io/controller-runtime v0.23.3/go.mod h1:B6COOxKptp+YaUT5q4l6LqUJTRpizbgf9KSRNdQGns0= sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg= diff --git a/services/server/main.go b/services/server/main.go index 4a79ba0..d123e3e 100644 --- a/services/server/main.go +++ b/services/server/main.go @@ -2,16 +2,17 @@ package main import ( "context" - "database/sql" "flag" "log/slog" "net/http" "net/url" "os" + "time" gooidc "github.com/coreos/go-oidc/v3/oidc" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" + "github.com/redis/go-redis/v9" ) var ( @@ -29,9 +30,9 @@ var ( opendepotServerNamespace *string opendepotFilesystemMountPath *string - // statsDB is the optional SQLite database used to track download events. - // It is nil when --stats-db-path is empty (stats tracking disabled). - statsDB *sql.DB + // statsClient is the Valkey/Redis client used to track download events. + // Stats tracking is always enabled; the server will not start if Valkey is unreachable. + statsClient *redis.Client // oidcVerifier is set at startup when --oidc-issuer-url and --oidc-client-id // are both provided. It is used to validate OIDC JWTs on every request. @@ -68,21 +69,39 @@ func main() { opendepotOIDCTokenURL = flag.String("oidc-token-url", "", "override the token URL advertised in /.well-known/terraform.json login.v1; when blank uses the token URL from the OIDC provider discovery document") opendepotServerNamespace = flag.String("namespace", "opendepot-system", "namespace where GroupBinding resources are managed") opendepotFilesystemMountPath = flag.String("filesystem-mount-path", "/data/modules", "allowed root path for filesystem module storage; download requests for paths outside this prefix are rejected") - opendepotStatsDBPath := flag.String("stats-db-path", "", "path to SQLite stats database file; when empty download tracking is disabled") + opendepotValkeyAddr := flag.String("stats-valkey-addr", "valkey:6379", "address of the Valkey/Redis instance used for download stats tracking") opendepotCertPath := flag.String("tls-cert-path", "", "path to TLS certificate file for HTTPS server") opendepotCertKey := flag.String("tls-cert-key", "", "path to TLS certificate key file for HTTPS server") flag.Parse() - if *opendepotStatsDBPath != "" { - db, err := initStatsDB(*opendepotStatsDBPath) - if err != nil { - logger.Error("failed to initialise stats database", "path", *opendepotStatsDBPath, "error", err) - os.Exit(1) + client := redis.NewClient(&redis.Options{ + Addr: *opendepotValkeyAddr, + Password: os.Getenv("OPENDEPOT_VALKEY_PASSWORD"), + }) + + const maxAttempts = 10 + var pingErr error + for i := range maxAttempts { + pingCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + pingErr = client.Ping(pingCtx).Err() + cancel() + + if pingErr == nil { + break } - statsDB = db - logger.Info("stats tracking enabled", "path", *opendepotStatsDBPath) + + logger.Warn("stats: waiting for Valkey", "addr", *opendepotValkeyAddr, "attempt", i+1, "error", pingErr) + time.Sleep(3 * time.Second) } + if pingErr != nil { + logger.Error("stats: failed to connect to Valkey", "addr", *opendepotValkeyAddr, "error", pingErr) + os.Exit(1) + } + + statsClient = client + logger.Info("stats tracking enabled", "addr", *opendepotValkeyAddr) + if (*opendepotOIDCIssuerURL == "") != (*opendepotOIDCClientID == "") { logger.Error("--oidc-issuer-url and --oidc-client-id must both be set or both be empty") os.Exit(1) diff --git a/services/server/modules.go b/services/server/modules.go index 9daedab..e3ff98a 100644 --- a/services/server/modules.go +++ b/services/server/modules.go @@ -146,7 +146,7 @@ func getDownloadModuleUrl(w http.ResponseWriter, r *http.Request) { // serveModuleFrom* handlers because the Terraform module protocol requires clients // to call this endpoint first; namespace and version are only available here. // The serveModuleFrom* routes do not carry namespace or version URL params. - _ = recordDownload(r.Context(), statsDB, chi.URLParam(r, "namespace"), "module", chi.URLParam(r, "name"), chi.URLParam(r, "version")) + _ = recordDownload(r.Context(), statsClient, chi.URLParam(r, "namespace"), "module", chi.URLParam(r, "name"), chi.URLParam(r, "version")) w.WriteHeader(http.StatusNoContent) } diff --git a/services/server/providers.go b/services/server/providers.go index 981bb8b..b9936bb 100644 --- a/services/server/providers.go +++ b/services/server/providers.go @@ -341,7 +341,7 @@ func serveProviderPackageDownload(w http.ResponseWriter, r *http.Request) { } logger.Info("failed to init storage backend for presign, falling back to proxy", "error", initErr) } else if presignErr := storageBackend.PresignObject(r.Context(), soi); presignErr == nil { - _ = recordDownload(r.Context(), statsDB, namespace, "provider", providerType, requestedVersion) + _ = recordDownload(r.Context(), statsClient, namespace, "provider", providerType, requestedVersion) http.Redirect(w, r, *soi.PresignedURL, http.StatusTemporaryRedirect) return } else { @@ -364,7 +364,7 @@ func serveProviderPackageDownload(w http.ResponseWriter, r *http.Request) { } checksumQuery := url.QueryEscape(*versionResource.Status.Checksum) - _ = recordDownload(r.Context(), statsDB, namespace, "provider", providerType, requestedVersion) + _ = recordDownload(r.Context(), statsClient, namespace, "provider", providerType, requestedVersion) http.Redirect(w, r, fmt.Sprintf("/opendepot/modules/v1/download/%s?fileChecksum=%s", downloadPath, checksumQuery), http.StatusFound) } diff --git a/services/server/stats_db.go b/services/server/stats_db.go deleted file mode 100644 index 148ebde..0000000 --- a/services/server/stats_db.go +++ /dev/null @@ -1,316 +0,0 @@ -/* -Copyright 2026 Tony Owens. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -package main - -import ( - "context" - "database/sql" - "fmt" - - _ "modernc.org/sqlite" -) - -const createStatsSchema = ` -CREATE TABLE IF NOT EXISTS download_events ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - namespace TEXT NOT NULL, - kind TEXT NOT NULL, - name TEXT NOT NULL, - version TEXT NOT NULL, - downloaded_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) -); -CREATE INDEX IF NOT EXISTS idx_dl_lookup - ON download_events(namespace, kind, name, version); -CREATE INDEX IF NOT EXISTS idx_dl_namespace - ON download_events(namespace); -` - -// initStatsDB opens (or creates) the SQLite stats database at the given path, -// enables WAL mode for concurrent reads, and creates the schema. -func initStatsDB(path string) (*sql.DB, error) { - db, err := sql.Open("sqlite", path) - if err != nil { - return nil, fmt.Errorf("stats_db: open: %w", err) - } - - // WAL mode allows multiple concurrent readers alongside a single writer. - if _, err := db.Exec("PRAGMA journal_mode=WAL"); err != nil { - db.Close() - return nil, fmt.Errorf("stats_db: WAL pragma: %w", err) - } - - if _, err := db.Exec("PRAGMA synchronous=NORMAL"); err != nil { - db.Close() - return nil, fmt.Errorf("stats_db: synchronous pragma: %w", err) - } - - if _, err := db.Exec(createStatsSchema); err != nil { - db.Close() - return nil, fmt.Errorf("stats_db: create schema: %w", err) - } - - return db, nil -} - -// recordDownload records a download event for the given resource. It is a -// no-op when db is nil (stats tracking disabled). -func recordDownload(ctx context.Context, db *sql.DB, namespace, kind, name, version string) error { - if db == nil { - return nil - } - - _, err := db.ExecContext(ctx, - `INSERT INTO download_events (namespace, kind, name, version) VALUES (?, ?, ?, ?)`, - namespace, kind, name, version, - ) - if err != nil { - return fmt.Errorf("stats_db: record download: %w", err) - } - - return nil -} - -// queryVersionDownloads returns the total download count and the RFC3339 timestamp -// of the most recent download for the given resource version. Returns zero values -// when db is nil. -func queryVersionDownloads(ctx context.Context, db *sql.DB, namespace, kind, name, version string) (count int64, lastAt string, err error) { - if db == nil { - return 0, "", nil - } - - row := db.QueryRowContext(ctx, - `SELECT COUNT(*), COALESCE(MAX(downloaded_at), '') - FROM download_events - WHERE namespace = ? AND kind = ? AND name = ? AND version = ?`, - namespace, kind, name, version, - ) - - if err := row.Scan(&count, &lastAt); err != nil { - return 0, "", fmt.Errorf("stats_db: query version downloads: %w", err) - } - - return count, lastAt, nil -} - -// queryResourceDownloads returns the total download count and most recent -// download timestamp for all versions of a resource. Returns zero values when -// db is nil. -func queryResourceDownloads(ctx context.Context, db *sql.DB, namespace, kind, name string) (count int64, lastAt string, err error) { - if db == nil { - return 0, "", nil - } - - row := db.QueryRowContext(ctx, - `SELECT COUNT(*), COALESCE(MAX(downloaded_at), '') - FROM download_events - WHERE namespace = ? AND kind = ? AND name = ?`, - namespace, kind, name, - ) - - if err := row.Scan(&count, &lastAt); err != nil { - return 0, "", fmt.Errorf("stats_db: query resource downloads: %w", err) - } - - return count, lastAt, nil -} - -// queryTotalDownloads returns the total number of recorded download events, -// optionally scoped to a namespace. When namespace is empty all namespaces are -// counted. Returns 0 when db is nil. -func queryTotalDownloads(ctx context.Context, db *sql.DB, namespace string) (int64, error) { - if db == nil { - return 0, nil - } - - var count int64 - var row *sql.Row - if namespace == "" { - row = db.QueryRowContext(ctx, `SELECT COUNT(*) FROM download_events`) - } else { - row = db.QueryRowContext(ctx, `SELECT COUNT(*) FROM download_events WHERE namespace = ?`, namespace) - } - - if err := row.Scan(&count); err != nil { - return 0, fmt.Errorf("stats_db: query total downloads: %w", err) - } - - return count, nil -} - -// queryMostDownloaded returns the top `limit` most-downloaded resources, -// optionally scoped to a namespace. Returns nil when db is nil. -func queryMostDownloaded(ctx context.Context, db *sql.DB, namespace string, limit int) ([]PopularResource, error) { - if db == nil { - return nil, nil - } - - var ( - rows *sql.Rows - err error - ) - - if namespace == "" { - rows, err = db.QueryContext(ctx, - `SELECT namespace, kind, name, version, COUNT(*) AS cnt, COALESCE(MAX(downloaded_at), '') - FROM download_events - GROUP BY namespace, kind, name, version - ORDER BY cnt DESC - LIMIT ?`, - limit, - ) - } else { - rows, err = db.QueryContext(ctx, - `SELECT namespace, kind, name, version, COUNT(*) AS cnt, COALESCE(MAX(downloaded_at), '') - FROM download_events - WHERE namespace = ? - GROUP BY namespace, kind, name, version - ORDER BY cnt DESC - LIMIT ?`, - namespace, limit, - ) - } - - if err != nil { - return nil, fmt.Errorf("stats_db: query most downloaded: %w", err) - } - defer rows.Close() - - var result []PopularResource - for rows.Next() { - var r PopularResource - if err := rows.Scan(&r.Namespace, &r.Kind, &r.Name, &r.Version, &r.DownloadCount, &r.LastDownloadedAt); err != nil { - return nil, fmt.Errorf("stats_db: scan most downloaded row: %w", err) - } - result = append(result, r) - } - - if err := rows.Err(); err != nil { - return nil, fmt.Errorf("stats_db: rows error: %w", err) - } - - return result, nil -} - -// queryAllResourceDownloadStats returns a map of "namespace/kind/name" to -// aggregated download stats for all resources, optionally scoped to a -// namespace. Returns nil when db is nil. -func queryAllResourceDownloadStats(ctx context.Context, db *sql.DB, namespace string) (map[string]resourceDownloadStats, error) { - if db == nil { - return nil, nil - } - - var ( - rows *sql.Rows - err error - ) - - if namespace == "" { - rows, err = db.QueryContext(ctx, - `SELECT namespace, kind, name, COUNT(*) AS cnt, COALESCE(MAX(downloaded_at), '') - FROM download_events - GROUP BY namespace, kind, name`, - ) - } else { - rows, err = db.QueryContext(ctx, - `SELECT namespace, kind, name, COUNT(*) AS cnt, COALESCE(MAX(downloaded_at), '') - FROM download_events - WHERE namespace = ? - GROUP BY namespace, kind, name`, - namespace, - ) - } - - if err != nil { - return nil, fmt.Errorf("stats_db: query all resource download stats: %w", err) - } - defer rows.Close() - - result := make(map[string]resourceDownloadStats) - for rows.Next() { - var ns, kind, name, lastAt string - var cnt int64 - if err := rows.Scan(&ns, &kind, &name, &cnt, &lastAt); err != nil { - return nil, fmt.Errorf("stats_db: scan resource download stats row: %w", err) - } - key := ns + "/" + kind + "/" + name - result[key] = resourceDownloadStats{Count: cnt, LastAt: lastAt} - } - - if err := rows.Err(); err != nil { - return nil, fmt.Errorf("stats_db: rows error: %w", err) - } - - return result, nil -} - -// queryAllVersionDownloadStats returns a map of "namespace/kind/name/version" -// to aggregated download stats, optionally scoped to a namespace. Returns nil -// when db is nil. -func queryAllVersionDownloadStats(ctx context.Context, db *sql.DB, namespace string) (map[string]resourceDownloadStats, error) { - if db == nil { - return nil, nil - } - - var ( - rows *sql.Rows - err error - ) - - if namespace == "" { - rows, err = db.QueryContext(ctx, - `SELECT namespace, kind, name, version, COUNT(*) AS cnt, COALESCE(MAX(downloaded_at), '') - FROM download_events - GROUP BY namespace, kind, name, version`, - ) - } else { - rows, err = db.QueryContext(ctx, - `SELECT namespace, kind, name, version, COUNT(*) AS cnt, COALESCE(MAX(downloaded_at), '') - FROM download_events - WHERE namespace = ? - GROUP BY namespace, kind, name, version`, - namespace, - ) - } - - if err != nil { - return nil, fmt.Errorf("stats_db: query all version download stats: %w", err) - } - defer rows.Close() - - result := make(map[string]resourceDownloadStats) - for rows.Next() { - var ns, kind, name, version, lastAt string - var cnt int64 - if err := rows.Scan(&ns, &kind, &name, &version, &cnt, &lastAt); err != nil { - return nil, fmt.Errorf("stats_db: scan version download stats row: %w", err) - } - key := ns + "/" + kind + "/" + name + "/" + version - result[key] = resourceDownloadStats{Count: cnt, LastAt: lastAt} - } - - if err := rows.Err(); err != nil { - return nil, fmt.Errorf("stats_db: rows error: %w", err) - } - - return result, nil -} - -// resourceDownloadStats holds aggregate download data for a single resource or version. -type resourceDownloadStats struct { - Count int64 - LastAt string -} diff --git a/services/server/stats_db_test.go b/services/server/stats_db_test.go deleted file mode 100644 index b407ee9..0000000 --- a/services/server/stats_db_test.go +++ /dev/null @@ -1,126 +0,0 @@ -/* -Copyright 2026 Tony Owens. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -package main - -import ( - "context" - "os" - "testing" -) - -func TestRecordDownloadAndQueryCount(t *testing.T) { - f, err := os.CreateTemp("", "stats_test_*.db") - if err != nil { - t.Fatalf("failed to create temp db file: %v", err) - } - f.Close() - defer os.Remove(f.Name()) - - db, err := initStatsDB(f.Name()) - if err != nil { - t.Fatalf("initStatsDB: %v", err) - } - defer db.Close() - - ctx := context.Background() - - // Record three downloads: two for the same version, one for another. - for i := 0; i < 2; i++ { - if err := recordDownload(ctx, db, "test-ns", "module", "my-module", "1.0.0"); err != nil { - t.Fatalf("recordDownload: %v", err) - } - } - if err := recordDownload(ctx, db, "test-ns", "module", "my-module", "2.0.0"); err != nil { - t.Fatalf("recordDownload: %v", err) - } - - // Query per-version count. - count, lastAt, err := queryVersionDownloads(ctx, db, "test-ns", "module", "my-module", "1.0.0") - if err != nil { - t.Fatalf("queryVersionDownloads: %v", err) - } - if count != 2 { - t.Errorf("expected 2 downloads for v1.0.0, got %d", count) - } - if lastAt == "" { - t.Error("expected lastAt to be set, got empty string") - } - - // Query resource-level count (all versions). - rCount, _, err := queryResourceDownloads(ctx, db, "test-ns", "module", "my-module") - if err != nil { - t.Fatalf("queryResourceDownloads: %v", err) - } - if rCount != 3 { - t.Errorf("expected 3 total downloads for my-module, got %d", rCount) - } - - // Query total across all namespaces. - total, err := queryTotalDownloads(ctx, db, "") - if err != nil { - t.Fatalf("queryTotalDownloads: %v", err) - } - if total != 3 { - t.Errorf("expected 3 total downloads, got %d", total) - } - - // nil db must be a no-op (graceful degradation when stats are disabled). - if err := recordDownload(ctx, nil, "ns", "module", "name", "1.0.0"); err != nil { - t.Errorf("recordDownload with nil db should be a no-op, got: %v", err) - } -} - -func TestQueryMostDownloaded(t *testing.T) { - f, err := os.CreateTemp("", "stats_most_test_*.db") - if err != nil { - t.Fatalf("failed to create temp db file: %v", err) - } - f.Close() - defer os.Remove(f.Name()) - - db, err := initStatsDB(f.Name()) - if err != nil { - t.Fatalf("initStatsDB: %v", err) - } - defer db.Close() - - ctx := context.Background() - - // module-a: 3 downloads; module-b: 1 download. - for i := 0; i < 3; i++ { - if err := recordDownload(ctx, db, "ns", "module", "module-a", "1.0.0"); err != nil { - t.Fatalf("recordDownload: %v", err) - } - } - if err := recordDownload(ctx, db, "ns", "module", "module-b", "1.0.0"); err != nil { - t.Fatalf("recordDownload: %v", err) - } - - results, err := queryMostDownloaded(ctx, db, "", 5) - if err != nil { - t.Fatalf("queryMostDownloaded: %v", err) - } - if len(results) < 2 { - t.Fatalf("expected at least 2 results, got %d", len(results)) - } - if results[0].DownloadCount < results[1].DownloadCount { - t.Errorf("results must be sorted descending by download count") - } - if results[0].Name != "module-a" { - t.Errorf("expected module-a to be most downloaded, got %s", results[0].Name) - } -} diff --git a/services/server/stats_valkey.go b/services/server/stats_valkey.go new file mode 100644 index 0000000..f2f1792 --- /dev/null +++ b/services/server/stats_valkey.go @@ -0,0 +1,298 @@ +/* +Copyright 2026 Tony Owens. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package main + +import ( + "context" + "fmt" + "strconv" + "strings" + "time" + + "github.com/redis/go-redis/v9" +) + +// keyGlobalTotal is the global download counter key. +const keyGlobalTotal = "stats:total" + +// keyNSTotal returns the namespace-scoped download counter key. +func keyNSTotal(namespace string) string { + return "stats:ns:" + namespace +} + +// keyResourceHash returns the hash key for a resource-level rollup (count + lastAt). +func keyResourceHash(namespace, kind, name string) string { + return fmt.Sprintf("stats:resource:%s:%s:%s", namespace, kind, name) +} + +// keyVersionHash returns the hash key for a version-level rollup (count + lastAt). +func keyVersionHash(namespace, kind, name, version string) string { + return fmt.Sprintf("stats:version:%s:%s:%s:%s", namespace, kind, name, version) +} + +// keyLeaderboard returns the sorted-set leaderboard key for the given scope. +// When namespace is empty the global leaderboard key is returned. +func keyLeaderboard(namespace string) string { + if namespace == "" { + return "stats:leaderboard:global" + } + + return "stats:leaderboard:" + namespace +} + +// keyDailyCounter returns the per-day counter key for a version. +// The key carries a 90-day TTL and is used for future time-series analytics. +func keyDailyCounter(namespace, kind, name, version string) string { + day := time.Now().UTC().Format("2006-01-02") + return fmt.Sprintf("stats:day:%s:%s:%s:%s:%s", day, namespace, kind, name, version) +} + +// dayTTL returns the UNIX timestamp of midnight UTC 90 days from now, used +// as the EXPIREAT deadline for daily counter keys. +func dayTTL() time.Time { + now := time.Now().UTC() + midnight := time.Date(now.Year(), now.Month(), now.Day()+90, 0, 0, 0, 0, time.UTC) + return midnight +} + +// recordDownload atomically records one download event for the given resource +// version using a single Valkey pipeline. All nine commands are sent in one +// network round trip, so concurrent server pods cannot produce partial writes. +func recordDownload(ctx context.Context, client *redis.Client, namespace, kind, name, version string) error { + now := time.Now().UTC().Format(time.RFC3339Nano) + member := fmt.Sprintf("%s/%s/%s/%s", namespace, kind, name, version) + + _, err := client.Pipelined(ctx, func(pipe redis.Pipeliner) error { + // Version-level hash. + pipe.HIncrBy(ctx, keyVersionHash(namespace, kind, name, version), "count", 1) + pipe.HSet(ctx, keyVersionHash(namespace, kind, name, version), "lastAt", now) + + // Resource-level hash. + pipe.HIncrBy(ctx, keyResourceHash(namespace, kind, name), "count", 1) + pipe.HSet(ctx, keyResourceHash(namespace, kind, name), "lastAt", now) + + // Global and namespace-scoped counters. + pipe.Incr(ctx, keyGlobalTotal) + pipe.Incr(ctx, keyNSTotal(namespace)) + + // Leaderboard sorted sets. + pipe.ZIncrBy(ctx, keyLeaderboard(""), 1, member) + pipe.ZIncrBy(ctx, keyLeaderboard(namespace), 1, member) + + // Daily counter with 90-day TTL for future analytics. + dayKey := keyDailyCounter(namespace, kind, name, version) + pipe.Incr(ctx, dayKey) + pipe.ExpireAt(ctx, dayKey, dayTTL()) + + return nil + }) + + if err != nil { + return fmt.Errorf("stats: record download: %w", err) + } + + return nil +} + +// queryTotalDownloads returns the total number of recorded download events. +// When namespace is empty the global total is returned. +func queryTotalDownloads(ctx context.Context, client *redis.Client, namespace string) (int64, error) { + key := keyGlobalTotal + if namespace != "" { + key = keyNSTotal(namespace) + } + + val, err := client.Get(ctx, key).Result() + if err == redis.Nil { + return 0, nil + } + + if err != nil { + return 0, fmt.Errorf("stats: query total downloads: %w", err) + } + + count, err := strconv.ParseInt(val, 10, 64) + if err != nil { + return 0, fmt.Errorf("stats: parse total downloads: %w", err) + } + + return count, nil +} + +// queryMostDownloaded returns the top limit most-downloaded resource versions, +// optionally scoped to a namespace. Results are sorted descending by download count. +func queryMostDownloaded(ctx context.Context, client *redis.Client, namespace string, limit int) ([]PopularResource, error) { + key := keyLeaderboard(namespace) + + members, err := client.ZRevRangeWithScores(ctx, key, 0, int64(limit-1)).Result() + if err == redis.Nil { + return []PopularResource{}, nil + } + + if err != nil { + return nil, fmt.Errorf("stats: query most downloaded: %w", err) + } + + result := make([]PopularResource, 0, len(members)) + for _, m := range members { + ns, kind, name, version, ok := splitLeaderboardMember(m.Member.(string)) + if !ok { + continue + } + + // Fetch lastAt from the version hash. + lastAt, _ := client.HGet(ctx, keyVersionHash(ns, kind, name, version), "lastAt").Result() + + result = append(result, PopularResource{ + Namespace: ns, + Kind: kind, + Name: name, + Version: version, + DownloadCount: int64(m.Score), + LastDownloadedAt: lastAt, + }) + } + + return result, nil +} + +// batchResourceDownloadStats fetches aggregate download stats for a slice of +// resource keys in a single pipeline round trip. Each key must be in the form +// "namespace/kind/name". The returned map uses the same key format. +func batchResourceDownloadStats(ctx context.Context, client *redis.Client, keys []string) (map[string]resourceDownloadStats, error) { + if len(keys) == 0 { + return nil, nil + } + + cmds := make([]*redis.MapStringStringCmd, len(keys)) + _, err := client.Pipelined(ctx, func(pipe redis.Pipeliner) error { + for i, k := range keys { + ns, kind, name, ok := splitResourceKey(k) + if !ok { + continue + } + + cmds[i] = pipe.HGetAll(ctx, keyResourceHash(ns, kind, name)) + } + + return nil + }) + + if err != nil && err != redis.Nil { + return nil, fmt.Errorf("stats: batch resource stats: %w", err) + } + + result := make(map[string]resourceDownloadStats, len(keys)) + for i, cmd := range cmds { + if cmd == nil { + continue + } + + vals, err := cmd.Result() + if err != nil || len(vals) == 0 { + continue + } + + count, _ := strconv.ParseInt(vals["count"], 10, 64) + result[keys[i]] = resourceDownloadStats{Count: count, LastAt: vals["lastAt"]} + } + + return result, nil +} + +// batchVersionDownloadStats fetches aggregate download stats for a slice of +// version keys in a single pipeline round trip. Each key must be in the form +// "namespace/kind/name/version". The returned map uses the same key format. +func batchVersionDownloadStats(ctx context.Context, client *redis.Client, keys []string) (map[string]resourceDownloadStats, error) { + if len(keys) == 0 { + return nil, nil + } + + cmds := make([]*redis.MapStringStringCmd, len(keys)) + _, err := client.Pipelined(ctx, func(pipe redis.Pipeliner) error { + for i, k := range keys { + ns, kind, name, version, ok := splitVersionKey(k) + if !ok { + continue + } + + cmds[i] = pipe.HGetAll(ctx, keyVersionHash(ns, kind, name, version)) + } + + return nil + }) + + if err != nil && err != redis.Nil { + return nil, fmt.Errorf("stats: batch version stats: %w", err) + } + + result := make(map[string]resourceDownloadStats, len(keys)) + for i, cmd := range cmds { + if cmd == nil { + continue + } + + vals, err := cmd.Result() + if err != nil || len(vals) == 0 { + continue + } + + count, _ := strconv.ParseInt(vals["count"], 10, 64) + result[keys[i]] = resourceDownloadStats{Count: count, LastAt: vals["lastAt"]} + } + + return result, nil +} + +// resourceDownloadStats holds aggregate download data for a single resource or version. +type resourceDownloadStats struct { + Count int64 + LastAt string +} + +// splitLeaderboardMember splits a leaderboard member string of the form +// "namespace/kind/name/version" into its components. +func splitLeaderboardMember(s string) (ns, kind, name, version string, ok bool) { + // Members use "/" as separator; version strings can contain "/" in edge cases + // but conventionally do not. Split on first three "/" occurrences only. + parts := strings.SplitN(s, "/", 4) + if len(parts) != 4 { + return "", "", "", "", false + } + + return parts[0], parts[1], parts[2], parts[3], true +} + +// splitResourceKey splits a key of the form "namespace/kind/name". +func splitResourceKey(s string) (ns, kind, name string, ok bool) { + parts := strings.SplitN(s, "/", 3) + if len(parts) != 3 { + return "", "", "", false + } + + return parts[0], parts[1], parts[2], true +} + +// splitVersionKey splits a key of the form "namespace/kind/name/version". +func splitVersionKey(s string) (ns, kind, name, version string, ok bool) { + parts := strings.SplitN(s, "/", 4) + if len(parts) != 4 { + return "", "", "", "", false + } + + return parts[0], parts[1], parts[2], parts[3], true +} diff --git a/services/server/stats_valkey_test.go b/services/server/stats_valkey_test.go new file mode 100644 index 0000000..e0eeca8 --- /dev/null +++ b/services/server/stats_valkey_test.go @@ -0,0 +1,196 @@ +/* +Copyright 2026 Tony Owens. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package main + +import ( + "context" + "sync" + "testing" + + "github.com/alicebob/miniredis/v2" + "github.com/redis/go-redis/v9" +) + +func newTestClient(t *testing.T) (*redis.Client, *miniredis.Miniredis) { + t.Helper() + mr := miniredis.RunT(t) + client := redis.NewClient(&redis.Options{Addr: mr.Addr()}) + return client, mr +} + +func TestRecordDownloadAndQueryCount(t *testing.T) { + client, _ := newTestClient(t) + ctx := context.Background() + + // Record two downloads for v1.0.0 and one for v2.0.0. + for range 2 { + if err := recordDownload(ctx, client, "test-ns", "module", "my-module", "1.0.0"); err != nil { + t.Fatalf("recordDownload: %v", err) + } + } + + if err := recordDownload(ctx, client, "test-ns", "module", "my-module", "2.0.0"); err != nil { + t.Fatalf("recordDownload: %v", err) + } + + // Per-version batch lookup for 1.0.0. + vStats, err := batchVersionDownloadStats(ctx, client, []string{"test-ns/module/my-module/1.0.0"}) + if err != nil { + t.Fatalf("batchVersionDownloadStats: %v", err) + } + + s, ok := vStats["test-ns/module/my-module/1.0.0"] + if !ok { + t.Fatal("expected stats for v1.0.0, got nothing") + } + + if s.Count != 2 { + t.Errorf("expected 2 downloads for v1.0.0, got %d", s.Count) + } + + if s.LastAt == "" { + t.Error("expected lastAt to be set, got empty string") + } + + // Resource-level batch lookup. + rStats, err := batchResourceDownloadStats(ctx, client, []string{"test-ns/module/my-module"}) + if err != nil { + t.Fatalf("batchResourceDownloadStats: %v", err) + } + + rs, ok := rStats["test-ns/module/my-module"] + if !ok { + t.Fatal("expected resource stats for my-module, got nothing") + } + + if rs.Count != 3 { + t.Errorf("expected 3 total downloads for my-module, got %d", rs.Count) + } + + // Global total. + total, err := queryTotalDownloads(ctx, client, "") + if err != nil { + t.Fatalf("queryTotalDownloads: %v", err) + } + + if total != 3 { + t.Errorf("expected global total of 3, got %d", total) + } + + // Namespace-scoped total. + nsTotal, err := queryTotalDownloads(ctx, client, "test-ns") + if err != nil { + t.Fatalf("queryTotalDownloads namespace: %v", err) + } + + if nsTotal != 3 { + t.Errorf("expected ns total of 3, got %d", nsTotal) + } +} + +func TestQueryMostDownloaded(t *testing.T) { + client, _ := newTestClient(t) + ctx := context.Background() + + // module-a: 3 downloads; module-b: 1 download. + for range 3 { + if err := recordDownload(ctx, client, "ns", "module", "module-a", "1.0.0"); err != nil { + t.Fatalf("recordDownload: %v", err) + } + } + + if err := recordDownload(ctx, client, "ns", "module", "module-b", "1.0.0"); err != nil { + t.Fatalf("recordDownload: %v", err) + } + + results, err := queryMostDownloaded(ctx, client, "", 5) + if err != nil { + t.Fatalf("queryMostDownloaded: %v", err) + } + + if len(results) < 2 { + t.Fatalf("expected at least 2 results, got %d", len(results)) + } + + if results[0].DownloadCount < results[1].DownloadCount { + t.Error("results must be sorted descending by download count") + } + + if results[0].Name != "module-a" { + t.Errorf("expected module-a to be most downloaded, got %s", results[0].Name) + } + + // Namespace-scoped leaderboard should match. + nsResults, err := queryMostDownloaded(ctx, client, "ns", 5) + if err != nil { + t.Fatalf("queryMostDownloaded namespace: %v", err) + } + + if len(nsResults) < 2 { + t.Fatalf("expected at least 2 namespace-scoped results, got %d", len(nsResults)) + } +} + +func TestBatchEmptyKeys(t *testing.T) { + client, _ := newTestClient(t) + ctx := context.Background() + + rStats, err := batchResourceDownloadStats(ctx, client, nil) + if err != nil { + t.Fatalf("batchResourceDownloadStats empty: %v", err) + } + + if rStats != nil { + t.Error("expected nil map for empty key slice") + } + + vStats, err := batchVersionDownloadStats(ctx, client, nil) + if err != nil { + t.Fatalf("batchVersionDownloadStats empty: %v", err) + } + + if vStats != nil { + t.Error("expected nil map for empty key slice") + } +} + +func TestConcurrentRecordDownload(t *testing.T) { + client, _ := newTestClient(t) + ctx := context.Background() + + const goroutines = 20 + var wg sync.WaitGroup + wg.Add(goroutines) + for range goroutines { + go func() { + defer wg.Done() + if err := recordDownload(ctx, client, "ns", "module", "concurrent-mod", "1.0.0"); err != nil { + t.Errorf("recordDownload: %v", err) + } + }() + } + wg.Wait() + + total, err := queryTotalDownloads(ctx, client, "") + if err != nil { + t.Fatalf("queryTotalDownloads: %v", err) + } + + if total != goroutines { + t.Errorf("expected %d total downloads after concurrent writes, got %d", goroutines, total) + } +} diff --git a/services/server/storage.go b/services/server/storage.go index 8190f48..8cff35c 100644 --- a/services/server/storage.go +++ b/services/server/storage.go @@ -106,14 +106,15 @@ func getObjectFromStorageSystem(w http.ResponseWriter, r *http.Request, s storag } } -// initStorageBackend creates and initialises a storage backend from the provided StorageConfig. -// It returns an error if the backend cannot be initialised or if no supported backend is configured. +// initStorageBackend creates and initializes a storage backend from the provided StorageConfig. +// It returns an error if the backend cannot be initialized or if no supported backend is configured. func initStorageBackend(ctx context.Context, storageConfig *opendepotv1alpha1.StorageConfig) (storage.Storage, error) { if storageConfig.S3 != nil { s3Storage := &storage.AmazonS3Storage{} if err := s3Storage.NewClient(ctx, storageConfig.S3.Region); err != nil { return nil, fmt.Errorf("failed to init s3 client: %w", err) } + return s3Storage, nil } @@ -122,6 +123,7 @@ func initStorageBackend(ctx context.Context, storageConfig *opendepotv1alpha1.St if err := gcsStorage.NewClient(ctx); err != nil { return nil, fmt.Errorf("failed to init gcs client: %w", err) } + return gcsStorage, nil } @@ -130,6 +132,7 @@ func initStorageBackend(ctx context.Context, storageConfig *opendepotv1alpha1.St if err := azStorage.NewClients(storageConfig.AzureStorage.SubscriptionID, storageConfig.AzureStorage.AccountUrl); err != nil { return nil, fmt.Errorf("failed to init azure client: %w", err) } + return azStorage, nil } diff --git a/services/server/test/e2e/e2e_test.go b/services/server/test/e2e/e2e_test.go index 763b9cf..3d6540c 100644 --- a/services/server/test/e2e/e2e_test.go +++ b/services/server/test/e2e/e2e_test.go @@ -77,6 +77,7 @@ var _ = Describe("Server Authentication", Ordered, func() { "--create-namespace", "--namespace", namespace, "--skip-crds", + "--force-conflicts", "--set", "global.image.tag=", "--set", "depot.enabled=false", "--set", "module.enabled=false", @@ -85,6 +86,7 @@ var _ = Describe("Server Authentication", Ordered, func() { "--set", "server.enabled=true", "--set", fmt.Sprintf("server.image.repository=%s", serverRepo), "--set", fmt.Sprintf("server.image.tag=%s", serverTag), + "--set", "valkey.dataStorage.enabled=false", "--wait", "--timeout", "2m", } @@ -1927,9 +1929,8 @@ server: }) It("browse resources response includes totalDownloads and lastDownloadedAt fields", func() { - // The stats fields are zero-valued when no downloads have been recorded yet - // (server deployed without --stats-db-path in this context). The test - // verifies the fields are present and parseable — not that they are non-zero. + // The stats fields are zero-valued when no downloads have been recorded yet. + // The test verifies the fields are present and parseable — not that they are non-zero. resp, err := http.Get(fmt.Sprintf("http://localhost:%d/opendepot/ui/v1/resources?pageSize=1", serverLocalPort)) Expect(err).NotTo(HaveOccurred()) defer resp.Body.Close() @@ -1968,6 +1969,169 @@ server: "versions endpoint must return 200 or 404, not an internal error") } }) + + It("totalDownloads increments after a real module download and mostDownloaded is populated", func() { + const ( + statsModuleName = "stats-smoke-module" + statsModuleVersion = "2.0.0" + statsModuleNS = "opendepot-system" + ) + // Version CR name follows the sanitizeModuleVersionForLookup convention: + // dots replaced with hyphens → "stats-smoke-module-2-0-0". + versionCRName := fmt.Sprintf("%s-2-0-0", statsModuleName) + + By("creating a Module CR so the stats visibility filter can match it") + moduleYAML := fmt.Sprintf(`apiVersion: opendepot.defdev.io/v1alpha1 +kind: Module +metadata: + name: %s + namespace: %s +spec: + moduleConfig: + provider: aws + storageConfig: + fileSystem: + directoryPath: "/data/modules" + versions: + - version: "2.0.0" +`, statsModuleName, statsModuleNS) + moduleCmd := exec.Command("kubectl", "apply", "-f", "-") + moduleCmd.Stdin = strings.NewReader(moduleYAML) + _, err := utils.Run(moduleCmd) + Expect(err).NotTo(HaveOccurred()) + + By("creating a Version CR with filesystem storage and a checksum") + dirPath := "/data/modules" + fileName := "stats-smoke-module.tar.gz" + versionYAML := fmt.Sprintf(`apiVersion: opendepot.defdev.io/v1alpha1 +kind: Version +metadata: + name: %s + namespace: %s +spec: + type: Module + version: "%s" + fileName: "%s" + moduleConfigRef: + name: "%s" + storageConfig: + fileSystem: + directoryPath: "%s" +`, versionCRName, statsModuleNS, statsModuleVersion, fileName, statsModuleName, dirPath) + applyCmd := exec.Command("kubectl", "apply", "-f", "-") + applyCmd.Stdin = strings.NewReader(versionYAML) + _, err = utils.Run(applyCmd) + Expect(err).NotTo(HaveOccurred()) + + By("patching the Version CR status to set a checksum") + patchCmd := exec.Command("kubectl", "patch", "version", versionCRName, + "-n", statsModuleNS, + "--subresource=status", + "--type=merge", + `--patch={"status":{"synced":true,"syncStatus":"Synced","checksum":"abc123"}}`) + _, err = utils.Run(patchCmd) + Expect(err).NotTo(HaveOccurred()) + + By("triggering the module download endpoint to record a stat") + downloadURL := fmt.Sprintf("http://localhost:%d/opendepot/modules/v1/%s/%s/aws/%s/download", + serverLocalPort, statsModuleNS, statsModuleName, statsModuleVersion) + resp, err := http.Get(downloadURL) + Expect(err).NotTo(HaveOccurred()) + resp.Body.Close() + Expect(resp.StatusCode).To(Equal(http.StatusNoContent), + "download redirect must return 204 so that recordDownload is called") + + By("asserting totalDownloads >= 1 from the stats endpoint") + statsResp, err := http.Get(fmt.Sprintf("http://localhost:%d/opendepot/ui/v1/stats", serverLocalPort)) + Expect(err).NotTo(HaveOccurred()) + defer statsResp.Body.Close() + Expect(statsResp.StatusCode).To(Equal(http.StatusOK)) + + var stats struct { + TotalDownloads int `json:"totalDownloads"` + MostDownloaded []interface{} `json:"mostDownloaded"` + } + Expect(json.NewDecoder(statsResp.Body).Decode(&stats)).To(Succeed()) + Expect(stats.TotalDownloads).To(BeNumerically(">=", 1), + "totalDownloads must be at least 1 after a recorded download") + Expect(len(stats.MostDownloaded)).To(BeNumerically(">=", 1), + "mostDownloaded must be non-empty after a recorded download") + + By("asserting the browse resources endpoint shows totalDownloads >= 1 for the downloaded module") + browseURL := fmt.Sprintf("http://localhost:%d/opendepot/ui/v1/resources?namespace=%s&search=%s", + serverLocalPort, statsModuleNS, statsModuleName) + browseResp, err := http.Get(browseURL) + Expect(err).NotTo(HaveOccurred()) + defer browseResp.Body.Close() + Expect(browseResp.StatusCode).To(Equal(http.StatusOK)) + + var browseBody struct { + Items []struct { + Name string `json:"name"` + TotalDownloads int64 `json:"totalDownloads"` + } `json:"items"` + } + Expect(json.NewDecoder(browseResp.Body).Decode(&browseBody)).To(Succeed()) + Expect(browseBody.Items).NotTo(BeEmpty(), "browse resources must return the downloaded module") + Expect(browseBody.Items[0].TotalDownloads).To(BeNumerically(">=", 1), + "browse resource totalDownloads must be >= 1 after a recorded download") + + By("cleaning up the smoke test Version and Module CRs") + cleanCmd := exec.Command("kubectl", "delete", "version", versionCRName, + "-n", statsModuleNS, "--ignore-not-found") + _, _ = utils.Run(cleanCmd) + cleanModuleCmd := exec.Command("kubectl", "delete", "module", statsModuleName, + "-n", statsModuleNS, "--ignore-not-found") + _, _ = utils.Run(cleanModuleCmd) + }) + }) + + Context("with Valkey ACL auth enabled", Ordered, func() { + const valkeyAuthSecret = "valkey-auth-test" + var pfCancel context.CancelFunc + + BeforeAll(func() { + By("creating valkey ACL password Secret") + createCmd := exec.Command("kubectl", "create", "secret", "generic", valkeyAuthSecret, + "--from-literal=default=testpassword123", + "-n", namespace, + "--dry-run=client", "-o", "yaml", + ) + applyCmd := exec.Command("kubectl", "apply", "-f", "-") + createOut, err := createCmd.Output() + Expect(err).NotTo(HaveOccurred()) + applyCmd.Stdin = strings.NewReader(string(createOut)) + _, err = utils.Run(applyCmd) + Expect(err).NotTo(HaveOccurred()) + + By("deploying server with Valkey ACL auth enabled") + deployServer( + "--set", "valkey.auth.enabled=true", + "--set", fmt.Sprintf("valkey.auth.usersExistingSecret=%s", valkeyAuthSecret), + "--set", "valkey.auth.aclUsers.default.permissions=~stats:* &* -@all +HSET +HINCRBY +HGET +HGETALL +INCR +GET +ZINCRBY +ZREVRANGEBYSCORE +ZREVRANGE +EXPIREAT", + "--set", fmt.Sprintf("server.stats.valkeyPasswordSecretName=%s", valkeyAuthSecret), + "--set", "valkey.dataStorage.enabled=false", + ) + + pfCancel = startPortForward() + }) + + AfterAll(func() { + stopPortForward(pfCancel) + + By("deleting valkey ACL password Secret") + deleteCmd := exec.Command("kubectl", "delete", "secret", valkeyAuthSecret, + "-n", namespace, "--ignore-not-found") + _, _ = utils.Run(deleteCmd) + }) + + It("should return 200 from the stats endpoint when Valkey auth is configured", func() { + resp, err := http.Get(fmt.Sprintf("http://localhost:%d/opendepot/ui/v1/stats", serverLocalPort)) + Expect(err).NotTo(HaveOccurred()) + defer resp.Body.Close() + Expect(resp.StatusCode).To(Equal(http.StatusOK), + "stats endpoint must return 200 when server authenticates to Valkey with ACL password") + }) }) }) @@ -1998,6 +2162,7 @@ var _ = Describe("Browse API", Ordered, func() { "--create-namespace", "--namespace", namespace, "--skip-crds", + "--force-conflicts", "--set", "global.image.tag=", "--set", "depot.enabled=false", "--set", "module.enabled=false", @@ -2009,6 +2174,7 @@ var _ = Describe("Browse API", Ordered, func() { // Anonymous auth so the SA client path is exercised. "--set", "server.anonymousAuth=true", "--set", "server.useBearerToken=false", + "--set", "valkey.dataStorage.enabled=false", "--wait", "--timeout", "2m", } @@ -2540,11 +2706,6 @@ spec: } pfCancel = nil - By("uninstalling previous Helm release before OIDC redeployment") - uninstallCmd := exec.Command("helm", "uninstall", helmReleaseName, - "--namespace", namespace, "--ignore-not-found") - _, _ = utils.Run(uninstallCmd) - By("generating bcrypt hash for the GroupBinding browse test password") hashBytes, err := bcrypt.GenerateFromPassword([]byte(gbBrowseUserPassword), 10) Expect(err).NotTo(HaveOccurred()) @@ -2597,6 +2758,7 @@ server: "--create-namespace", "--namespace", namespace, "--skip-crds", + "--force-conflicts", "--set", "global.image.tag=", "--set", "depot.enabled=false", "--set", "module.enabled=false", @@ -2607,6 +2769,7 @@ server: "--set", fmt.Sprintf("server.image.tag=%s", serverTag), "--set", "server.anonymousAuth=false", "--set", "server.useBearerToken=false", + "--set", "valkey.dataStorage.enabled=false", "-f", valuesFile, "--wait", "--timeout", "10m", diff --git a/services/server/types.go b/services/server/types.go index b3a1846..7a1cf99 100644 --- a/services/server/types.go +++ b/services/server/types.go @@ -112,7 +112,7 @@ type BrowseResource struct { LastScanned string `json:"lastScanned,omitempty"` // Public reports whether the namespace and resource are both explicitly public. Public bool `json:"public"` - // Download stats (populated from SQLite when stats tracking is enabled). + // Download stats (populated from Valkey). TotalDownloads int64 `json:"totalDownloads,omitempty"` LastDownloadedAt string `json:"lastDownloadedAt,omitempty"` } diff --git a/services/server/ui_browse.go b/services/server/ui_browse.go index 3c26e2c..4ed6578 100644 --- a/services/server/ui_browse.go +++ b/services/server/ui_browse.go @@ -439,7 +439,14 @@ func browseCollectModules(cs *kubernetes.Clientset, r *http.Request, nsFilter, n return nil, fmt.Errorf("failed to unmarshal module list: %w", err) } - dlStats, _ := queryAllResourceDownloadStats(r.Context(), statsDB, "") + dlStats, _ := batchResourceDownloadStats(r.Context(), statsClient, func() []string { + keys := make([]string, 0, len(list.Items)) + for _, m := range list.Items { + keys = append(keys, m.Namespace+"/module/"+m.Name) + } + + return keys + }()) var items []BrowseResource for _, m := range list.Items { @@ -478,7 +485,14 @@ func browseCollectProviders(cs *kubernetes.Clientset, r *http.Request, nsFilter, return nil, fmt.Errorf("failed to unmarshal provider list: %w", err) } - dlStats, _ := queryAllResourceDownloadStats(r.Context(), statsDB, "") + dlStats, _ := batchResourceDownloadStats(r.Context(), statsClient, func() []string { + keys := make([]string, 0, len(list.Items)) + for _, p := range list.Items { + keys = append(keys, p.Namespace+"/provider/"+p.Name) + } + + return keys + }()) var items []BrowseResource for _, p := range list.Items { @@ -1221,21 +1235,25 @@ func deduplicateFindings(in []opendepotv1alpha1.SecurityFinding) []opendepotv1al } // enrichVersionSummariesWithDownloads populates DownloadCount and LastDownloadedAt on each -// BrowseVersionSummary by issuing a single batch query against the stats DB. +// BrowseVersionSummary by issuing a single batch query against Valkey. func enrichVersionSummariesWithDownloads(ctx context.Context, summaries []BrowseVersionSummary, namespace, kind, name string) { - if statsDB == nil || len(summaries) == 0 { + if len(summaries) == 0 { return } - dlStats, err := queryAllVersionDownloadStats(ctx, statsDB, namespace) + keys := make([]string, len(summaries)) + for i := range summaries { + keys[i] = namespace + "/" + kind + "/" + name + "/" + summaries[i].Version + } + + dlStats, err := batchVersionDownloadStats(ctx, statsClient, keys) if err != nil { logger.Error("browse: failed to query version download stats", "error", err) return } for i := range summaries { - key := namespace + "/" + kind + "/" + name + "/" + summaries[i].Version - if s, ok := dlStats[key]; ok { + if s, ok := dlStats[keys[i]]; ok { summaries[i].DownloadCount = s.Count summaries[i].LastDownloadedAt = s.LastAt } diff --git a/services/server/ui_stats.go b/services/server/ui_stats.go index e499516..10a18ca 100644 --- a/services/server/ui_stats.go +++ b/services/server/ui_stats.go @@ -240,18 +240,15 @@ func handleBrowseStats(w http.ResponseWriter, r *http.Request) { secPosture.TotalAffectedResources = len(affectedResources) - // Query download stats from SQLite. - totalDownloads, err := queryTotalDownloads(r.Context(), statsDB, namespace) + // Query download stats from Valkey. + totalDownloads, err := queryTotalDownloads(r.Context(), statsClient, namespace) if err != nil { logger.Error("stats: failed to query total downloads", "error", err) } - mostDownloaded, err := queryMostDownloaded(r.Context(), statsDB, namespace, 10) + mostDownloaded, err := queryMostDownloaded(r.Context(), statsClient, namespace, 10) if err != nil { logger.Error("stats: failed to query most downloaded", "error", err) - } - - if mostDownloaded == nil { mostDownloaded = []PopularResource{} } diff --git a/services/ui/Dockerfile b/services/ui/Dockerfile index c7420e8..79a5a20 100644 --- a/services/ui/Dockerfile +++ b/services/ui/Dockerfile @@ -16,7 +16,7 @@ RUN yarn build FROM nginx:1.27-alpine AS runner # Install Node.js for the Next.js standalone server. -RUN apk add --no-cache nodejs +RUN apk upgrade --no-cache && apk add --no-cache nodejs # Pre-create nginx writable directories for non-root (UID 101) operation. RUN mkdir -p /var/cache/nginx/client_temp \ diff --git a/services/ui/entrypoint.sh b/services/ui/entrypoint.sh index e1f27e5..43a0f42 100644 --- a/services/ui/entrypoint.sh +++ b/services/ui/entrypoint.sh @@ -7,9 +7,9 @@ export OPENDEPOT_SERVER_HOST="${OPENDEPOT_SERVER_HOST:-server.opendepot-system.s # Substitute environment variables into the nginx template. envsubst '${OPENDEPOT_SERVER_HOST}' < /etc/nginx/nginx.conf.template > /tmp/nginx.conf -# Start Next.js standalone server in the background, bound to all interfaces -# so that the nginx upstream on 127.0.0.1:3000 can reach it. -HOSTNAME=0.0.0.0 node /app/server.js & +# Start Next.js standalone server in the background, bound to loopback only +# so that only the nginx upstream on 127.0.0.1:3000 can reach it. +HOSTNAME=127.0.0.1 node /app/server.js & # Start NGINX in the foreground (PID 1 equivalent). exec nginx -c /tmp/nginx.conf -g "daemon off;" diff --git a/services/ui/nginx.conf b/services/ui/nginx.conf index f11a3ff..c42de11 100644 --- a/services/ui/nginx.conf +++ b/services/ui/nginx.conf @@ -12,6 +12,7 @@ http { sendfile on; keepalive_timeout 65; server_tokens off; + client_max_body_size 10m; # Write access logs to stdout. access_log /dev/stdout combined; diff --git a/services/ui/src/components/Sidebar.tsx b/services/ui/src/components/Sidebar.tsx index fcee6e4..eb59698 100644 --- a/services/ui/src/components/Sidebar.tsx +++ b/services/ui/src/components/Sidebar.tsx @@ -38,8 +38,6 @@ import { useEffect, useState, useCallback } from "react"; const DRAWER_WIDTH = 260; const COLLAPSED_WIDTH = 56; -const API_BASE_URL = (process.env.NEXT_PUBLIC_API_BASE_URL ?? "").replace(/\/$/, ""); - interface Namespace { name: string; public: boolean; @@ -81,7 +79,7 @@ export default function Sidebar({ // Fetch namespaces client-side if none passed as props useEffect(() => { if (initialNamespaces.length > 0) return; - fetch(`${API_BASE_URL}/opendepot/ui/v1/namespaces`) + fetch(`/opendepot/ui/v1/namespaces`) .then((r) => r.json()) .then((data: { items: Namespace[] }) => { setNamespaces(data.items ?? []); diff --git a/services/version/go.mod b/services/version/go.mod index ac2304d..89b907f 100644 --- a/services/version/go.mod +++ b/services/version/go.mod @@ -10,6 +10,7 @@ require ( github.com/onsi/gomega v1.40.0 github.com/tonedefdev/opendepot/api/v1alpha1 v0.2.7 github.com/tonedefdev/opendepot/pkg/github v0.2.7 + github.com/tonedefdev/opendepot/pkg/registry v0.0.0-20260531075737-3500a5f0c756 github.com/tonedefdev/opendepot/pkg/storage v0.2.7 github.com/tonedefdev/opendepot/pkg/testutils v0.2.7 k8s.io/apimachinery v0.35.4 diff --git a/services/version/go.sum b/services/version/go.sum index 4031c21..b510775 100644 --- a/services/version/go.sum +++ b/services/version/go.sum @@ -280,8 +280,8 @@ github.com/tonedefdev/opendepot/api/v1alpha1 v0.2.7 h1:RsSO5aKpbhEE/qNXhLlLl7zae github.com/tonedefdev/opendepot/api/v1alpha1 v0.2.7/go.mod h1:fAkqWqqNWN+RJmgYh75DCQ/TIOJIGvlSECxV+GCKoxc= github.com/tonedefdev/opendepot/pkg/github v0.2.7 h1:ufVqDW/otxkKc0dRJQJAn+Q2P2LHluvyrOrveYr7u2Q= github.com/tonedefdev/opendepot/pkg/github v0.2.7/go.mod h1:zHEDMTMptGOsdRWdqcUlxbEFgFw4CBEQzuhGgnVYzcw= -github.com/tonedefdev/opendepot/pkg/registry v0.0.0-20260531013401-7bafdedbe77f h1:nspEeT4ppFewddNG+NmsqR5JOlqBPRdqxrO9r4sL4Fc= -github.com/tonedefdev/opendepot/pkg/registry v0.0.0-20260531013401-7bafdedbe77f/go.mod h1:XlyJuLAVbeL0cT1OkJp2GkBDWKN4B6QM6VtFPyhYdQs= +github.com/tonedefdev/opendepot/pkg/registry v0.0.0-20260531075737-3500a5f0c756 h1:TxLv5mxDVWwKAlO0wHNTZ3wnK3nzvmo9WiDNlxeVNC0= +github.com/tonedefdev/opendepot/pkg/registry v0.0.0-20260531075737-3500a5f0c756/go.mod h1:XlyJuLAVbeL0cT1OkJp2GkBDWKN4B6QM6VtFPyhYdQs= github.com/tonedefdev/opendepot/pkg/storage v0.2.7 h1:9eEZHOVMJ3HTUIkDXDBvGb3kp+huIOrUNW5Nz3NRSyQ= github.com/tonedefdev/opendepot/pkg/storage v0.2.7/go.mod h1:RGwm+erGodklHxwYZy115g0e5TQa7PePCW/DIxTY4Zw= github.com/tonedefdev/opendepot/pkg/testutils v0.2.7 h1:zxeFJyrTm4puMYkrYlv8qv+z3OUSGFXN+xg+0H3Q0BU=