Package loops Kubernetes deployment
This commit is contained in:
@@ -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
|
||||
@@ -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.
|
||||
@@ -0,0 +1,6 @@
|
||||
apiVersion: v2
|
||||
name: loops
|
||||
description: Kubernetes deployment contract for loops
|
||||
type: application
|
||||
version: 0.0.0-f614b34f
|
||||
appVersion: "f614b34f"
|
||||
@@ -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: |-
|
||||
<?php
|
||||
|
||||
return [
|
||||
'default' => '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 }}
|
||||
@@ -0,0 +1,760 @@
|
||||
apiVersion: v1
|
||||
data:
|
||||
ForYouFeedService.php: |
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Http\Resources\VideoResource;
|
||||
use App\Models\FeedFeedback;
|
||||
use App\Models\Profile;
|
||||
use App\Models\UserInterest;
|
||||
use App\Models\Video;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class ForYouFeedService
|
||||
{
|
||||
private const DEFAULT_LIMIT = 20;
|
||||
|
||||
private const FRESHNESS_WEIGHT = 0.25;
|
||||
|
||||
private const ENGAGEMENT_WEIGHT = 0.35;
|
||||
|
||||
private const PERSONALIZATION_WEIGHT = 0.30;
|
||||
|
||||
private const CREATOR_QUALITY_WEIGHT = 0.10;
|
||||
|
||||
private const MAX_AGE_DAYS = 730;
|
||||
|
||||
private const MIN_SCORE_THRESHOLD = 0.1;
|
||||
|
||||
private const MIN_RESULTS = 20;
|
||||
|
||||
private const DISCOVERY_MIX_RATIO = 0.3;
|
||||
|
||||
public function getFeed(
|
||||
Profile $profile,
|
||||
?string $cursor = null,
|
||||
int $limit = self::DEFAULT_LIMIT
|
||||
): array {
|
||||
$videos = $this->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: |
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\FeedImpression;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Redis;
|
||||
|
||||
class ImpressionBloomFilterService
|
||||
{
|
||||
private const FILTER_KEY_PREFIX = 'loops:bf:impressions:';
|
||||
|
||||
private const ERROR_RATE = 0.01;
|
||||
|
||||
private const EXPECTED_ITEMS = 10000;
|
||||
|
||||
private const TTL_SECONDS = 1209600;
|
||||
|
||||
private const HYDRATION_DAYS = 90;
|
||||
|
||||
public function add(int $profileId, int $videoId): void
|
||||
{
|
||||
$key = $this->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 }}
|
||||
@@ -0,0 +1,236 @@
|
||||
apiVersion: v1
|
||||
data:
|
||||
FollowingFeedServiceProvider.php: |
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Http\Controllers\Api\FeedController;
|
||||
use App\Http\Controllers\Api\RandomizedFeedController;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
class FollowingFeedServiceProvider extends ServiceProvider
|
||||
{
|
||||
public function register(): void
|
||||
{
|
||||
$enabled = filter_var(
|
||||
env('LOOPS_FOLLOWING_RANDOMIZED', false),
|
||||
FILTER_VALIDATE_BOOLEAN
|
||||
);
|
||||
|
||||
if ($enabled) {
|
||||
$this->app->bind(FeedController::class, RandomizedFeedController::class);
|
||||
}
|
||||
}
|
||||
}
|
||||
RandomizedFeedController.php: |
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Resources\VideoResource;
|
||||
use App\Models\Video;
|
||||
use App\Services\RandomizedFollowingFeedService;
|
||||
use App\Services\UserActivityService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class RandomizedFeedController extends FeedController
|
||||
{
|
||||
public function __construct(
|
||||
private RandomizedFollowingFeedService $followingFeed
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
public function getFollowingFeed(Request $request): JsonResponse
|
||||
{
|
||||
$user = $request->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: |
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Models\Video;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
class RandomizedFollowingFeedService
|
||||
{
|
||||
private const SESSION_TTL_SECONDS = 21600;
|
||||
|
||||
/**
|
||||
* Return one stable page from a randomly ordered snapshot.
|
||||
*
|
||||
* A request without a cursor creates a new snapshot. Subsequent cursor
|
||||
* requests read from that snapshot, preventing repeats while scrolling.
|
||||
*
|
||||
* @return array{videos: Collection, next_cursor: ?string, has_more: bool}
|
||||
*/
|
||||
public function getPage(User $user, ?string $cursor, int $limit = 5): array
|
||||
{
|
||||
$limit = max(1, min($limit, 20));
|
||||
$profileId = (int) $user->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<int, int> */
|
||||
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: |
|
||||
<?php
|
||||
|
||||
// Pinned to Loops 1.0.0-beta.14. Compare this list with the new image's
|
||||
// bootstrap/providers.php before changing the Loops image tag.
|
||||
return [
|
||||
App\Providers\AppServiceProvider::class,
|
||||
App\Providers\HorizonServiceProvider::class,
|
||||
App\Providers\LiveServiceProvider::class,
|
||||
App\Providers\FollowingFeedServiceProvider::class,
|
||||
];
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: loops-following-feed-hook
|
||||
namespace: {{ .Release.Namespace }}
|
||||
@@ -0,0 +1,10 @@
|
||||
apiVersion: v1
|
||||
data:
|
||||
index-Cng6wq5r.js: |
|
||||
import{_ as h}from"./HideCommentConfirmModal-QPWnMLxt.js";import{u as k,b as y,a as g,S as v}from"./LoopLink.vue_vue_type_script_setup_true_lang-D1dGRmzo.js";import{u as x}from"./usePublicFeed-BqqecCdM.js";import{c as P}from"./app--YWhAMOJ.js";import{V}from"./VideoPlayer-DP4y4yM4.js";import{y as b,d as F,w as I,a as S,c as C,e as r,u as w,q,ay as B}from"./vendor-core-C7C5leYv.js";import"./Comments-ByHWOGrH.js";import"./KlipyPicker-CbkaMsji.js";import"./comments-CRjqfgs4.js";import"./useHashids-DT1SW4G7.js";import"./AutolinkedText-BAfi_gt-.js";import"./CommentMediaAttachment-DfTLtvaG.js";import"./__vite-browser-external-ke9ZaMpT.js";/* empty css *//* empty css */import"./ShareModal-Bb3hVgNV.js";import"./DmQuickShare-C1LTf34j.js";import"./DmShareModal-DB3TK55A.js";const H=()=>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 }}
|
||||
@@ -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 }}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
Executable
+8
@@ -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 "$@"
|
||||
Executable
+8
@@ -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"
|
||||
Executable
+6
@@ -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
|
||||
Reference in New Issue
Block a user