Compare commits
9
Commits
73c3dc0541
..
pro
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bc101b3278 | ||
|
|
39b0392545 | ||
|
|
93a6ff0d03 | ||
|
|
9c10862a6d | ||
|
|
5197960e58 | ||
|
|
fe5f870b6b | ||
|
|
5b270860e7 | ||
|
|
209f105b12 | ||
|
|
5d49008960 |
+53
-102
@@ -1,118 +1,69 @@
|
|||||||
name: Deploy im-server (k3s test cluster)
|
name: Deploy im-server production
|
||||||
|
|
||||||
on:
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- pro
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
|
||||||
image_tag:
|
concurrency:
|
||||||
description: '镜像tag,默认为 test(与集群 kustomization 里的 newTag 一致)'
|
group: production-im-server
|
||||||
required: false
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
env:
|
||||||
|
REGISTRY: git.olns.shop
|
||||||
|
IMAGE: git.olns.shop/pro/im_server
|
||||||
|
SERVICE: im-server
|
||||||
|
DEPLOY_HOST: 145.223.69.9
|
||||||
|
DEPLOY_USER: olns-deploy
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
deploy:
|
deploy:
|
||||||
runs-on: deploy-host
|
runs-on: deploy-host
|
||||||
|
timeout-minutes: 60
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: 确定镜像tag
|
- name: Log in to Gitea Container Registry
|
||||||
id: vars
|
env:
|
||||||
|
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
TAG="${{ github.event.inputs.image_tag }}"
|
set -euo pipefail
|
||||||
[ -z "$TAG" ] && TAG=test
|
printf '%s' "$REGISTRY_TOKEN" | docker login "$REGISTRY" -u admin --password-stdin
|
||||||
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
|
|
||||||
|
|
||||||
- name: 登录镜像仓库
|
- name: Build and push image
|
||||||
run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login git.118im.com -u admin --password-stdin
|
env:
|
||||||
|
GITHUB_SHA: ${{ github.sha }}
|
||||||
- name: 构建并推送镜像(从本仓库源码编译,不是官方预编译包)
|
|
||||||
run: |
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
SHORT_SHA="$(printf '%.12s' "$GITHUB_SHA")"
|
||||||
docker build -f docker/tinode/Dockerfile.build \
|
docker build -f docker/tinode/Dockerfile.build \
|
||||||
-t git.118im.com/im_team/im-server:${{ steps.vars.outputs.tag }} .
|
-t "${IMAGE}:pro" -t "${IMAGE}:${SHORT_SHA}" .
|
||||||
docker push git.118im.com/im_team/im-server:${{ steps.vars.outputs.tag }}
|
docker push "$IMAGE:pro"
|
||||||
|
docker push "$IMAGE:$SHORT_SHA"
|
||||||
|
|
||||||
- name: 部署到k3s测试集群
|
- name: Deploy Compose service
|
||||||
|
env:
|
||||||
|
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
|
||||||
|
DEPLOY_KNOWN_HOSTS: ${{ secrets.DEPLOY_KNOWN_HOSTS }}
|
||||||
|
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
mkdir -p ~/.kube
|
set -euo pipefail
|
||||||
echo "${{ secrets.KUBECONFIG }}" > ~/.kube/config
|
install -d -m 700 "$HOME/.ssh"
|
||||||
chmod 600 ~/.kube/config
|
KEY_FILE="$(mktemp "$HOME/.ssh/olns-deploy.XXXXXX")"
|
||||||
kubectl set resources statefulset/im-server -n im-server-system -c im-server \
|
cleanup() {
|
||||||
--requests=cpu=2,memory=512Mi --limits=cpu=2,memory=1Gi
|
shred -u "$KEY_FILE" 2>/dev/null || true
|
||||||
kubectl set env statefulset/im-server -n im-server-system -c im-server \
|
}
|
||||||
CLUSTER_PROXY_TO_MASTER_BUFFER=4096 \
|
trap cleanup EXIT
|
||||||
CLUSTER_PROXY_TO_MASTER_WORKERS=16 \
|
printf '%s\n' "$DEPLOY_SSH_KEY" > "$KEY_FILE"
|
||||||
CLUSTER_PROXY_TO_MASTER_ENQUEUE_TIMEOUT_MS=300 \
|
chmod 600 "$KEY_FILE"
|
||||||
CLUSTER_ROUTE_RETRIES=3 \
|
printf '%s\n' "$DEPLOY_KNOWN_HOSTS" > "$HOME/.ssh/known_hosts"
|
||||||
CLUSTER_ROUTE_RETRY_BACKOFF_MS=25 \
|
chmod 600 "$HOME/.ssh/known_hosts"
|
||||||
MYSQL_MAX_OPEN_CONNS=24 \
|
SSH_ARGS=(-i "$KEY_FILE" -o BatchMode=yes -o StrictHostKeyChecking=yes)
|
||||||
MYSQL_MAX_IDLE_CONNS=8 \
|
printf '%s' "$REGISTRY_TOKEN" | ssh "${SSH_ARGS[@]}" "$DEPLOY_USER@$DEPLOY_HOST" \
|
||||||
MYSQL_REGISTRY_MAX_OPEN_CONNS=4 \
|
"docker login git.olns.shop -u admin --password-stdin"
|
||||||
MYSQL_REGISTRY_MAX_IDLE_CONNS=2 \
|
ssh "${SSH_ARGS[@]}" "$DEPLOY_USER@$DEPLOY_HOST" \
|
||||||
MYSQL_CONN_MAX_LIFETIME_SECONDS=1800
|
"cd /opt/olns && docker compose pull im-server && docker compose up -d --no-deps --wait im-server"
|
||||||
kubectl patch statefulset/im-server -n im-server-system --type=strategic -p \
|
|
||||||
'{"spec":{"template":{"spec":{"containers":[{"name":"im-server","readinessProbe":{"httpGet":{"path":"/healthz","port":6060},"timeoutSeconds":3,"periodSeconds":10,"failureThreshold":3},"livenessProbe":{"httpGet":{"path":"/livez","port":6060},"timeoutSeconds":3,"periodSeconds":20,"failureThreshold":3}}]}}}}'
|
|
||||||
if kubectl auth can-i get services -n im-server-system >/dev/null 2>&1 && \
|
|
||||||
kubectl auth can-i patch services -n im-server-system >/dev/null 2>&1; then
|
|
||||||
kubectl patch service/im-server -n im-server-system --type=merge -p \
|
|
||||||
'{"spec":{"sessionAffinity":"None"}}'
|
|
||||||
else
|
|
||||||
echo "::warning::ci-deployer cannot get/patch Service; skipped sessionAffinity update"
|
|
||||||
fi
|
|
||||||
if kubectl auth can-i get ingresses.networking.k8s.io -n im-server-system >/dev/null 2>&1 && \
|
|
||||||
kubectl auth can-i patch ingresses.networking.k8s.io -n im-server-system >/dev/null 2>&1 && \
|
|
||||||
kubectl get ingress/im-server -n im-server-system >/dev/null 2>&1; then
|
|
||||||
kubectl annotate ingress/im-server -n im-server-system --overwrite \
|
|
||||||
nginx.ingress.kubernetes.io/load-balance=least_conn
|
|
||||||
else
|
|
||||||
echo "::warning::ci-deployer cannot manage Ingress/im-server or it does not exist; skipped least_conn update"
|
|
||||||
fi
|
|
||||||
kubectl rollout restart statefulset/im-server -n im-server-system
|
|
||||||
if ! kubectl rollout status statefulset/im-server -n im-server-system --timeout=300s; then
|
|
||||||
echo "::error::im-server rollout did not become ready within 300 seconds"
|
|
||||||
kubectl get statefulset/im-server -n im-server-system -o wide || true
|
|
||||||
kubectl get pods -n im-server-system -l app=im-server -o wide || true
|
|
||||||
kubectl describe statefulset/im-server -n im-server-system || true
|
|
||||||
for pod in $(kubectl get pods -n im-server-system -l app=im-server -o name 2>/dev/null); do
|
|
||||||
echo "===== ${pod} describe ====="
|
|
||||||
kubectl describe -n im-server-system "${pod}" || true
|
|
||||||
echo "===== ${pod} current log ====="
|
|
||||||
kubectl logs -n im-server-system "${pod}" -c im-server --tail=200 || true
|
|
||||||
echo "===== ${pod} previous log ====="
|
|
||||||
kubectl logs -n im-server-system "${pod}" -c im-server --previous --tail=200 || true
|
|
||||||
done
|
|
||||||
if kubectl auth can-i list events -n im-server-system >/dev/null 2>&1; then
|
|
||||||
echo "===== recent events ====="
|
|
||||||
kubectl get events -n im-server-system --sort-by=.lastTimestamp | tail -100 || true
|
|
||||||
fi
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Waiting for ArgoCD reconciliation before validating effective settings"
|
|
||||||
sleep 60
|
|
||||||
request_cpu="$(kubectl get statefulset/im-server -n im-server-system -o \
|
|
||||||
jsonpath='{.spec.template.spec.containers[?(@.name=="im-server")].resources.requests.cpu}')"
|
|
||||||
request_memory="$(kubectl get statefulset/im-server -n im-server-system -o \
|
|
||||||
jsonpath='{.spec.template.spec.containers[?(@.name=="im-server")].resources.requests.memory}')"
|
|
||||||
limit_cpu="$(kubectl get statefulset/im-server -n im-server-system -o \
|
|
||||||
jsonpath='{.spec.template.spec.containers[?(@.name=="im-server")].resources.limits.cpu}')"
|
|
||||||
limit_memory="$(kubectl get statefulset/im-server -n im-server-system -o \
|
|
||||||
jsonpath='{.spec.template.spec.containers[?(@.name=="im-server")].resources.limits.memory}')"
|
|
||||||
liveness="$(kubectl get statefulset/im-server -n im-server-system -o \
|
|
||||||
jsonpath='{.spec.template.spec.containers[?(@.name=="im-server")].livenessProbe.httpGet.path}')"
|
|
||||||
mysql_max_open="$(kubectl get statefulset/im-server -n im-server-system -o \
|
|
||||||
jsonpath='{.spec.template.spec.containers[?(@.name=="im-server")].env[?(@.name=="MYSQL_MAX_OPEN_CONNS")].value}')"
|
|
||||||
mysql_max_idle="$(kubectl get statefulset/im-server -n im-server-system -o \
|
|
||||||
jsonpath='{.spec.template.spec.containers[?(@.name=="im-server")].env[?(@.name=="MYSQL_MAX_IDLE_CONNS")].value}')"
|
|
||||||
mysql_registry_max_open="$(kubectl get statefulset/im-server -n im-server-system -o \
|
|
||||||
jsonpath='{.spec.template.spec.containers[?(@.name=="im-server")].env[?(@.name=="MYSQL_REGISTRY_MAX_OPEN_CONNS")].value}')"
|
|
||||||
mysql_registry_max_idle="$(kubectl get statefulset/im-server -n im-server-system -o \
|
|
||||||
jsonpath='{.spec.template.spec.containers[?(@.name=="im-server")].env[?(@.name=="MYSQL_REGISTRY_MAX_IDLE_CONNS")].value}')"
|
|
||||||
mysql_conn_max_lifetime="$(kubectl get statefulset/im-server -n im-server-system -o \
|
|
||||||
jsonpath='{.spec.template.spec.containers[?(@.name=="im-server")].env[?(@.name=="MYSQL_CONN_MAX_LIFETIME_SECONDS")].value}')"
|
|
||||||
echo "effective requests=${request_cpu}/${request_memory} limits=${limit_cpu}/${limit_memory} liveness=${liveness} mysql=${mysql_max_open}/${mysql_max_idle} registry=${mysql_registry_max_open}/${mysql_registry_max_idle} lifetime=${mysql_conn_max_lifetime}"
|
|
||||||
if [ "${request_cpu}" != "2" ] || [ "${request_memory}" != "512Mi" ] || \
|
|
||||||
[ "${limit_cpu}" != "2" ] || [ "${limit_memory}" != "1Gi" ] || \
|
|
||||||
[ "${liveness}" != "/livez" ] || [ "${mysql_max_open}" != "24" ] || \
|
|
||||||
[ "${mysql_max_idle}" != "8" ] || [ "${mysql_registry_max_open}" != "4" ] || \
|
|
||||||
[ "${mysql_registry_max_idle}" != "2" ] || [ "${mysql_conn_max_lifetime}" != "1800" ]; then
|
|
||||||
echo "::error::ArgoCD reverted StatefulSet resources/probes/MySQL pools. Apply deploy/argocd/im-server-test/statefulset-performance-patch.yaml to the im-server-test source."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|||||||
@@ -177,7 +177,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"admin_internal_api": {
|
"admin_internal_api": {
|
||||||
"enabled": true,
|
"enabled": $ADMIN_INTERNAL_API_ENABLED,
|
||||||
"base_url": "$ADMIN_INTERNAL_API_BASE_URL",
|
"base_url": "$ADMIN_INTERNAL_API_BASE_URL",
|
||||||
"service_name": "$ADMIN_INTERNAL_API_SERVICE_NAME",
|
"service_name": "$ADMIN_INTERNAL_API_SERVICE_NAME",
|
||||||
"secret": "$ADMIN_INTERNAL_API_SECRET",
|
"secret": "$ADMIN_INTERNAL_API_SECRET",
|
||||||
|
|||||||
@@ -72,6 +72,19 @@ func TestMessageQuerySpecsExposeAdminFields(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestPrivateMessageQueryUsesTenantShardTables(t *testing.T) {
|
||||||
|
spec := privateMessagesInternalQuerySpecForTenant(types.TenantID(9))
|
||||||
|
query := strings.ToLower(spec.From)
|
||||||
|
for _, table := range []string{"im_009_messages", "im_009_subscriptions"} {
|
||||||
|
if !strings.Contains(query, table) {
|
||||||
|
t.Fatalf("private message query must use tenant shard table %q: %s", table, query)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(query, "from im_messages ") || strings.Contains(query, "from im_subscriptions ") {
|
||||||
|
t.Fatalf("private message query must not use legacy shared tables: %s", query)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestGroupMessageQuerySupportsOperationalFilters(t *testing.T) {
|
func TestGroupMessageQuerySupportsOperationalFilters(t *testing.T) {
|
||||||
req := httptest.NewRequest("GET", "/?seq_id=42&sender_username=alice&sender_nickname=%E5%B0%8F%E6%98%8E&group_name=%E6%B5%8B%E8%AF%95%E7%BE%A4", nil)
|
req := httptest.NewRequest("GET", "/?seq_id=42&sender_username=alice&sender_nickname=%E5%B0%8F%E6%98%8E&group_name=%E6%B5%8B%E8%AF%95%E7%BE%A4", nil)
|
||||||
query, _, args, _, _, err := buildInternalAdminListQuery(req, groupMessagesInternalQuerySpec(), "", types.TenantID(1))
|
query, _, args, _, _, err := buildInternalAdminListQuery(req, groupMessagesInternalQuerySpec(), "", types.TenantID(1))
|
||||||
|
|||||||
@@ -147,6 +147,7 @@ func (s *testHTTPInfrastructureStore) MarkKafkaDeadLetterReplay(_ types.TenantID
|
|||||||
type testRelationsStore struct {
|
type testRelationsStore struct {
|
||||||
context *types.RelationshipContext
|
context *types.RelationshipContext
|
||||||
err error
|
err error
|
||||||
|
directAdded bool
|
||||||
created *types.FriendRequest
|
created *types.FriendRequest
|
||||||
blocked *types.UserBlock
|
blocked *types.UserBlock
|
||||||
deleted bool
|
deleted bool
|
||||||
@@ -964,6 +965,11 @@ func (s *testRelationsStore) CreateFriendRequest(_ types.TenantID, req *types.Fr
|
|||||||
return req, nil
|
return req, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *testRelationsStore) AddFriendDirect(_ types.TenantID, _, _ types.Uid, _ time.Time) error {
|
||||||
|
s.directAdded = true
|
||||||
|
return s.err
|
||||||
|
}
|
||||||
|
|
||||||
func (s *testRelationsStore) ListFriendRequests(_ types.TenantID, _ types.Uid, _, _, _ string, _ int) ([]types.FriendRequestListItem, string, error) {
|
func (s *testRelationsStore) ListFriendRequests(_ types.TenantID, _ types.Uid, _, _, _ string, _ int) ([]types.FriendRequestListItem, string, error) {
|
||||||
return s.friendRequests, "", nil
|
return s.friendRequests, "", nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -127,6 +127,9 @@ func serveInternalAdminResourceList(wrt http.ResponseWriter, req *http.Request,
|
|||||||
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "resource not found", nil)
|
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "resource not found", nil)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if resource == "private-messages" {
|
||||||
|
spec = privateMessagesInternalQuerySpecForTenant(tenantID)
|
||||||
|
}
|
||||||
if resource == "group-messages" {
|
if resource == "group-messages" {
|
||||||
var err error
|
var err error
|
||||||
spec, err = groupMessagesInternalQuerySpecForTenant(tenantID)
|
spec, err = groupMessagesInternalQuerySpecForTenant(tenantID)
|
||||||
@@ -400,6 +403,14 @@ func privateMessagesInternalQuerySpec() *internalQuerySpec {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func privateMessagesInternalQuerySpecForTenant(tenantID types.TenantID) *internalQuerySpec {
|
||||||
|
spec := privateMessagesInternalQuerySpec()
|
||||||
|
prefix := fmt.Sprintf("im_%03d_", tenantID)
|
||||||
|
spec.From = strings.Replace(spec.From, "FROM im_messages ", "FROM "+prefix+"messages ", 1)
|
||||||
|
spec.From = strings.ReplaceAll(spec.From, "FROM im_subscriptions ", "FROM "+prefix+"subscriptions ")
|
||||||
|
return spec
|
||||||
|
}
|
||||||
|
|
||||||
func groupMessagesInternalQuerySpec() *internalQuerySpec {
|
func groupMessagesInternalQuerySpec() *internalQuerySpec {
|
||||||
status := `CASE WHEN aa.action='delete' THEN 'deleted' WHEN aa.action='hide' THEN 'hidden' WHEN m.status='recalled' THEN 'recalled' WHEN m.status IN ('deleted','blocked') THEN m.status ELSE 'normal' END`
|
status := `CASE WHEN aa.action='delete' THEN 'deleted' WHEN aa.action='hide' THEN 'hidden' WHEN m.status='recalled' THEN 'recalled' WHEN m.status IN ('deleted','blocked') THEN m.status ELSE 'normal' END`
|
||||||
contentType := `CASE m.msg_type WHEN 'call' THEN 'system' WHEN 'system_notice' THEN 'system' WHEN 'contact_card' THEN 'card' WHEN 'forward_bundle' THEN 'card' WHEN 'link' THEN 'text' ELSE m.msg_type END`
|
contentType := `CASE m.msg_type WHEN 'call' THEN 'system' WHEN 'system_notice' THEN 'system' WHEN 'contact_card' THEN 'card' WHEN 'forward_bundle' THEN 'card' WHEN 'link' THEN 'text' ELSE m.msg_type END`
|
||||||
|
|||||||
@@ -116,6 +116,8 @@ func serveInternalAdminQuery(wrt http.ResponseWriter, req *http.Request, prefix
|
|||||||
serveInternalUserCreate(wrt, req, tenant.ID)
|
serveInternalUserCreate(wrt, req, tenant.ID)
|
||||||
case req.Method == http.MethodPost && len(parts) == 5 && parts[2] == "users" && parts[4] == "action":
|
case req.Method == http.MethodPost && len(parts) == 5 && parts[2] == "users" && parts[4] == "action":
|
||||||
serveInternalUserAction(wrt, req, tenant.ID, parts[3])
|
serveInternalUserAction(wrt, req, tenant.ID, parts[3])
|
||||||
|
case req.Method == http.MethodPost && len(parts) == 4 && parts[2] == "friends" && parts[3] == "direct":
|
||||||
|
serveInternalFriendDirectAdd(wrt, req, tenant.ID)
|
||||||
case req.Method == http.MethodPost && len(parts) == 4 && parts[2] == "login-lockouts" && parts[3] == "release":
|
case req.Method == http.MethodPost && len(parts) == 4 && parts[2] == "login-lockouts" && parts[3] == "release":
|
||||||
serveInternalLoginLockoutRelease(wrt, req, tenant.ID)
|
serveInternalLoginLockoutRelease(wrt, req, tenant.ID)
|
||||||
case req.Method == http.MethodGet && len(parts) == 3 && parts[2] == "users":
|
case req.Method == http.MethodGet && len(parts) == 3 && parts[2] == "users":
|
||||||
@@ -166,9 +168,37 @@ func serveInternalAdminQuery(wrt http.ResponseWriter, req *http.Request, prefix
|
|||||||
func isInternalAdminPostRoute(parts []string) bool {
|
func isInternalAdminPostRoute(parts []string) bool {
|
||||||
return (len(parts) == 3 && parts[2] == "users") ||
|
return (len(parts) == 3 && parts[2] == "users") ||
|
||||||
(len(parts) == 5 && parts[2] == "users" && parts[4] == "action") ||
|
(len(parts) == 5 && parts[2] == "users" && parts[4] == "action") ||
|
||||||
|
(len(parts) == 4 && parts[2] == "friends" && parts[3] == "direct") ||
|
||||||
(len(parts) == 4 && parts[2] == "login-lockouts" && parts[3] == "release")
|
(len(parts) == 4 && parts[2] == "login-lockouts" && parts[3] == "release")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type internalDirectFriendRequest struct {
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
PeerID string `json:"peer_user_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func serveInternalFriendDirectAdd(wrt http.ResponseWriter, req *http.Request, tenantID types.TenantID) {
|
||||||
|
var body internalDirectFriendRequest
|
||||||
|
if err := json.NewDecoder(io.LimitReader(req.Body, 1<<20)).Decode(&body); err != nil {
|
||||||
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
userID := types.ParseUserId(strings.TrimSpace(body.UserID))
|
||||||
|
peerID := types.ParseUserId(strings.TrimSpace(body.PeerID))
|
||||||
|
if userID.IsZero() || peerID.IsZero() || userID == peerID {
|
||||||
|
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed user IDs", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := store.Relations.AddFriendDirect(tenantID, userID, peerID, time.Now().UTC().Round(time.Millisecond)); err != nil {
|
||||||
|
writeIMRelationError(wrt, req, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeIMSuccess(wrt, req, map[string]any{
|
||||||
|
"relationship": "friend",
|
||||||
|
"topic": userID.P2PName(peerID),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
type internalUserCreateRequest struct {
|
type internalUserCreateRequest struct {
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
Nickname string `json:"nickname"`
|
Nickname string `json:"nickname"`
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -37,6 +38,35 @@ func (internalAdminQueryNotFound) QueryOne(types.TenantID, string, []any) (map[s
|
|||||||
return nil, types.ErrNotFound
|
return nil, types.ErrNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestServeInternalFriendDirectAddCreatesRelationshipWithoutRequest(t *testing.T) {
|
||||||
|
oldRelations := store.Relations
|
||||||
|
relations := &testRelationsStore{}
|
||||||
|
store.Relations = relations
|
||||||
|
t.Cleanup(func() { store.Relations = oldRelations })
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/internal/admin-query/tenants/default/friends/direct", strings.NewReader(`{"user_id":"usrAQAAAAAAAAA","peer_user_id":"usrAgAAAAAAAAA"}`))
|
||||||
|
wrt := httptest.NewRecorder()
|
||||||
|
serveInternalFriendDirectAdd(wrt, req, types.TenantID(1))
|
||||||
|
|
||||||
|
if wrt.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status: got %d, want %d; body=%s", wrt.Code, http.StatusOK, wrt.Body.String())
|
||||||
|
}
|
||||||
|
if !relations.directAdded || relations.created != nil {
|
||||||
|
t.Fatalf("direct_added=%v friend_request=%v, want direct add and no request", relations.directAdded, relations.created)
|
||||||
|
}
|
||||||
|
var payload struct {
|
||||||
|
Data struct {
|
||||||
|
Relationship string `json:"relationship"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(wrt.Body.Bytes(), &payload); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if payload.Data.Relationship != types.RelationshipFriend {
|
||||||
|
t.Fatalf("relationship = %q, want %q", payload.Data.Relationship, types.RelationshipFriend)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestServeInternalUserQueryMissingUserReturnsNotFound(t *testing.T) {
|
func TestServeInternalUserQueryMissingUserReturnsNotFound(t *testing.T) {
|
||||||
ctrl := gomock.NewController(t)
|
ctrl := gomock.NewController(t)
|
||||||
defer ctrl.Finish()
|
defer ctrl.Finish()
|
||||||
|
|||||||
@@ -3065,6 +3065,7 @@ func validUserProfilePatch(patch types.UserProfilePatch) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type RelationsPersistenceInterface interface {
|
type RelationsPersistenceInterface interface {
|
||||||
|
AddFriendDirect(tenantID types.TenantID, userID, peerID types.Uid, now time.Time) error
|
||||||
CreateFriendRequest(tenantID types.TenantID, req *types.FriendRequest) (*types.FriendRequest, error)
|
CreateFriendRequest(tenantID types.TenantID, req *types.FriendRequest) (*types.FriendRequest, error)
|
||||||
ListFriendRequests(tenantID types.TenantID, uid types.Uid, direction, status, cursor string, limit int) ([]types.FriendRequestListItem, string, error)
|
ListFriendRequests(tenantID types.TenantID, uid types.Uid, direction, status, cursor string, limit int) ([]types.FriendRequestListItem, string, error)
|
||||||
CountPendingFriendRequests(tenantID types.TenantID, uid types.Uid) (int, error)
|
CountPendingFriendRequests(tenantID types.TenantID, uid types.Uid) (int, error)
|
||||||
@@ -3153,6 +3154,41 @@ func (relationsMapper) CreateFriendRequest(tenantID types.TenantID, req *types.F
|
|||||||
return tenantAdp.TenantFriendRequestCreate(tenantID, req)
|
return tenantAdp.TenantFriendRequestCreate(tenantID, req)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AddFriendDirect creates a symmetric friendship for trusted server-side
|
||||||
|
// workflows, such as opening a customer-service conversation. It deliberately
|
||||||
|
// does not create a friend-request record, but it still honors either user's
|
||||||
|
// block list.
|
||||||
|
func (relationsMapper) AddFriendDirect(tenantID types.TenantID, userID, peerID types.Uid, now time.Time) error {
|
||||||
|
if userID.IsZero() || peerID.IsZero() || userID == peerID {
|
||||||
|
return types.ErrMalformed
|
||||||
|
}
|
||||||
|
if _, err := Users.Get(tenantID, userID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := Users.Get(tenantID, peerID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tenantAdp, err := tenantBusinessAdapter(tenantID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
relationship, err := relationshipContext(tenantAdp, tenantID, userID, peerID, "")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if relationship.Relationship == types.RelationshipBlockedByMe || relationship.Relationship == types.RelationshipBlockedByPeer {
|
||||||
|
return types.ErrPermissionDenied
|
||||||
|
}
|
||||||
|
if now.IsZero() {
|
||||||
|
now = time.Now().UTC()
|
||||||
|
}
|
||||||
|
if err = tenantAdp.TenantFriendshipUpsertPair(tenantID, userID, peerID, userID.P2PName(peerID), now); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
cacheFriendPair(tenantID, userID, peerID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (relationsMapper) ListFriendRequests(tenantID types.TenantID, uid types.Uid, direction, status, cursor string, limit int) ([]types.FriendRequestListItem, string, error) {
|
func (relationsMapper) ListFriendRequests(tenantID types.TenantID, uid types.Uid, direction, status, cursor string, limit int) ([]types.FriendRequestListItem, string, error) {
|
||||||
if uid.IsZero() {
|
if uid.IsZero() {
|
||||||
return nil, "", types.ErrMalformed
|
return nil, "", types.ErrMalformed
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
|
||||||
|
WORKFLOW = Path(__file__).resolve().parents[1] / ".gitea/workflows/deploy.yml"
|
||||||
|
|
||||||
|
|
||||||
|
class DeployWorkflowContractTest(unittest.TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
cls.workflow = WORKFLOW.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
def test_targets_pro_branch_and_single_runner(self):
|
||||||
|
self.assertIn("push:", self.workflow)
|
||||||
|
self.assertIn("- pro", self.workflow)
|
||||||
|
self.assertIn("workflow_dispatch:", self.workflow)
|
||||||
|
self.assertIn("runs-on: deploy-host", self.workflow)
|
||||||
|
self.assertIn("concurrency:", self.workflow)
|
||||||
|
|
||||||
|
def test_builds_and_pushes_pro_image(self):
|
||||||
|
self.assertIn("git.olns.shop/pro/im_server", self.workflow)
|
||||||
|
self.assertIn("docker push", self.workflow)
|
||||||
|
self.assertIn(":pro", self.workflow)
|
||||||
|
|
||||||
|
def test_deploys_compose_service_with_pinned_host_key(self):
|
||||||
|
self.assertIn("DEPLOY_SSH_KEY", self.workflow)
|
||||||
|
self.assertIn("DEPLOY_KNOWN_HOSTS", self.workflow)
|
||||||
|
self.assertIn("StrictHostKeyChecking=yes", self.workflow)
|
||||||
|
self.assertIn("docker compose pull im-server", self.workflow)
|
||||||
|
self.assertIn("docker compose up -d --no-deps --wait im-server", self.workflow)
|
||||||
|
|
||||||
|
def test_removes_old_kubernetes_and_registry_targets(self):
|
||||||
|
self.assertNotIn("kubectl", self.workflow)
|
||||||
|
self.assertNotIn("KUBECONFIG", self.workflow)
|
||||||
|
self.assertNotIn("git.118im.com", self.workflow)
|
||||||
|
|
||||||
|
def test_deploy_does_not_mutate_shared_caddy_configuration(self):
|
||||||
|
self.assertNotIn("Ensure production IM CORS routing", self.workflow)
|
||||||
|
self.assertNotIn("Caddyfile", self.workflow)
|
||||||
|
self.assertNotIn("perl -0pi", self.workflow)
|
||||||
|
self.assertNotIn("force-recreate caddy", self.workflow)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
|
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
|
||||||
|
TEMPLATE = Path(__file__).resolve().parents[1] / "docker/tinode/config.template"
|
||||||
|
|
||||||
|
|
||||||
|
class TinodeConfigTemplateTest(unittest.TestCase):
|
||||||
|
def test_admin_internal_api_enabled_comes_from_environment(self):
|
||||||
|
template = TEMPLATE.read_text(encoding="utf-8")
|
||||||
|
section = template.split('"admin_internal_api": {', 1)[1].split("},", 1)[0]
|
||||||
|
|
||||||
|
self.assertIn('"enabled": $ADMIN_INTERNAL_API_ENABLED', section)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user