commit 3795736e5e3d556cb58ef1cafbf30993d2b7f2d0 Author: 0xFEEDC0DE64 Date: Thu Oct 22 17:39:25 2026 +0200 Package loops Kubernetes deployment diff --git a/.gitea/workflows/chart.yml b/.gitea/workflows/chart.yml new file mode 100644 index 0000000..44af96e --- /dev/null +++ b/.gitea/workflows/chart.yml @@ -0,0 +1,62 @@ +name: Validate, publish and promote loops chart +on: + push: + pull_request: +jobs: + validate: + runs-on: linux_amd64 + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Lint and render + run: ./scripts/test.sh + publish: + if: gitea.event_name == 'push' && gitea.ref == 'refs/heads/main' + needs: validate + runs-on: linux_amd64 + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Derive immutable chart version + run: echo "CHART_VERSION=$(./scripts/chart-version.sh)" >> "$GITHUB_ENV" + - name: Package and upload + env: + PACKAGE_USERNAME: ${{ secrets.PACKAGE_USERNAME }} + PACKAGE_TOKEN: ${{ secrets.PACKAGE_TOKEN }} + run: | + set -euo pipefail + test -n "$PACKAGE_USERNAME" && test -n "$PACKAGE_TOKEN" + helm package chart --destination "$RUNNER_TEMP" --version "$CHART_VERSION" + curl --fail --silent --show-error --user "$PACKAGE_USERNAME:$PACKAGE_TOKEN" \ + --request POST --upload-file "$RUNNER_TEMP/loops-$CHART_VERSION.tgz" \ + https://code.brunner.ninja/api/packages/feedc0de/helm/api/charts + - name: Confirm current main + env: + COMMIT_SHA: ${{ gitea.sha }} + run: | + git fetch --no-tags origin main + test "$(git rev-parse FETCH_HEAD)" = "$COMMIT_SHA" + - uses: actions/checkout@v4 + with: + repository: feedc0de/homelab-gitops + ref: main + token: ${{ secrets.GITOPS_TOKEN }} + path: homelab-gitops + - name: Promote exact chart + working-directory: homelab-gitops + run: | + set -euo pipefail + ./scripts/update-chart-version.sh loops "$CHART_VERSION" + git config user.name feedc0de + git config user.email feedc0de@users.noreply.code.brunner.ninja + git add bootstrap/loops.yaml + if git diff --cached --quiet; then exit 0; fi + git commit -m "Deploy loops chart $CHART_VERSION" + for attempt in 1 2 3; do + git push origin HEAD:main && exit 0 + test "$attempt" = 3 && exit 1 + git fetch origin main + git rebase origin/main + done diff --git a/README.md b/README.md new file mode 100644 index 0000000..9a4b891 --- /dev/null +++ b/README.md @@ -0,0 +1,20 @@ +# loops Helm chart + +This chart owns the loops web and worker topology, its MySQL and Valkey +services, persistent claims, probes, and optional Traefik route. It expects +existing Kubernetes Secrets named by `runtimeSecret`, `mysql.authSecret`, +`valkey.authSecret`, and (when bucket bootstrap is enabled) +`s3BucketJob.secretName`. Never put their values in a public values file. + +For this homelab, [cluster-specific values](https://code.brunner.ninja/feedc0de/homelab-gitops/src/branch/main/apps/loops/values.yaml) +select the existing PVC names, storage classes, domain and Secret names. A fresh +installation may choose different storage; migrating a live installation must +preserve its PVC UIDs and database contents. + +```sh +helm repo add brunner.ninja https://brunner.ninja/charts +helm repo update brunner.ninja +helm upgrade --install loops brunner.ninja/loops --namespace loops --create-namespace --wait --timeout 20m +``` + +The deployed image is built from Loops source commit f614b34f. The chart's frontend, backend and Following-feed overlays are tied to that exact image; review and rebase them before changing `appVersion` or `image.tag`. The upstream project does not issue SemVer releases for this commit, so Git tag `f614b34f` maps reversibly to chart version `0.0.0-f614b34f`; chart-only commits append their Git-describe count and hash. diff --git a/chart/Chart.yaml b/chart/Chart.yaml new file mode 100644 index 0000000..786eef7 --- /dev/null +++ b/chart/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v2 +name: loops +description: Kubernetes deployment contract for loops +type: application +version: 0.0.0-f614b34f +appVersion: "f614b34f" diff --git a/chart/templates/application.yaml b/chart/templates/application.yaml new file mode 100644 index 0000000..3feb923 --- /dev/null +++ b/chart/templates/application.yaml @@ -0,0 +1,583 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: loops-env + namespace: {{ .Release.Namespace }} + labels: + app.kubernetes.io/part-of: loops +data: +{{- range $key, $value := .Values.config }} + {{ $key }}: {{ $value | quote }} +{{- end }} +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: loops-filesystem-config + namespace: {{ .Release.Namespace }} + labels: + app.kubernetes.io/part-of: loops +data: + filesystems.php: |- + 'local', + 'disks' => [ + 'app' => [ + 'driver' => 'local', + 'root' => storage_path('app'), + 'serve' => false, + 'throw' => false, + ], + 'local' => [ + 'driver' => 'local', + 'root' => storage_path('app/private'), + 'serve' => false, + 'throw' => false, + ], + 'public' => [ + 'driver' => 'local', + 'root' => storage_path('app/public'), + 'url' => env('APP_URL').'/storage', + 'visibility' => 'public', + 'throw' => false, + ], + 's3' => [ + 'driver' => 's3', + 'key' => env('AWS_ACCESS_KEY_ID'), + 'secret' => env('AWS_SECRET_ACCESS_KEY'), + 'region' => env('AWS_DEFAULT_REGION'), + 'bucket' => env('AWS_BUCKET'), + 'url' => env('AWS_URL'), + 'endpoint' => env('AWS_ENDPOINT'), + 'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false), + 'throw' => false, + 'options' => [ + 'request_checksum_calculation' => env('AWS_REQUEST_CHECKSUM_CALCULATION', 'WHEN_SUPPORTED'), + 'response_checksum_validation' => env('AWS_RESPONSE_CHECKSUM_VALIDATION', 'WHEN_SUPPORTED'), + 'StorageClass' => env('AWS_STORAGE_CLASS', 'STANDARD'), + ], + ], + 'backupS3' => [ + 'driver' => 's3', + 'key' => env('BACKUP_AWS_ACCESS_KEY_ID'), + 'secret' => env('BACKUP_AWS_SECRET_ACCESS_KEY'), + 'region' => env('BACKUP_AWS_DEFAULT_REGION'), + 'bucket' => env('BACKUP_AWS_BUCKET'), + 'url' => env('BACKUP_AWS_URL'), + 'endpoint' => env('BACKUP_AWS_ENDPOINT'), + 'visibility' => 'private', + 'use_path_style_endpoint' => false, + 'throw' => false, + 'options' => [ + 'request_checksum_calculation' => env('BACKUP_AWS_REQUEST_CHECKSUM_CALCULATION', 'WHEN_SUPPORTED'), + 'response_checksum_validation' => env('BACKUP_AWS_RESPONSE_CHECKSUM_VALIDATION', 'WHEN_SUPPORTED'), + ], + ], + 'assetCDN' => [ + 'driver' => 's3', + 'key' => env('ASSETCDN_AWS_ACCESS_KEY_ID'), + 'secret' => env('ASSETCDN_AWS_SECRET_KEY'), + 'region' => env('ASSETCDN_AWS_DEFAULT_REGION'), + 'bucket' => env('ASSETCDN_AWS_BUCKET'), + 'endpoint' => env('ASSETCDN_AWS_ENDPOINT'), + 'use_path_style_endpoint' => env('ASSETCDN_AWS_USE_PATH_STYLE_ENDPOINT', false), + 'visibility' => 'public', + 'throw' => false, + ], + 'backupLocal' => [ + 'driver' => 'local', + 'root' => storage_path('app/backups'), + 'visibility' => 'private', + 'throw' => false, + ], + 'exports' => [ + 'driver' => 'local', + 'root' => storage_path('app/exports'), + 'url' => env('APP_URL').'/storage/exports', + 'visibility' => 'private', + ], + ], + 'links' => [ + public_path('storage') => storage_path('app/public'), + ], + ]; +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: loops-web + namespace: {{ .Release.Namespace }} + labels: + app.kubernetes.io/name: loops-web + app.kubernetes.io/part-of: loops +spec: + replicas: {{ .Values.replicas.web }} + strategy: + type: Recreate + selector: + matchLabels: + app.kubernetes.io/name: loops-web + app.kubernetes.io/part-of: loops + template: + metadata: + labels: + app.kubernetes.io/name: loops-web + app.kubernetes.io/part-of: loops + spec: + securityContext: + fsGroup: 33 + fsGroupChangePolicy: OnRootMismatch + initContainers: + - name: initialize-app-data + image: {{ .Values.image.repository }}:{{ default .Chart.AppVersion .Values.image.tag }} + imagePullPolicy: IfNotPresent + command: + - sh + - -ec + - >- + mkdir -p /data/storage /data/bootstrap-cache && + cp -rn /var/www/html/storage/. /data/storage/ && + cp -rn /var/www/html/bootstrap/cache/. /data/bootstrap-cache/ && + chown -R 33:33 /data + securityContext: + runAsUser: 0 + runAsGroup: 0 + volumeMounts: + - name: app-data + mountPath: /data + containers: + - name: loops + image: {{ .Values.image.repository }}:{{ default .Chart.AppVersion .Values.image.tag }} + imagePullPolicy: IfNotPresent + ports: + - name: http + containerPort: 8080 + envFrom: + - configMapRef: + name: loops-env + - secretRef: + name: {{ .Values.runtimeSecret }} + env: + - name: AUTORUN_ENABLED + value: "false" + startupProbe: + httpGet: + path: /ping + port: http + # Initial Loops migrations can take longer than ten minutes. + failureThreshold: 360 + periodSeconds: 10 + timeoutSeconds: 5 + readinessProbe: + httpGet: + path: /ping + port: http + periodSeconds: 10 + timeoutSeconds: 5 + livenessProbe: + httpGet: + path: /ping + port: http + initialDelaySeconds: 60 + periodSeconds: 30 + timeoutSeconds: 5 + resources: + requests: + cpu: 500m + memory: 768Mi + limits: + cpu: "3" + memory: 3Gi + volumeMounts: + - name: app-data + mountPath: /var/www/html/storage + subPath: storage + - name: app-data + mountPath: /var/www/html/public/storage + subPath: storage/app/public + - name: app-data + mountPath: /var/www/html/bootstrap/cache + subPath: bootstrap-cache + - name: filesystem-config + mountPath: /var/www/html/config/filesystems.php + subPath: filesystems.php + - name: frontend-hotfix + mountPath: /var/www/html/public/build/assets/index-Cng6wq5r.js + subPath: index-Cng6wq5r.js + readOnly: true + - name: backend-hotfix + mountPath: /var/www/html/app/Services/ForYouFeedService.php + subPath: ForYouFeedService.php + readOnly: true + - name: backend-hotfix + mountPath: /var/www/html/app/Services/ImpressionBloomFilterService.php + subPath: ImpressionBloomFilterService.php + readOnly: true + - name: following-feed-hook + mountPath: /var/www/html/app/Services/RandomizedFollowingFeedService.php + subPath: RandomizedFollowingFeedService.php + readOnly: true + - name: following-feed-hook + mountPath: /var/www/html/app/Http/Controllers/Api/RandomizedFeedController.php + subPath: RandomizedFeedController.php + readOnly: true + - name: following-feed-hook + mountPath: /var/www/html/app/Providers/FollowingFeedServiceProvider.php + subPath: FollowingFeedServiceProvider.php + readOnly: true + - name: following-feed-hook + mountPath: /var/www/html/bootstrap/providers.php + subPath: providers.php + readOnly: true + volumes: + - name: app-data + persistentVolumeClaim: + claimName: app-data + - name: filesystem-config + configMap: + name: loops-filesystem-config + - name: frontend-hotfix + configMap: + name: loops-frontend-hotfix + - name: backend-hotfix + configMap: + name: loops-backend-hotfix + - name: following-feed-hook + configMap: + name: loops-following-feed-hook +--- +apiVersion: v1 +kind: Service +metadata: + name: loops-web + namespace: {{ .Release.Namespace }} + labels: + app.kubernetes.io/name: loops-web + app.kubernetes.io/part-of: loops +spec: + selector: + app.kubernetes.io/name: loops-web + app.kubernetes.io/part-of: loops + ports: + - name: http + port: 80 + targetPort: http +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: loops-horizon + namespace: {{ .Release.Namespace }} + labels: + app.kubernetes.io/name: loops-horizon + app.kubernetes.io/part-of: loops +spec: + replicas: {{ .Values.replicas.horizon }} + strategy: + type: Recreate + selector: + matchLabels: + app.kubernetes.io/name: loops-horizon + app.kubernetes.io/part-of: loops + template: + metadata: + labels: + app.kubernetes.io/name: loops-horizon + app.kubernetes.io/part-of: loops + spec: + securityContext: + fsGroup: 33 + fsGroupChangePolicy: OnRootMismatch + containers: + - name: horizon + image: {{ .Values.image.repository }}:{{ default .Chart.AppVersion .Values.image.tag }} + imagePullPolicy: IfNotPresent + command: + - php + - artisan + - queue:work + - redis + - --queue=default,activitypub-out,activitypub-in,actor-update,activitypub-delete-fanout,notify,push,klipy,email-verification + - --sleep=3 + - --tries=3 + - --timeout=300 + - --max-time=3600 + - --memory=512 + envFrom: + - configMapRef: + name: loops-env + - secretRef: + name: {{ .Values.runtimeSecret }} + env: + - name: AUTORUN_ENABLED + value: "false" + resources: + requests: + cpu: 200m + memory: 512Mi + limits: + cpu: "1" + memory: 1Gi + volumeMounts: + - name: app-data + mountPath: /var/www/html/storage + subPath: storage + - name: app-data + mountPath: /var/www/html/bootstrap/cache + subPath: bootstrap-cache + - name: filesystem-config + mountPath: /var/www/html/config/filesystems.php + subPath: filesystems.php + volumes: + - name: app-data + persistentVolumeClaim: + claimName: app-data + - name: filesystem-config + configMap: + name: loops-filesystem-config +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: loops-transcoder + namespace: {{ .Release.Namespace }} + labels: + app.kubernetes.io/name: loops-transcoder + app.kubernetes.io/component: transcoder + app.kubernetes.io/part-of: loops +spec: + replicas: {{ .Values.replicas.transcoder }} + strategy: + type: Recreate + selector: + matchLabels: + app.kubernetes.io/name: loops-transcoder + app.kubernetes.io/part-of: loops + template: + metadata: + labels: + app.kubernetes.io/name: loops-transcoder + app.kubernetes.io/component: transcoder + app.kubernetes.io/part-of: loops + spec: +{{- with .Values.transcoder.nodeSelector }} + nodeSelector: +{{ toYaml . | indent 8 }} +{{- end }} +{{- with .Values.transcoder.tolerations }} + tolerations: +{{ toYaml . | indent 8 }} +{{- end }} + terminationGracePeriodSeconds: 900 + securityContext: + fsGroup: 33 + fsGroupChangePolicy: OnRootMismatch + containers: + - name: remote-video + image: {{ .Values.image.repository }}:{{ default .Chart.AppVersion .Values.image.tag }} + imagePullPolicy: IfNotPresent + command: + - php + - artisan + - queue:work + - redis + - --queue=remote-video + - --sleep=3 + - --tries=3 + - --timeout=900 + - --max-time=3600 + - --memory=4096 + envFrom: + - configMapRef: + name: loops-env + - secretRef: + name: {{ .Values.runtimeSecret }} + env: + - name: AUTORUN_ENABLED + value: "false" + resources: + requests: + cpu: "4" + memory: 2Gi + limits: + cpu: "10" + memory: 8Gi + volumeMounts: + - name: app-data + mountPath: /var/www/html/storage + subPath: storage + - name: app-data + mountPath: /var/www/html/bootstrap/cache + subPath: bootstrap-cache + - name: filesystem-config + mountPath: /var/www/html/config/filesystems.php + subPath: filesystems.php + - name: local-media + image: {{ .Values.image.repository }}:{{ default .Chart.AppVersion .Values.image.tag }} + imagePullPolicy: IfNotPresent + command: + - php + - artisan + - queue:work + - redis + - --queue=video-processing,audio-processing + - --sleep=3 + - --tries=3 + - --timeout=900 + - --max-time=3600 + - --memory=4096 + envFrom: + - configMapRef: + name: loops-env + - secretRef: + name: {{ .Values.runtimeSecret }} + env: + - name: AUTORUN_ENABLED + value: "false" + resources: + requests: + cpu: "2" + memory: 2Gi + limits: + cpu: "10" + memory: 8Gi + volumeMounts: + - name: app-data + mountPath: /var/www/html/storage + subPath: storage + - name: app-data + mountPath: /var/www/html/bootstrap/cache + subPath: bootstrap-cache + - name: filesystem-config + mountPath: /var/www/html/config/filesystems.php + subPath: filesystems.php + volumes: + - name: app-data + persistentVolumeClaim: + claimName: app-data + - name: filesystem-config + configMap: + name: loops-filesystem-config +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: loops-scheduler + namespace: {{ .Release.Namespace }} + labels: + app.kubernetes.io/name: loops-scheduler + app.kubernetes.io/part-of: loops +spec: + replicas: {{ .Values.replicas.scheduler }} + strategy: + type: Recreate + selector: + matchLabels: + app.kubernetes.io/name: loops-scheduler + app.kubernetes.io/part-of: loops + template: + metadata: + labels: + app.kubernetes.io/name: loops-scheduler + app.kubernetes.io/part-of: loops + spec: + securityContext: + fsGroup: 33 + fsGroupChangePolicy: OnRootMismatch + containers: + - name: scheduler + image: {{ .Values.image.repository }}:{{ default .Chart.AppVersion .Values.image.tag }} + imagePullPolicy: IfNotPresent + command: + - php + - artisan + - schedule:work + envFrom: + - configMapRef: + name: loops-env + - secretRef: + name: {{ .Values.runtimeSecret }} + env: + - name: AUTORUN_ENABLED + value: "false" + resources: + requests: + cpu: 100m + memory: 256Mi + limits: + cpu: "1" + memory: 1Gi + volumeMounts: + - name: app-data + mountPath: /var/www/html/storage + subPath: storage + - name: app-data + mountPath: /var/www/html/bootstrap/cache + subPath: bootstrap-cache + - name: filesystem-config + mountPath: /var/www/html/config/filesystems.php + subPath: filesystems.php + volumes: + - name: app-data + persistentVolumeClaim: + claimName: app-data + - name: filesystem-config + configMap: + name: loops-filesystem-config +{{- if .Values.ingress.enabled }} +--- +apiVersion: traefik.io/v1alpha1 +kind: Middleware +metadata: + name: loops-android-for-you + namespace: {{ .Release.Namespace }} + labels: + app.kubernetes.io/part-of: loops +spec: + replacePathRegex: + regex: ^/api/v1/feed/for-you$ + replacement: /api/v0/feed/recommended +--- +apiVersion: traefik.io/v1alpha1 +kind: Middleware +metadata: + name: loops-upload-buffer + namespace: {{ .Release.Namespace }} + labels: + app.kubernetes.io/part-of: loops +spec: + buffering: + maxRequestBodyBytes: 524288000 + memRequestBodyBytes: 2097152 +--- +apiVersion: traefik.io/v1alpha1 +kind: IngressRoute +metadata: + name: {{ .Values.ingress.name }} + namespace: {{ .Release.Namespace }} + labels: + app.kubernetes.io/part-of: loops +spec: + entryPoints: + - {{ .Values.ingress.entryPoint }} + routes: + - kind: Rule + match: Host(`{{ .Values.domain }}`) && Path(`/api/v1/feed/for-you`) + priority: 100 + middlewares: + - name: loops-android-for-you + - name: loops-upload-buffer + services: + - name: loops-web + port: 80 + - kind: Rule + match: Host(`{{ .Values.domain }}`) + middlewares: + - name: loops-upload-buffer + services: + - name: loops-web + port: 80 + tls: {} +{{- end }} diff --git a/chart/templates/backend-hotfix.yaml b/chart/templates/backend-hotfix.yaml new file mode 100644 index 0000000..7a26b42 --- /dev/null +++ b/chart/templates/backend-hotfix.yaml @@ -0,0 +1,760 @@ +apiVersion: v1 +data: + ForYouFeedService.php: | + buildFeedQuery($profile, $cursor, $limit); + + return [ + 'data' => VideoResource::collection($videos), + 'links' => [ + 'first' => null, + 'last' => null, + 'prev' => null, + 'next' => null, + ], + 'meta' => [ + 'path' => url('/api/v1/feed/recommended'), + 'per_page' => $limit, + 'next_cursor' => $this->generateCursor($videos), + 'prev_cursor' => null, + 'has_more' => count($videos) === $limit, + ], + ]; + } + + private function buildFeedQuery(Profile $profile, ?string $cursor, int $limit): Collection + { + // Older mobile and web clients do not report recommendation impressions. + // Treat delivery as an impression so refreshes and subsequent pages always + // advance, and exclude likes at query time so newly liked videos cannot + // reappear even before the Bloom filter has been updated by a client. + $videos = collect(); + $sampledIds = []; + + for ($attempt = 0; $attempt < 8 && $videos->count() < $limit; $attempt++) { + $query = Video::query() + ->select([ + 'videos.*', + DB::raw('0 as feed_score'), + ]) + ->where('videos.status', 2) + ->where('videos.visibility', 1) + ->where('videos.profile_id', '<>', $profile->id) + ->whereNotExists(function ($subquery) use ($profile) { + $subquery->selectRaw(1) + ->from('video_likes') + ->whereColumn('video_likes.video_id', 'videos.id') + ->where('video_likes.profile_id', $profile->id); + }) + ->when($sampledIds !== [], fn ($query) => $query->whereNotIn('videos.id', $sampledIds)) + ->with([ + 'profile:id,username', + 'hashtags:hashtags.id,hashtags.name', + ]); + + $query = $this->applyUserFilters($query, $profile->id); + $query = $this->applyContentPreferences($query, $profile); + + $batch = $query + ->inRandomOrder() + ->limit(max($limit * 5, 100)) + ->get(); + + if ($batch->isEmpty()) { + break; + } + + $sampledIds = array_merge($sampledIds, $batch->pluck('id')->all()); + $unseen = app(ImpressionBloomFilterService::class) + ->filterVideos($profile->id, $batch); + + $videos = $videos + ->concat($unseen) + ->unique('id') + ->take($limit) + ->values(); + } + + foreach ($videos as $video) { + $this->recordImpression($profile->id, (int) $video->id, 0, false); + } + + return $videos; + } + + private function applyUserFilters($query, int $profileId) + { + return $query->leftJoin('user_filters', function ($join) use ($profileId) { + $join->on('videos.profile_id', '=', 'user_filters.account_id') + ->where('user_filters.profile_id', '=', $profileId); + })->whereNull('user_filters.id'); + } + + private function applyContentPreferences($query, Profile $profile) + { + return $query->when($profile->hide_ai, function ($q) { + $q->where('videos.contains_ai', false); + }); + } + + private function getPersonalizedVideos( + Profile $profile, + Collection $interests, + ?float $cursorScore, + ?int $cursorId, + int $limit + ): Collection { + $scoredVideos = collect(); + $lastId = $cursorId; + $maxAttempts = 5; + $attempts = 0; + $batchMultiplier = 3; + + while ($scoredVideos->count() < $limit && $attempts < $maxAttempts) { + $attempts++; + $batchSize = $limit * $batchMultiplier * $attempts; + + $query = Video::query() + ->select([ + 'videos.*', + DB::raw('0 as feed_score'), + ]) + ->where('videos.status', 2) + ->where('videos.visibility', 1) + ->where('videos.created_at', '>=', now()->subDays(self::MAX_AGE_DAYS)) + ->orderByDesc('videos.id') + ->with([ + 'profile:id,username', + 'hashtags:hashtags.id,hashtags.name', + ]); + + // Apply user filters via join + $query = $this->applyUserFilters($query, $profile->id); + $query = $this->applyContentPreferences($query, $profile); + + if ($lastId !== null) { + $query->where('videos.id', '<', $lastId); + } + + $videos = $query->limit($batchSize)->get(); + + if ($videos->isEmpty()) { + break; + } + + $lastId = $videos->last()->id; + + $unviewedVideos = app(ImpressionBloomFilterService::class) + ->filterVideos($profile->id, $videos); + + $batchScored = $unviewedVideos->map(function ($video) use ($profile, $interests) { + $score = $this->calculateVideoScore($video, $profile, $interests); + $video->feed_score = $score; + + return $video; + })->filter(fn ($v) => $v->feed_score >= self::MIN_SCORE_THRESHOLD); + + $scoredVideos = $scoredVideos->concat($batchScored); + } + + return $scoredVideos + ->sortByDesc('feed_score') + ->take($limit) + ->values(); + } + + private function getPopularVideos( + Profile $profile, + ?int $cursorId, + int $limit, + array $alreadyIncludedIds = [] + ): Collection { + $query = Video::query() + ->select([ + 'videos.*', + DB::raw('( + (videos.likes * 2) + + (videos.comments * 3) + + (videos.shares * 4) + + (videos.bookmarks * 3) + + (videos.views * 0.1) + ) / (TIMESTAMPDIFF(HOUR, videos.created_at, NOW()) + 1) as feed_score'), + ]) + ->where('videos.status', 2) + ->where('videos.visibility', 1) + ->where('videos.created_at', '>=', now()->subDays(self::MAX_AGE_DAYS)) + ->whereNotIn('videos.id', $alreadyIncludedIds) + ->with([ + 'profile:id,username', + 'hashtags:hashtags.id,hashtags.name', + ]); + + // Apply user filters via join + $query = $this->applyUserFilters($query, $profile->id); + $query = $this->applyContentPreferences($query, $profile); + + if ($cursorId) { + $query->where('videos.id', '<', $cursorId); + } + + $videos = $query + ->orderByDesc('feed_score') + ->limit($limit) + ->get(); + + $videos = app(ImpressionBloomFilterService::class)->filterVideos($profile->id, $videos); + + return $videos; + } + + private function getDiscoveryVideos( + Profile $profile, + array $alreadyIncludedIds, + int $limit + ): Collection { + $query = Video::query() + ->select([ + 'videos.*', + DB::raw('RAND() as feed_score'), + ]) + ->where('videos.status', 2) + ->where('videos.visibility', 1) + ->where('videos.created_at', '>=', now()->subDays(self::MAX_AGE_DAYS)) + ->whereNotIn('videos.id', $alreadyIncludedIds) + ->where('videos.likes', '>=', 2) + ->with([ + 'profile:id,username', + 'hashtags:hashtags.id,hashtags.name', + ]); + + // Apply user filters via join + $query = $this->applyUserFilters($query, $profile->id); + $query = $this->applyContentPreferences($query, $profile); + + $videos = $query + ->inRandomOrder() + ->limit($limit) + ->get(); + + $videos = app(ImpressionBloomFilterService::class)->filterVideos($profile->id, $videos); + + return $videos; + } + + private function interleaveVideos(Collection $personalized, Collection $discovery, int $limit): Collection + { + $result = collect(); + $pIndex = 0; + $dIndex = 0; + + while ($result->count() < $limit) { + for ($i = 0; $i < 2 && $pIndex < $personalized->count() && $result->count() < $limit; $i++) { + $result->push($personalized[$pIndex++]); + } + + if ($dIndex < $discovery->count() && $result->count() < $limit) { + $result->push($discovery[$dIndex++]); + } + + if ($pIndex >= $personalized->count() && $dIndex >= $discovery->count()) { + break; + } + } + + return $result->take($limit)->values(); + } + + private function calculateVideoScore(Video $video, Profile $profile, Collection $interests): float + { + $freshnessScore = $this->calculateFreshnessScore($video); + $engagementScore = $this->calculateEngagementScore($video); + $personalizationScore = $this->calculatePersonalizationScore($video, $interests); + $creatorQualityScore = $this->calculateCreatorQualityScore($video); + + $totalScore = ( + ($freshnessScore * self::FRESHNESS_WEIGHT) + + ($engagementScore * self::ENGAGEMENT_WEIGHT) + + ($personalizationScore * self::PERSONALIZATION_WEIGHT) + + ($creatorQualityScore * self::CREATOR_QUALITY_WEIGHT) + ); + + return round($totalScore, 4); + } + + private function calculateFreshnessScore(Video $video): float + { + $ageInHours = now()->diffInHours($video->created_at); + $maxAgeHours = self::MAX_AGE_DAYS * 24; + + return max(0, 1 - pow($ageInHours / $maxAgeHours, 2)); + } + + private function calculateEngagementScore(Video $video): float + { + $ageInDays = max(1, now()->diffInDays($video->created_at) + 1); + + $likeScore = min(1, $video->likes / 1000); + $commentScore = min(1, $video->comments / 100); + $shareScore = min(1, $video->shares / 50); + $bookmarkScore = min(1, $video->bookmarks / 50); + $viewScore = min(1, $video->views / 5000); + + $rawEngagement = ( + ($likeScore * 0.30) + + ($commentScore * 0.20) + + ($shareScore * 0.25) + + ($bookmarkScore * 0.15) + + ($viewScore * 0.10) + ); + + return $rawEngagement / log($ageInDays + 2); + } + + private function calculatePersonalizationScore(Video $video, Collection $interests): float + { + if ($interests->isEmpty()) { + return 0.5; + } + + $score = 0; + $maxScore = 0; + + $creatorInterest = $interests->first(function ($interest) use ($video) { + return $interest->interest_type === UserInterest::TYPE_CREATOR + && $interest->interest_value == $video->profile_id; + }); + + if ($creatorInterest) { + $score += ($creatorInterest->score / 100) * 0.5; + } + $maxScore += 0.5; + + $videoHashtags = $this->getVideoHashtags($video); + + if ($videoHashtags->isNotEmpty()) { + $hashtagMatches = $interests->filter(function ($interest) use ($videoHashtags) { + return $interest->interest_type === UserInterest::TYPE_HASHTAG + && $videoHashtags->contains('name', $interest->interest_value); + }); + + if ($hashtagMatches->isNotEmpty()) { + $avgHashtagScore = $hashtagMatches->avg('score') / 100; + $score += $avgHashtagScore * 0.3; + } + } + $maxScore += 0.3; + + if ($video->category) { + $categoryInterest = $interests->first(function ($interest) use ($video) { + return $interest->interest_type === UserInterest::TYPE_CATEGORY + && $interest->interest_value === $video->category; + }); + + if ($categoryInterest) { + $score += ($categoryInterest->score / 100) * 0.2; + } + } + $maxScore += 0.2; + + // @phpstan-ignore-next-line + return $maxScore > 0 ? $score / $maxScore : 0.5; + } + + private function getVideoHashtags(Video $video): Collection + { + if ($video->relationLoaded('hashtags')) { + return $video->hashtags; + } + + return Cache::remember( + "video_hashtags:{$video->id}", + 7200, + fn () => $video->hashtags()->get(['hashtags.id', 'hashtags.name']) + ); + } + + private function calculateCreatorQualityScore(Video $video): float + { + if (! $video->profile) { + return 0.5; + } + + $trustScore = ($video->profile->trust_level ?? 5) / 10; + + return $trustScore; + } + + private function getUserInterests(int $profileId): Collection + { + return Cache::remember( + "fyf:user_interests:{$profileId}", + now()->addMinutes(30), + fn () => UserInterest::where('profile_id', $profileId) + ->where('score', '>', 0) + ->orderByDesc('score') + ->limit(50) + ->get() + ); + } + + public function forgetUserInterests(int $profileId): void + { + Cache::forget("fyf:user_interests:{$profileId}"); + } + + private function generateCursor(Collection $videos): ?string + { + if ($videos->isEmpty()) { + return null; + } + + $boundaryId = (int) $videos->min('id'); + + return base64_encode('0|'.$boundaryId); + } + + private function decodeCursor(string $cursor): array + { + try { + $decoded = base64_decode($cursor, true); + if (! $decoded || ! str_contains($decoded, '|')) { + return [null, null]; + } + + [$score, $id] = explode('|', $decoded, 2); + + return [ + is_numeric($score) ? (float) $score : null, + is_numeric($id) ? (int) $id : null, + ]; + } catch (\Throwable $e) { + return [null, null]; + } + } + + public function recordImpression( + int $profileId, + int $videoId, + int $watchDuration = 0, + bool $completed = false + ): void { + $watchDuration = max(0, min($watchDuration, 65535)); + + DB::statement( + ' + INSERT INTO feed_impressions ( + profile_id, + video_id, + viewed_at, + watch_duration, + completed + ) + VALUES (?, ?, NOW(), ?, ?) + AS new + ON DUPLICATE KEY UPDATE + viewed_at = new.viewed_at, + watch_duration = GREATEST(feed_impressions.watch_duration, new.watch_duration), + completed = GREATEST(feed_impressions.completed, new.completed) + ', + [ + $profileId, + $videoId, + $watchDuration, + $completed ? 1 : 0, + ] + ); + + app(ImpressionBloomFilterService::class)->add($profileId, $videoId); + } + + public function recordFeedback( + int $profileId, + int $videoId, + string $feedbackType + ): void { + FeedFeedback::create([ + 'profile_id' => $profileId, + 'video_id' => $videoId, + 'feedback_type' => $feedbackType, + ]); + + $this->updateUserInterestsFromFeedback($profileId, $videoId, $feedbackType); + } + + private function updateUserInterestsFromFeedback( + int $profileId, + int $videoId, + string $feedbackType + ): void { + $video = Video::with(['hashtags'])->find($videoId); + + if (! $video) { + return; + } + + $weight = match ($feedbackType) { + FeedFeedback::TYPE_LIKE => 2.0, + FeedFeedback::TYPE_DISLIKE => -1.0, + FeedFeedback::TYPE_NOT_INTERESTED => -0.5, + FeedFeedback::TYPE_HIDE_CREATOR => -3.0, + default => 0, + }; + + $this->updateInterest( + $profileId, + UserInterest::TYPE_CREATOR, + (string) $video->profile_id, + $weight + ); + + if ($video->hashtags->isNotEmpty()) { + foreach ($video->hashtags as $hashtag) { + $this->updateInterest( + $profileId, + UserInterest::TYPE_HASHTAG, + $hashtag->name, + $weight + ); + } + } + + if ($video->category) { + $this->updateInterest( + $profileId, + UserInterest::TYPE_CATEGORY, + $video->category, + $weight + ); + } + } + + private function updateInterest( + int $profileId, + string $type, + string $value, + float $weight + ): void { + $interest = UserInterest::firstOrCreate( + [ + 'profile_id' => $profileId, + 'interest_type' => $type, + 'interest_value' => $value, + ], + [ + 'score' => 0, + 'interaction_count' => 0, + ] + ); + + if ($weight > 0) { + $interest->recordInteraction($weight); + } else { + $interest->score = max(0, $interest->score + ($weight * 5)); + $interest->save(); + } + } + } + ImpressionBloomFilterService.php: | + getKey($profileId); + $this->ensureFilterExists($key); + $this->bloom('BF.ADD', [$key, (string) $videoId]); + } + + public function mightContain(int $profileId, int $videoId): bool + { + $key = $this->getKey($profileId); + + $this->ensureFilterHydrated($profileId, $key); + + if (! $this->keyExists($key)) { + return false; + } + + return (int) $this->bloom('BF.EXISTS', [$key, (string) $videoId]) === 1; + } + + public function filterVideos(int $profileId, Collection $videos): Collection + { + if ($videos->isEmpty()) { + return $videos; + } + + $key = $this->getKey($profileId); + $this->ensureFilterHydrated($profileId, $key); + + $exists = $this->keyExists($key); + + if (! $exists) { + return $videos; + } + + $videoIdList = $videos->pluck('id') + ->map(fn ($id) => (string) $id) + ->values() + ->all(); + + $results = $this->bloom('BF.MEXISTS', array_merge([$key], $videoIdList)); + + $seenVideoIds = []; + foreach ($videoIdList as $i => $videoId) { + $resultValue = $results[$i] ?? null; + if (((int) ($resultValue ?? 0)) === 1) { + $seenVideoIds[$videoId] = true; + } + } + + return $videos->reject( + fn ($video) => isset($seenVideoIds[(string) $video->id]) + )->values(); + } + + private function getClient() + { + return Redis::connection()->client(); + } + + private function keyExists(string $key): bool + { + return (int) $this->getClient()->rawCommand('EXISTS', $key) === 1; + } + + private function setex(string $key, int $ttl, string $value): void + { + $this->getClient()->rawCommand('SETEX', $key, $ttl, $value); + } + + private function expire(string $key, int $ttl): void + { + $this->getClient()->rawCommand('EXPIRE', $key, $ttl); + } + + private function ensureFilterHydrated(int $profileId, string $key): void + { + $hydratedKey = $key.':hydrated'; + + if ($this->keyExists($hydratedKey)) { + return; + } + + $videoIds = FeedImpression::where('profile_id', $profileId) + ->where('viewed_at', '>=', now()->subDays(self::HYDRATION_DAYS)) + ->pluck('video_id') + ->toArray(); + + if (empty($videoIds)) { + $this->setex($hydratedKey, self::TTL_SECONDS, '1'); + + return; + } + + $this->ensureFilterExists($key); + + foreach (array_chunk($videoIds, 100) as $chunk) { + $this->bloom('BF.MADD', array_merge([$key], array_map('strval', $chunk))); + } + + if ($this->keyExists($key)) { + $this->setex($hydratedKey, self::TTL_SECONDS, '1'); + } + } + + private function ensureFilterExists(string $key): void + { + if ($this->keyExists($key)) { + return; + } + + try { + $this->bloom('BF.RESERVE', [$key, self::ERROR_RATE, self::EXPECTED_ITEMS]); + $this->expire($key, self::TTL_SECONDS); + } catch (\Throwable $e) { + if (! str_contains($e->getMessage(), 'exists')) { + Log::error("BF.RESERVE failed for key {$key}: {$e->getMessage()}"); + } + } + } + + private function bloom(string $command, array $args = []) + { + return $this->getClient()->rawCommand($command, ...$args); + } + + private function getKey(int $profileId): string + { + $week = now()->format('Y-W'); + + return self::FILTER_KEY_PREFIX."{$profileId}:{$week}"; + } + + public function invalidate(int $profileId): void + { + $key = $this->getKey($profileId); + $client = $this->getClient(); + $client->rawCommand('DEL', $key); + $client->rawCommand('DEL', $key.':hydrated'); + } + } +kind: ConfigMap +metadata: + name: loops-backend-hotfix + namespace: {{ .Release.Namespace }} diff --git a/chart/templates/following-feed-hook.yaml b/chart/templates/following-feed-hook.yaml new file mode 100644 index 0000000..5f3e068 --- /dev/null +++ b/chart/templates/following-feed-hook.yaml @@ -0,0 +1,236 @@ +apiVersion: v1 +data: + FollowingFeedServiceProvider.php: | + app->bind(FeedController::class, RandomizedFeedController::class); + } + } + } + RandomizedFeedController.php: | + user(); + if ($user->cannot('viewAny', [Video::class])) { + return $this->error('Please finish setting up your account', 403); + } + + app(UserActivityService::class)->markActive($user); + + $limit = min(max((int) $request->input('limit', 5), 1), 20); + $page = $this->followingFeed->getPage( + $user, + $request->input('cursor'), + $limit + ); + + return response()->json([ + 'data' => VideoResource::collection($page['videos'])->resolve($request), + 'links' => [ + 'first' => null, + 'last' => null, + 'prev' => null, + 'next' => null, + ], + 'meta' => [ + 'path' => $request->url(), + 'per_page' => $limit, + 'next_cursor' => $page['next_cursor'], + 'prev_cursor' => null, + 'has_more' => $page['has_more'], + ], + ]); + } + } + RandomizedFollowingFeedService.php: | + profile_id; + [$token, $offset] = $this->decodeCursor($cursor); + + if ($token === null) { + $token = bin2hex(random_bytes(16)); + $offset = 0; + $videoIds = $this->candidateVideoIds($user); + shuffle($videoIds); + Cache::put($this->cacheKey($profileId, $token), $videoIds, self::SESSION_TTL_SECONDS); + } else { + $videoIds = Cache::get($this->cacheKey($profileId, $token)); + if (! is_array($videoIds)) { + return $this->emptyPage(); + } + + // Keep an actively viewed feed snapshot alive. + Cache::put($this->cacheKey($profileId, $token), $videoIds, self::SESSION_TTL_SECONDS); + } + + $pageIds = array_slice($videoIds, $offset, $limit); + $videosById = Video::query() + ->published() + ->with('hashtags') + ->whereIn('id', $pageIds) + ->get() + ->keyBy(fn (Video $video) => (string) $video->id); + + $videos = collect($pageIds) + ->map(fn ($id) => $videosById->get((string) $id)) + ->filter() + ->values(); + + $nextOffset = $offset + count($pageIds); + $hasMore = $nextOffset < count($videoIds); + + return [ + 'videos' => $videos, + 'next_cursor' => $hasMore ? $this->encodeCursor($token, $nextOffset) : null, + 'has_more' => $hasMore, + ]; + } + + /** @return array */ + private function candidateVideoIds(User $user): array + { + $profileId = (int) $user->profile_id; + + return Video::query() + ->published() + ->whereIn('visibility', [1, 2, 3, 4]) + ->where(function ($query) use ($profileId) { + $query->where('videos.profile_id', $profileId) + ->orWhereExists(function ($subquery) use ($profileId) { + $subquery->selectRaw(1) + ->from('followers') + ->whereColumn('followers.following_id', 'videos.profile_id') + ->where('followers.profile_id', $profileId); + }); + }) + ->when((bool) $user->hide_ai, fn ($query) => $query->where('contains_ai', false)) + ->pluck('videos.id') + ->map(fn ($id) => (int) $id) + ->all(); + } + + /** @return array{0: ?string, 1: int} */ + private function decodeCursor(?string $cursor): array + { + if ($cursor === null || $cursor === '') { + return [null, 0]; + } + + $decoded = base64_decode($cursor, true); + $data = $decoded === false ? null : json_decode($decoded, true); + + if (! is_array($data) + || ($data['v'] ?? null) !== 1 + || ! is_string($data['token'] ?? null) + || preg_match('/^[a-f0-9]{32}$/', $data['token']) !== 1 + || ! is_int($data['offset'] ?? null) + || $data['offset'] < 0) { + return [null, 0]; + } + + return [$data['token'], $data['offset']]; + } + + private function encodeCursor(string $token, int $offset): string + { + return base64_encode(json_encode([ + 'v' => 1, + 'token' => $token, + 'offset' => $offset, + ], JSON_THROW_ON_ERROR)); + } + + private function cacheKey(int $profileId, string $token): string + { + return "loops:following-feed:{$profileId}:{$token}"; + } + + /** @return array{videos: Collection, next_cursor: null, has_more: false} */ + private function emptyPage(): array + { + return [ + 'videos' => collect(), + 'next_cursor' => null, + 'has_more' => false, + ]; + } + } + providers.php: | + k({queryKey:["fyfeed"],queryFn:({pageParam:a})=>y({cursor:a}),getNextPageParam:a=>a.meta?.next_cursor??void 0,initialPageParam:null,enabled:!0,staleTime:0,retry:2}),X={__name:"index",setup(a){const s=S("authStore"),n=H(),j=x(),{hasInteracted:o,handleFirstInteraction:m,globalMuted:c}=g(),u=(e,t)=>({"video-id":e.id,"video-url":e.media.src_url,"share-url":e.url,"profile-id":e.account.id,username:e.account.username,"profile-image":e.account.avatar,caption:e.caption,hashtags:e.tags,mentions:e.mentions,likes:e.likes,hasLiked:e.has_liked,hasBookmarked:e.has_bookmarked,bookmarks:e.bookmarks,shares:e.shares,comments:[],canComment:e.permissions?.can_comment,"comment-count":e.comments,index:t,isSensitive:e?.is_sensitive,altText:e?.media.alt_text,autoPlay:o.value,muted:c.value}),l=e=>e.id,_=e=>{},f=e=>{},p=()=>{m()};return(e,t)=>{const i=h;return C(),F(P,null,{default:I(()=>[r(v,{key:s.authenticated?"recommended-feed":"public-feed","feed-data":s.authenticated?n:j,"item-component":V,"get-item-props":u,"get-item-key":l,"auto-play":w(o),"scroll-threshold":1.5,"snap-sensitivity":50,onItemVisible:_,onItemHidden:f,onInteraction:p},null,8,["feed-data","auto-play"]),r(i)]),_:1})}}};export{X as default}; +kind: ConfigMap +metadata: + labels: + app.kubernetes.io/part-of: loops + name: loops-frontend-hotfix + namespace: {{ .Release.Namespace }} diff --git a/chart/templates/infrastructure.yaml b/chart/templates/infrastructure.yaml new file mode 100644 index 0000000..25aa188 --- /dev/null +++ b/chart/templates/infrastructure.yaml @@ -0,0 +1,269 @@ +apiVersion: v1 +kind: Service +metadata: + name: mysql + namespace: {{ .Release.Namespace }} + labels: + app.kubernetes.io/name: mysql + app.kubernetes.io/part-of: loops +spec: + clusterIP: None + selector: + app.kubernetes.io/name: mysql + app.kubernetes.io/part-of: loops + ports: + - name: mysql + port: 3306 + targetPort: mysql +--- +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: mysql + namespace: {{ .Release.Namespace }} + labels: + app.kubernetes.io/name: mysql + app.kubernetes.io/part-of: loops +spec: + serviceName: mysql + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/name: mysql + app.kubernetes.io/part-of: loops + template: + metadata: + labels: + app.kubernetes.io/name: mysql + app.kubernetes.io/part-of: loops + spec: + terminationGracePeriodSeconds: 120 + securityContext: + fsGroup: 999 + fsGroupChangePolicy: OnRootMismatch + containers: + - name: mysql + image: mysql:8.4.11 + imagePullPolicy: IfNotPresent + args: + - --character-set-server=utf8mb4 + - --collation-server=utf8mb4_unicode_ci + - --innodb-file-per-table=1 + ports: + - name: mysql + containerPort: 3306 + env: + - name: MYSQL_DATABASE + value: loops + - name: MYSQL_USER + value: loops + - name: MYSQL_PASSWORD + valueFrom: + secretKeyRef: + name: {{ .Values.mysql.authSecret }} + key: password + - name: MYSQL_ROOT_PASSWORD + valueFrom: + secretKeyRef: + name: {{ .Values.mysql.authSecret }} + key: root-password + readinessProbe: + exec: + command: + - sh + - -ec + - mysqladmin ping -h 127.0.0.1 -u root -p"$MYSQL_ROOT_PASSWORD" --silent + initialDelaySeconds: 10 + periodSeconds: 5 + timeoutSeconds: 5 + livenessProbe: + exec: + command: + - sh + - -ec + - mysqladmin ping -h 127.0.0.1 -u root -p"$MYSQL_ROOT_PASSWORD" --silent + initialDelaySeconds: 600 + periodSeconds: 10 + timeoutSeconds: 5 + resources: + requests: + cpu: 250m + memory: 768Mi + limits: + cpu: "2" + memory: 2Gi + volumeMounts: + - name: data + mountPath: /var/lib/mysql + volumeClaimTemplates: + - metadata: + name: data + spec: + accessModes: + - ReadWriteOnce + storageClassName: {{ .Values.mysql.storageClass | quote }} + resources: + requests: + storage: {{ .Values.mysql.storageSize }} +--- +apiVersion: v1 +kind: Service +metadata: + name: valkey + namespace: {{ .Release.Namespace }} + labels: + app.kubernetes.io/name: valkey + app.kubernetes.io/part-of: loops +spec: + clusterIP: None + selector: + app.kubernetes.io/name: valkey + app.kubernetes.io/part-of: loops + ports: + - name: valkey + port: 6379 + targetPort: valkey +--- +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: valkey + namespace: {{ .Release.Namespace }} + labels: + app.kubernetes.io/name: valkey + app.kubernetes.io/part-of: loops +spec: + serviceName: valkey + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/name: valkey + app.kubernetes.io/part-of: loops + template: + metadata: + labels: + app.kubernetes.io/name: valkey + app.kubernetes.io/part-of: loops + spec: + terminationGracePeriodSeconds: 60 + securityContext: + runAsNonRoot: true + runAsUser: 999 + runAsGroup: 999 + fsGroup: 999 + fsGroupChangePolicy: OnRootMismatch + containers: + - name: valkey + image: valkey/valkey-bundle:9.0.6-alpine + imagePullPolicy: IfNotPresent + command: + - sh + - -ec + - exec valkey-server --loadmodule /usr/lib/valkey/libvalkey_bloom.so --appendonly yes --appendfsync everysec --requirepass "$VALKEY_PASSWORD" + env: + - name: VALKEY_PASSWORD + valueFrom: + secretKeyRef: + name: {{ .Values.valkey.authSecret }} + key: password + ports: + - name: valkey + containerPort: 6379 + readinessProbe: + exec: + command: + - sh + - -ec + - valkey-cli --no-auth-warning -a "$VALKEY_PASSWORD" ping | grep -qx PONG + initialDelaySeconds: 3 + periodSeconds: 5 + timeoutSeconds: 3 + livenessProbe: + exec: + command: + - sh + - -ec + - valkey-cli --no-auth-warning -a "$VALKEY_PASSWORD" ping | grep -qx PONG + initialDelaySeconds: 20 + periodSeconds: 10 + timeoutSeconds: 5 + resources: + requests: + cpu: 100m + memory: 256Mi + limits: + cpu: "1" + memory: 1Gi + volumeMounts: + - name: data + mountPath: /data + volumeClaimTemplates: + - metadata: + name: data + spec: + accessModes: + - ReadWriteOnce + storageClassName: {{ .Values.valkey.storageClass | quote }} + resources: + requests: + storage: {{ .Values.valkey.storageSize }} +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: app-data + namespace: {{ .Release.Namespace }} + labels: + app.kubernetes.io/part-of: loops +spec: + accessModes: + - ReadWriteMany + storageClassName: {{ .Values.persistence.storageClass | quote }} + resources: + requests: + storage: {{ .Values.persistence.storageSize }} +{{- if .Values.s3BucketJob.enabled }} +--- +apiVersion: batch/v1 +kind: Job +metadata: + name: create-media-bucket + namespace: {{ .Release.Namespace }} + labels: + app.kubernetes.io/name: create-media-bucket + app.kubernetes.io/part-of: loops +spec: + ttlSecondsAfterFinished: 300 + backoffLimit: 5 + template: + metadata: + labels: + app.kubernetes.io/name: create-media-bucket + app.kubernetes.io/part-of: loops + spec: + restartPolicy: OnFailure + containers: + - name: aws-cli + image: amazon/aws-cli:2.28.22 + command: + - /bin/sh + - -ec + - >- + aws --endpoint-url "$S3_ENDPOINT" s3api head-bucket --bucket {{ .Values.s3BucketJob.bucket }} >/dev/null 2>&1 || + aws --endpoint-url "$S3_ENDPOINT" s3api create-bucket --bucket {{ .Values.s3BucketJob.bucket }} >/dev/null + env: + - name: S3_ENDPOINT + value: {{ .Values.s3BucketJob.endpoint | quote }} + - name: AWS_DEFAULT_REGION + value: {{ .Values.s3BucketJob.region | quote }} + - name: AWS_ACCESS_KEY_ID + valueFrom: + secretKeyRef: + name: {{ .Values.s3BucketJob.secretName }} + key: access-key-id + - name: AWS_SECRET_ACCESS_KEY + valueFrom: + secretKeyRef: + name: {{ .Values.s3BucketJob.secretName }} + key: secret-access-key +{{- end }} diff --git a/chart/values.schema.json b/chart/values.schema.json new file mode 100644 index 0000000..64f5a53 --- /dev/null +++ b/chart/values.schema.json @@ -0,0 +1,127 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "domain": { + "type": "string", + "minLength": 1 + }, + "image": { + "type": "object", + "properties": { + "repository": { + "type": "string", + "minLength": 1 + }, + "tag": { + "type": "string" + } + } + }, + "runtimeSecret": { + "type": "string", + "minLength": 1 + }, + "replicas": { + "type": "object", + "additionalProperties": { + "type": "integer", + "minimum": 0 + } + }, + "mysql": { + "type": "object", + "properties": { + "authSecret": { + "type": "string", + "minLength": 1 + }, + "storageClass": { + "type": "string" + }, + "storageSize": { + "type": "string" + } + } + }, + "valkey": { + "type": "object", + "properties": { + "authSecret": { + "type": "string", + "minLength": 1 + }, + "storageClass": { + "type": "string" + }, + "storageSize": { + "type": "string" + } + } + }, + "persistence": { + "type": "object", + "properties": { + "storageClass": { + "type": "string" + }, + "storageSize": { + "type": "string" + } + } + }, + "ingress": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "entryPoint": { + "type": "string" + } + } + }, + "s3BucketJob": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "endpoint": { + "type": "string" + }, + "region": { + "type": "string" + }, + "bucket": { + "type": "string" + }, + "secretName": { + "type": "string" + } + } + }, + "config": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "transcoder": { + "type": "object" + } + }, + "required": [ + "domain", + "image", + "runtimeSecret", + "mysql", + "valkey", + "persistence", + "ingress", + "config" + ] +} diff --git a/chart/values.yaml b/chart/values.yaml new file mode 100644 index 0000000..5b08f40 --- /dev/null +++ b/chart/values.yaml @@ -0,0 +1,99 @@ +image: + repository: registry.brunner.ninja/feedc0de/loops + tag: "" +replicas: + web: 1 + horizon: 1 + scheduler: 1 + transcoder: 1 +transcoder: + nodeSelector: {} + tolerations: [] +domain: loops.example.org +runtimeSecret: loops-runtime +mysql: + authSecret: mysql-auth + storageClass: "" + storageSize: 30Gi +valkey: + authSecret: valkey-auth + storageClass: "" + storageSize: 5Gi +persistence: + storageClass: "" + storageSize: 10Gi +ingress: + enabled: false + name: loops + entryPoint: websecure +s3BucketJob: + enabled: false + endpoint: "" + region: us-east-1 + bucket: loops + secretName: loops-s3 +config: + APP_DEBUG: "false" + APP_ENV: "production" + APP_FALLBACK_LOCALE: "en" + APP_LOCALE: "en" + APP_NAME: "Loops" + APP_TIMEZONE: "Europe/Vienna" + APP_URL: "https://loops.example.org" + AUTORUN_ENABLED: "true" + AUTORUN_LARAVEL_CONFIG_CACHE: "true" + AUTORUN_LARAVEL_EVENT_CACHE: "true" + AUTORUN_LARAVEL_MIGRATION: "true" + AUTORUN_LARAVEL_MIGRATION_ISOLATION: "true" + AUTORUN_LARAVEL_ROUTE_CACHE: "true" + AUTORUN_LARAVEL_STORAGE_LINK: "true" + AUTORUN_LARAVEL_VIEW_CACHE: "true" + AWS_BUCKET: "loops" + AWS_DEFAULT_REGION: "us-east-1" + AWS_ENDPOINT: "http://s3.example.org" + AWS_REQUEST_CHECKSUM_CALCULATION: "WHEN_REQUIRED" + AWS_RESPONSE_CHECKSUM_VALIDATION: "WHEN_REQUIRED" + AWS_STORAGE_CLASS: "STANDARD_IA" + AWS_URL: "https://s3.example.org/loops" + AWS_USE_PATH_STYLE_ENDPOINT: "true" + BROADCAST_CONNECTION: "log" + CACHE_PREFIX: "loops-cache-" + CACHE_STORE: "redis" + DB_CONNECTION: "mysql" + DB_DATABASE: "loops" + DB_HOST: "mysql" + DB_PORT: "3306" + DB_USERNAME: "loops" + FFMPEG_BINARIES: "/usr/bin/ffmpeg" + FFPROBE_BINARIES: "/usr/bin/ffprobe" + FILESYSTEM_DISK: "local" + HORIZON_PREFIX: "loops-horizon-" + LOG_CHANNEL: "stderr" + LOG_LEVEL: "info" + LOOPS_ADMIN_DASHBOARD_AUTOUPDATE: "true" + LOOPS_ADMIN_MAILS_TO: "daniel@example.org" + LOOPS_EMAILS_DMCA: "daniel@example.org" + LOOPS_EMAILS_SUPPORT: "daniel@example.org" + LOOPS_FOLLOWING_RANDOMIZED: "true" + LOOPS_LOCAL_DOMAINS: "social.example.org,pixels.example.org" + LOOPS_REG_MIN_YEARS_OLD: "16" + MAIL_AUTO_TLS: "false" + MAIL_ENCRYPTION: "null" + MAIL_FROM_ADDRESS: "loops@example.org" + MAIL_FROM_NAME: "Loops" + MAIL_HOST: "smtp.example.org" + MAIL_MAILER: "smtp" + MAIL_PORT: "25" + PHP_MEMORY_LIMIT: "1G" + PHP_OPCACHE_ENABLE: "1" + PHP_POST_MAX_SIZE: "500M" + PHP_UPLOAD_MAX_FILE_SIZE: "500M" + QUEUE_CONNECTION: "redis" + REDIS_CLIENT: "phpredis" + REDIS_HOST: "valkey" + REDIS_PORT: "6379" + SESSION_DOMAIN: "loops.example.org" + SESSION_DRIVER: "database" + SESSION_LIFETIME: "86400" + SESSION_SECURE_COOKIE: "true" + SSL_MODE: "off" diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..59f785a --- /dev/null +++ b/install.sh @@ -0,0 +1,8 @@ +#!/bin/sh +set -eu +chart_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +namespace=${1:-loops} +shift $(( $# > 0 ? 1 : 0 )) +helm upgrade --install loops "$chart_dir/chart" \ + --namespace "$namespace" --create-namespace \ + --wait --timeout 20m "$@" diff --git a/scripts/chart-version.sh b/scripts/chart-version.sh new file mode 100755 index 0000000..6039c3c --- /dev/null +++ b/scripts/chart-version.sh @@ -0,0 +1,8 @@ +#!/bin/sh +set -eu +# Loops has no upstream SemVer release. Its source commit is the Git tag. +description=$(git describe --tags --long --match 'f[0-9a-f]*' --dirty) +case "$description" in *-dirty) echo 'Refusing dirty tree' >&2; exit 1 ;; esac +version=0.0.0-$description +case "$version" in *-0-g*) version=${version%%-0-g*} ;; esac +printf '%s\n' "$version" diff --git a/scripts/test.sh b/scripts/test.sh new file mode 100755 index 0000000..858ca3e --- /dev/null +++ b/scripts/test.sh @@ -0,0 +1,6 @@ +#!/bin/sh +set -eu +repo_dir=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +helm lint "$repo_dir/chart" +helm template loops "$repo_dir/chart" --namespace loops >/dev/null +helm template loops "$repo_dir/chart" --namespace loops --set ingress.enabled=true >/dev/null