mirror of
https://github.com/iflytek/skillhub.git
synced 2026-08-27 11:14:59 +00:00
test(helm): add reproducible upgrade smoke coverage
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
This commit is contained in:
parent
2854b26b5d
commit
0efc91b73f
8 changed files with 255 additions and 6 deletions
25
.github/workflows/pr-helm-chart.yml
vendored
25
.github/workflows/pr-helm-chart.yml
vendored
|
|
@ -180,3 +180,28 @@ jobs:
|
|||
with:
|
||||
entrypoint: '/kubeconform'
|
||||
args: "-strict -summary -output text -schema-location default -schema-location https://raw.githubusercontent.com/datreeio/CRDs-catalog/main/{{.Group}}/{{.ResourceKind}}_{{.ResourceAPIVersion}}.json charts/skillhub/rendered.yaml"
|
||||
|
||||
install-upgrade:
|
||||
name: Install and Upgrade Smoke
|
||||
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }}
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Helm
|
||||
uses: azure/setup-helm@v4
|
||||
with:
|
||||
version: v3.19.0
|
||||
|
||||
- name: Create Kubernetes cluster
|
||||
uses: helm/kind-action@ef37e7f390d99f746eb8b610417061a60e82a6cc # v1.14.0
|
||||
with:
|
||||
cluster_name: skillhub-helm-smoke
|
||||
wait: 120s
|
||||
|
||||
- name: Run install and upgrade smoke
|
||||
run: bash charts/skillhub/tests/install-upgrade-smoke.sh
|
||||
|
|
|
|||
|
|
@ -343,6 +343,7 @@ Basic Kubernetes manifests are available under [`deploy/k8s/`](./deploy/k8s):
|
|||
- `backend-deployment.yaml`
|
||||
- `frontend-deployment.yaml`
|
||||
- `services.yaml`
|
||||
- `ingress.yaml`
|
||||
|
||||
For a configurable deployment with bundled PostgreSQL and Redis dependencies,
|
||||
use the Helm chart under [`charts/skillhub/`](./charts/skillhub):
|
||||
|
|
@ -357,7 +358,6 @@ helm upgrade --install skillhub ./charts/skillhub \
|
|||
|
||||
See the [Helm chart guide](./charts/skillhub/README.md) for required secrets,
|
||||
Ingress/TLS, external data services, storage migration, and upgrade constraints.
|
||||
- `ingress.yaml`
|
||||
|
||||
Apply them after creating your own secret:
|
||||
|
||||
|
|
|
|||
|
|
@ -54,6 +54,16 @@ helm -n skillhub upgrade -i skillhub ./charts/skillhub \
|
|||
> `ingress.tls[]`。旧的 `ingress.host`、`ingress.tls.enabled` 与
|
||||
> `ingress.tls.secretName` 不再接受,升级前必须改成本文 Ingress 示例中的数组结构。
|
||||
|
||||
合并或发布前,可在一个空的测试 Kubernetes 集群中运行可重复的安装/升级 smoke:
|
||||
|
||||
```bash
|
||||
bash charts/skillhub/tests/install-upgrade-smoke.sh
|
||||
```
|
||||
|
||||
脚本验证 `install -> Ready -> HTTP health -> upgrade -> Ready`,并确认 Secret 数据、
|
||||
PVC UID 与绑定 PV 在升级前后保持不变。默认清理自己创建的 namespace;设置
|
||||
`KEEP_HELM_SMOKE=true` 可保留现场用于排查。
|
||||
|
||||
### 高可用模式
|
||||
|
||||
```bash
|
||||
|
|
@ -134,6 +144,11 @@ redis:
|
|||
可预测密码,而是在任何随机密码缺失时终止渲染并指出具体配置项。敏感值应放在
|
||||
受保护的 values、External Secrets、Sealed Secrets 或密钥注入插件中。
|
||||
|
||||
内置 PostgreSQL、Redis、Sentinel 及 metrics exporter 镜像默认使用不可变 digest,
|
||||
避免 Bitnami 子 Chart 的 `latest` 默认值造成不可复现的安装和回滚。覆盖私有镜像
|
||||
仓库或 tag 时,必须同时把对应的 `image.digest` 设为空,或改成私有仓库中该镜像
|
||||
的真实 digest;digest 非空时会优先于 tag。
|
||||
|
||||
## 配置参考
|
||||
|
||||
### 副本数配置
|
||||
|
|
@ -207,27 +222,32 @@ postgresql:
|
|||
registry: registry.example.com
|
||||
repository: library/postgresql
|
||||
tag: 18.4.0
|
||||
digest: ""
|
||||
metrics:
|
||||
image:
|
||||
registry: registry.example.com
|
||||
repository: library/postgres-exporter
|
||||
tag: 0.20.1
|
||||
digest: ""
|
||||
|
||||
redis:
|
||||
image:
|
||||
registry: registry.example.com
|
||||
repository: library/redis
|
||||
tag: 8.8.0
|
||||
digest: ""
|
||||
sentinel:
|
||||
image:
|
||||
registry: registry.example.com
|
||||
repository: library/redis-sentinel
|
||||
tag: 8.8.0
|
||||
digest: ""
|
||||
metrics:
|
||||
image:
|
||||
registry: registry.example.com
|
||||
repository: library/redis-exporter
|
||||
tag: 1.86.0
|
||||
digest: ""
|
||||
```
|
||||
|
||||
`global.security.allowInsecureImages` 是 Bitnami 对自定义镜像仓库和镜像名称的校验
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
{{- /* Cross-field validation that JSON Schema cannot express reliably. */ -}}
|
||||
{{- $absoluteHttpUrlPattern := "^https?://(\\[[0-9A-Fa-f:.]+\\]|[A-Za-z0-9]([A-Za-z0-9.-]*[A-Za-z0-9])?)(:[0-9]{1,5})?([/?#][^[:space:]]*)?$" -}}
|
||||
{{- if not .Values.server.enabled -}}
|
||||
{{- fail "server.enabled=false is unsupported because the bundled web component requires the SkillHub server" -}}
|
||||
{{- end -}}
|
||||
|
|
@ -17,7 +18,7 @@
|
|||
{{- end -}}
|
||||
{{- range $host := .Values.ingress.hosts -}}
|
||||
{{- range $path := $host.paths -}}
|
||||
{{- if regexMatch "^/(api|oauth2|login/oauth2|\\.well-known)(?:/|$)" $path.path -}}
|
||||
{{- if regexMatch "^/(api|oauth2|login/oauth2|\\.well-known)(/|$)" $path.path -}}
|
||||
{{- fail "ingress.hosts[].paths reserves /api, /oauth2, /login/oauth2 and /.well-known for the SkillHub server" -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
|
|
@ -61,10 +62,10 @@
|
|||
{{- if and (not .Values.redis.enabled) .Values.externalRedis.sentinel.enabled (not .Values.externalRedis.sentinel.nodes) -}}
|
||||
{{- fail "external Redis Sentinel requires at least one externalRedis.sentinel.nodes entry" -}}
|
||||
{{- end -}}
|
||||
{{- if and .Values.s3.endpoint (not (regexMatch "^https?://.+" .Values.s3.endpoint)) -}}
|
||||
{{- if and .Values.s3.endpoint (not (regexMatch $absoluteHttpUrlPattern .Values.s3.endpoint)) -}}
|
||||
{{- fail "s3.endpoint must be an absolute HTTP(S) URL" -}}
|
||||
{{- end -}}
|
||||
{{- if and .Values.s3.publicEndpoint (not (regexMatch "^https?://.+" .Values.s3.publicEndpoint)) -}}
|
||||
{{- if and .Values.s3.publicEndpoint (not (regexMatch $absoluteHttpUrlPattern .Values.s3.publicEndpoint)) -}}
|
||||
{{- fail "s3.publicEndpoint must be an absolute HTTP(S) URL" -}}
|
||||
{{- end -}}
|
||||
|
||||
|
|
|
|||
|
|
@ -27,6 +27,13 @@ render verify "$CHART_DIR" >"$TMP_DIR/default.yaml"
|
|||
grep -Fq 'name: POSTGRESQL_MAX_CONNECTIONS' "$TMP_DIR/default.yaml"
|
||||
grep -Fq 'value: "verify-postgresql"' "$TMP_DIR/default.yaml"
|
||||
grep -Fq 'value: "verify-redis-master"' "$TMP_DIR/default.yaml"
|
||||
grep -Fq 'bitnami/postgresql@sha256:52e4eae10959a7ef4810f2d0c9ce656e9337090fd2427d8d15d2e1335ba4f8fb' "$TMP_DIR/default.yaml"
|
||||
grep -Fq 'bitnami/postgres-exporter@sha256:fbd5fbc3f350c793efef777b0710f17019e8dd7812fc7c7396b13aa5130f3bd9' "$TMP_DIR/default.yaml"
|
||||
grep -Fq 'bitnami/redis@sha256:5ff436b8a0dbe3808f03c7b682865cd30ddb1f08c1a8d17970ebf475f5689722' "$TMP_DIR/default.yaml"
|
||||
grep -Fq 'bitnami/redis-exporter@sha256:0468a9a14ebd96953f967f9f3e8673c3eb1ad1dd54d379fef15c3f3ee714c99e' "$TMP_DIR/default.yaml"
|
||||
if grep -Eq 'image:.*:latest([@"[:space:]]|$)' "$TMP_DIR/default.yaml"; then
|
||||
fail "default workloads must not use mutable latest image tags"
|
||||
fi
|
||||
grep -Fq 'fsGroup: 101' "$TMP_DIR/default.yaml"
|
||||
grep -Fq 'fsGroupChangePolicy: OnRootMismatch' "$TMP_DIR/default.yaml"
|
||||
grep -Fq 'type: Recreate' "$TMP_DIR/default.yaml"
|
||||
|
|
@ -104,6 +111,13 @@ grep -Fq 'name: SPRING_DATA_REDIS_PASSWORD' "$TMP_DIR/sentinel.yaml"
|
|||
grep -Fq 'name: SPRING_DATA_REDIS_SENTINEL_PASSWORD' "$TMP_DIR/sentinel.yaml"
|
||||
grep -A1 -F 'name: SKILLHUB_REDIS_SENTINEL_CHECK_SENTINELS_LIST' "$TMP_DIR/sentinel.yaml" \
|
||||
| grep -Fq 'value: "false"'
|
||||
render sentinel-full "$CHART_DIR" \
|
||||
--set redis.architecture=replication \
|
||||
--set redis.sentinel.enabled=true >"$TMP_DIR/sentinel-full.yaml"
|
||||
grep -Fq 'bitnami/redis-sentinel@sha256:667bfef3e22ce9910bdcf304b1b7c659cfc15390a2d473187eb3caac2537d0e6' "$TMP_DIR/sentinel-full.yaml"
|
||||
if grep -Eq 'image:.*:latest([@"[:space:]]|$)' "$TMP_DIR/sentinel-full.yaml"; then
|
||||
fail "Sentinel workloads must not use mutable latest image tags"
|
||||
fi
|
||||
|
||||
render external-sentinel "$CHART_DIR" \
|
||||
--set postgresql.enabled=false \
|
||||
|
|
@ -221,8 +235,13 @@ assert_rejected old-ingress-tls-object --set ingress.tls.enabled=true
|
|||
assert_rejected reserved-oauth-ingress-path \
|
||||
--set ingress.enabled=true \
|
||||
--set-json 'ingress.hosts=[{"host":"skills.example.com","paths":[{"path":"/oauth2","pathType":"Prefix"}]}]'
|
||||
assert_rejected reserved-oauth-ingress-child-path \
|
||||
--set ingress.enabled=true \
|
||||
--set-json 'ingress.hosts=[{"host":"skills.example.com","paths":[{"path":"/login/oauth2/code/github","pathType":"Prefix"}]}]'
|
||||
assert_rejected invalid-s3-endpoint --set s3.endpoint=s3.amazonaws.com
|
||||
assert_rejected invalid-s3-public-endpoint --set s3.publicEndpoint=cdn.example.com
|
||||
assert_rejected invalid-s3-empty-authority --set-string 's3.endpoint=https://?'
|
||||
assert_rejected invalid-s3-whitespace-authority --set-string 's3.publicEndpoint=https:// '
|
||||
assert_rejected empty-ingress-hosts --set-json 'ingress.hosts=[]'
|
||||
assert_rejected cert-manager-without-tls \
|
||||
--set ingress.enabled=true \
|
||||
|
|
|
|||
169
charts/skillhub/tests/install-upgrade-smoke.sh
Executable file
169
charts/skillhub/tests/install-upgrade-smoke.sh
Executable file
|
|
@ -0,0 +1,169 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
CHART_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
|
||||
TEST_VALUES="$CHART_DIR/tests/test-values.yaml"
|
||||
NAMESPACE=${HELM_SMOKE_NAMESPACE:-skillhub-helm-smoke}
|
||||
RELEASE=${HELM_SMOKE_RELEASE:-skillhub-smoke}
|
||||
TIMEOUT=${HELM_SMOKE_TIMEOUT:-15m}
|
||||
KEEP_ENVIRONMENT=${KEEP_HELM_SMOKE:-false}
|
||||
TMP_DIR=$(mktemp -d)
|
||||
PORT_FORWARD_PID=""
|
||||
OWNS_NAMESPACE=false
|
||||
|
||||
fail() {
|
||||
echo "ERROR: $*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
for command in helm kubectl curl jq sha256sum; do
|
||||
command -v "$command" >/dev/null 2>&1 || fail "$command is required"
|
||||
done
|
||||
|
||||
if kubectl get namespace "$NAMESPACE" >/dev/null 2>&1; then
|
||||
fail "namespace $NAMESPACE already exists; choose an unused HELM_SMOKE_NAMESPACE"
|
||||
fi
|
||||
OWNS_NAMESPACE=true
|
||||
|
||||
stop_port_forward() {
|
||||
if [[ -n "$PORT_FORWARD_PID" ]]; then
|
||||
kill "$PORT_FORWARD_PID" >/dev/null 2>&1 || true
|
||||
wait "$PORT_FORWARD_PID" >/dev/null 2>&1 || true
|
||||
PORT_FORWARD_PID=""
|
||||
fi
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
local exit_code=$?
|
||||
trap - EXIT
|
||||
stop_port_forward
|
||||
|
||||
if (( exit_code != 0 )) && kubectl get namespace "$NAMESPACE" >/dev/null 2>&1; then
|
||||
echo "Helm smoke failed; collecting non-secret diagnostics" >&2
|
||||
helm status "$RELEASE" --namespace "$NAMESPACE" >&2 || true
|
||||
kubectl get pods,pvc,deployments,statefulsets --namespace "$NAMESPACE" -o wide >&2 || true
|
||||
kubectl get events --namespace "$NAMESPACE" --sort-by=.lastTimestamp >&2 || true
|
||||
fi
|
||||
|
||||
if [[ "$KEEP_ENVIRONMENT" != "true" && "$OWNS_NAMESPACE" == "true" ]]; then
|
||||
helm uninstall "$RELEASE" --namespace "$NAMESPACE" --wait >/dev/null 2>&1 || true
|
||||
kubectl delete namespace "$NAMESPACE" --wait --timeout=5m >/dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
rm -rf "$TMP_DIR"
|
||||
exit "$exit_code"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
probe_service() {
|
||||
local service=$1
|
||||
local service_port=$2
|
||||
local local_port=$3
|
||||
local path=$4
|
||||
local log_file="$TMP_DIR/${service}.port-forward.log"
|
||||
|
||||
stop_port_forward
|
||||
kubectl port-forward \
|
||||
--namespace "$NAMESPACE" \
|
||||
"service/$service" \
|
||||
"$local_port:$service_port" >"$log_file" 2>&1 &
|
||||
PORT_FORWARD_PID=$!
|
||||
|
||||
for _ in $(seq 1 60); do
|
||||
if curl --fail --silent --show-error "http://127.0.0.1:$local_port$path" >/dev/null; then
|
||||
stop_port_forward
|
||||
return 0
|
||||
fi
|
||||
if ! kill -0 "$PORT_FORWARD_PID" >/dev/null 2>&1; then
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
cat "$log_file" >&2
|
||||
fail "$service$path did not become healthy"
|
||||
}
|
||||
|
||||
snapshot_secrets() {
|
||||
local output=$1
|
||||
: >"$output"
|
||||
for secret in "$RELEASE-secret" "$RELEASE-postgresql" "$RELEASE-redis"; do
|
||||
printf '%s ' "$secret" >>"$output"
|
||||
kubectl get secret "$secret" --namespace "$NAMESPACE" -o json \
|
||||
| jq -cS '.data' \
|
||||
| sha256sum \
|
||||
| awk '{print $1}' >>"$output"
|
||||
done
|
||||
}
|
||||
|
||||
snapshot_pvcs() {
|
||||
local output=$1
|
||||
kubectl get pvc --namespace "$NAMESPACE" -o json \
|
||||
| jq -r '.items[] | [.metadata.name, .metadata.uid, .spec.volumeName] | @tsv' \
|
||||
| sort >"$output"
|
||||
[[ -s "$output" ]] || fail "Helm install did not create any PVCs"
|
||||
}
|
||||
|
||||
assert_ready_and_healthy() {
|
||||
kubectl wait pod \
|
||||
--namespace "$NAMESPACE" \
|
||||
--all \
|
||||
--for=condition=Ready \
|
||||
--timeout="$TIMEOUT"
|
||||
|
||||
probe_service "$RELEASE-server" 8080 18081 /actuator/health
|
||||
probe_service "$RELEASE-web" 80 18080 /nginx-health
|
||||
probe_service "$RELEASE-web" 80 18080 /api/v1/namespaces
|
||||
probe_service "$RELEASE-scanner" 8000 18082 /health
|
||||
|
||||
local restarts
|
||||
restarts=$(kubectl get pods --namespace "$NAMESPACE" -o json \
|
||||
| jq '[.items[].status.containerStatuses[]?.restartCount] | add // 0')
|
||||
[[ "$restarts" == "0" ]] || fail "workloads restarted $restarts time(s)"
|
||||
}
|
||||
|
||||
helm dependency build "$CHART_DIR"
|
||||
|
||||
helm install "$RELEASE" "$CHART_DIR" \
|
||||
--namespace "$NAMESPACE" \
|
||||
--create-namespace \
|
||||
--values "$TEST_VALUES" \
|
||||
--set-string fullnameOverride="$RELEASE" \
|
||||
--set-string publicBaseUrl=http://skillhub-smoke.local \
|
||||
--wait \
|
||||
--timeout "$TIMEOUT"
|
||||
|
||||
assert_ready_and_healthy
|
||||
snapshot_secrets "$TMP_DIR/secrets-before"
|
||||
snapshot_pvcs "$TMP_DIR/pvcs-before"
|
||||
revision_before=$(helm history "$RELEASE" --namespace "$NAMESPACE" -o json \
|
||||
| jq -r '.[-1].revision')
|
||||
|
||||
helm upgrade "$RELEASE" "$CHART_DIR" \
|
||||
--namespace "$NAMESPACE" \
|
||||
--reuse-values \
|
||||
--set-string publicBaseUrl=https://skillhub-smoke.local \
|
||||
--set-string server.podAnnotations.helm-smoke-revision=revision-2 \
|
||||
--wait \
|
||||
--timeout "$TIMEOUT"
|
||||
|
||||
assert_ready_and_healthy
|
||||
snapshot_secrets "$TMP_DIR/secrets-after"
|
||||
snapshot_pvcs "$TMP_DIR/pvcs-after"
|
||||
revision_after=$(helm history "$RELEASE" --namespace "$NAMESPACE" -o json \
|
||||
| jq -r '.[-1].revision')
|
||||
|
||||
(( revision_after == revision_before + 1 )) \
|
||||
|| fail "Helm revision did not advance exactly once"
|
||||
cmp "$TMP_DIR/secrets-before" "$TMP_DIR/secrets-after" \
|
||||
|| fail "application or dependency Secret data changed during upgrade"
|
||||
cmp "$TMP_DIR/pvcs-before" "$TMP_DIR/pvcs-after" \
|
||||
|| fail "PVC identity or bound volume changed during upgrade"
|
||||
|
||||
public_base_url=$(kubectl get configmap "$RELEASE-config" \
|
||||
--namespace "$NAMESPACE" \
|
||||
-o json | jq -r '.data["public-base-url"]')
|
||||
[[ "$public_base_url" == "https://skillhub-smoke.local" ]] \
|
||||
|| fail "publicBaseUrl was not applied by the upgrade"
|
||||
|
||||
echo "Helm install/upgrade smoke passed"
|
||||
|
|
@ -114,8 +114,8 @@
|
|||
"properties": {
|
||||
"enabled": { "type": "boolean" },
|
||||
"bucket": { "type": "string", "minLength": 1 },
|
||||
"endpoint": { "type": "string", "pattern": "^(|https?://.+)$" },
|
||||
"publicEndpoint": { "type": "string", "pattern": "^(|https?://.+)$" },
|
||||
"endpoint": { "type": "string", "pattern": "^(|https?://(\\[[0-9A-Fa-f:.]+\\]|[A-Za-z0-9]([A-Za-z0-9.-]*[A-Za-z0-9])?)(:[0-9]{1,5})?([/?#]\\S*)?)$" },
|
||||
"publicEndpoint": { "type": "string", "pattern": "^(|https?://(\\[[0-9A-Fa-f:.]+\\]|[A-Za-z0-9]([A-Za-z0-9.-]*[A-Za-z0-9])?)(:[0-9]{1,5})?([/?#]\\S*)?)$" },
|
||||
"region": { "type": "string", "minLength": 1 },
|
||||
"forcePathStyle": { "type": "boolean" },
|
||||
"disableChunkedEncoding": { "type": "boolean" },
|
||||
|
|
|
|||
|
|
@ -105,6 +105,11 @@ postgresql:
|
|||
|
||||
architecture: standalone
|
||||
|
||||
# Bitnami's current chart defaults to a mutable latest tag. Pin the image
|
||||
# digest so installs and rollbacks remain reproducible.
|
||||
image:
|
||||
digest: "sha256:52e4eae10959a7ef4810f2d0c9ce656e9337090fd2427d8d15d2e1335ba4f8fb"
|
||||
|
||||
auth:
|
||||
postgresPassword: ""
|
||||
database: skillhub
|
||||
|
|
@ -165,6 +170,8 @@ postgresql:
|
|||
|
||||
metrics:
|
||||
enabled: true
|
||||
image:
|
||||
digest: "sha256:fbd5fbc3f350c793efef777b0710f17019e8dd7812fc7c7396b13aa5130f3bd9"
|
||||
serviceMonitor:
|
||||
enabled: false
|
||||
|
||||
|
|
@ -184,6 +191,10 @@ redis:
|
|||
|
||||
architecture: standalone
|
||||
|
||||
# Keep the bundled Redis runtime immutable for repeatable upgrades.
|
||||
image:
|
||||
digest: "sha256:5ff436b8a0dbe3808f03c7b682865cd30ddb1f08c1a8d17970ebf475f5689722"
|
||||
|
||||
auth:
|
||||
enabled: true
|
||||
password: ""
|
||||
|
|
@ -228,6 +239,8 @@ redis:
|
|||
sentinel:
|
||||
enabled: false
|
||||
masterSet: mymaster
|
||||
image:
|
||||
digest: "sha256:667bfef3e22ce9910bdcf304b1b7c659cfc15390a2d473187eb3caac2537d0e6"
|
||||
service:
|
||||
ports:
|
||||
sentinel: 26379
|
||||
|
|
@ -236,6 +249,8 @@ redis:
|
|||
|
||||
metrics:
|
||||
enabled: true
|
||||
image:
|
||||
digest: "sha256:0468a9a14ebd96953f967f9f3e8673c3eb1ad1dd54d379fef15c3f3ee714c99e"
|
||||
serviceMonitor:
|
||||
enabled: false
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue