From 3f83bb18b268e58fa24a8582e077b8dec39992b9 Mon Sep 17 00:00:00 2001 From: "Harol A. Reina H." Date: Fri, 9 Oct 2026 09:46:33 -0500 Subject: [PATCH 1/5] build(deps): cascade Verbara.Sdk 2.8.0 and Verbara.Sdk.Pro 2.18.0-pro - 7 Verbara.Sdk.* pins 2.7.0 -> 2.8.0 (Data.Npgsql, Cluster.Primitives, Cluster.Postgres, Hosting, Push, Resilience, OpenTelemetry). - 22 Verbara.Sdk.Pro.* pins 2.17.1-pro -> 2.18.0-pro. - Measured restore: no NU1605/NU1109; the project.assets.json diff by package name moves only Verbara.Sdk* (2.8.0) and Verbara.Sdk.Pro* (2.18.0-pro) packages, nothing added or removed, so no floor moved. Change: sdk-2-8-0-pin-cascade (decision record Verbara.Sdk/ADR-0040). --- Directory.Packages.props | 58 ++++++++++++++++++++-------------------- 1 file changed, 29 insertions(+), 29 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 0158ce47..f532e432 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -21,9 +21,9 @@ - - - + + + - - - - From 8da98b821de71f1ca578adac1b1e89092c01d52b Mon Sep 17 00:00:00 2001 From: "Harol A. Reina H." Date: Fri, 9 Oct 2026 09:49:33 -0500 Subject: [PATCH 3/5] fix(voice): forward the IAmiConnection.Subscribe overload Sdk 2.8.0 added Sdk 2.8.0 (#391) added Subscribe(Func) to IAmiConnection with a default body. DeferredPrimaryAmiConnection compiled clean without it, so a handler subscribed through the wrapper was attached via the wrapper's OnEvent with a token that is never cancelled, and the primary connection's fast close never reached it. The wrapper now forwards the overload to the primary, resolved at use like every other member. Test first: the new forwarding test failed against the unforwarded wrapper (OnEventTokenSubscription returned instead of the primary's subscription), and a mutation pass that deletes the forwarding still builds and turns it red. Change: sdk-2-8-0-pin-cascade (decision record Verbara.Sdk/ADR-0040). --- .../Services/DeferredPrimaryAmiConnection.cs | 6 ++++++ .../DeferredPrimaryAmiConnectionTests.cs | 17 +++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/src/Verbara.Platform.Api/Services/DeferredPrimaryAmiConnection.cs b/src/Verbara.Platform.Api/Services/DeferredPrimaryAmiConnection.cs index e9836d1b..dbfb7f44 100644 --- a/src/Verbara.Platform.Api/Services/DeferredPrimaryAmiConnection.cs +++ b/src/Verbara.Platform.Api/Services/DeferredPrimaryAmiConnection.cs @@ -92,6 +92,12 @@ public event Action? StateChanged public IDisposable Subscribe(IObserver observer) => Primary.Subscribe(observer); + // Sdk 2.8.0 (#391) added this overload with a default body that subscribes through this wrapper's OnEvent + // with a token that is never cancelled. Forwarding hands the handler to the primary, whose token is + // cancelled when the connection closes. + public IDisposable Subscribe(Func handler) + => Primary.Subscribe(handler); + public ValueTask DisconnectAsync(CancellationToken cancellationToken = default) => Primary.DisconnectAsync(cancellationToken); diff --git a/tests/Verbara.Platform.Api.Tests/DeferredPrimaryAmiConnectionTests.cs b/tests/Verbara.Platform.Api.Tests/DeferredPrimaryAmiConnectionTests.cs index 9491f79b..418bdf55 100644 --- a/tests/Verbara.Platform.Api.Tests/DeferredPrimaryAmiConnectionTests.cs +++ b/tests/Verbara.Platform.Api.Tests/DeferredPrimaryAmiConnectionTests.cs @@ -141,4 +141,21 @@ public void StateChanged_ShouldStopDelivering_WhenUnsubscribedThroughWrapper() Assert.Same(first, Assert.Single(delivered)); } + + // sdk-2-8-0-pin-cascade: Sdk 2.8.0 (#391) added Subscribe(Func) + // with a default body that attaches the handler through the wrapper's own OnEvent with a token that is never + // cancelled. The wrapper must hand it to the primary instead, so the primary's fast close reaches the handler. + [Fact] + public void Subscribe_ShouldDelegateTokenHandlerToPrimary_WhenHandlerIsSubscribedThroughWrapper() + { + var (wrapper, primary) = WrapperOverSubstitutedPrimary(); + var subscription = Substitute.For(); + Func handler = (_, _) => ValueTask.CompletedTask; + primary.Subscribe(handler).Returns(subscription); + + var returned = wrapper.Subscribe(handler); + + Assert.Same(subscription, returned); + primary.Received(1).Subscribe(handler); + } } From 4a933abd5b5180a4aaa468e42a60d41ae0528511 Mon Sep 17 00:00:00 2001 From: "Harol A. Reina H." Date: Fri, 9 Oct 2026 09:52:57 -0500 Subject: [PATCH 4/5] fix(helm): ship Platform's AMI user, a per-pod systemname and one Asterisk replica The Asterisk chart mounted only pjsip, extensions and modules, so AMI and ARI came from the andrius/asterisk:22 defaults: manager.conf has AMI disabled and http.conf binds ARI to 127.0.0.1. It also ran two replicas behind one load-balanced AMI address while Platform registers exactly one AMI server. - ConfigMap: manager.conf.template with Platform's [platform] user (same read/write/deny/permit as the Compose template, no eventfilter, so Sdk 2.8.0's pool sweep can send Status and sees every Hangup), ari.conf.template with the [verbara] user, and an http.conf serving ARI on 0.0.0.0:8088. - Passwords only in Secrets (asterisk.ami/ari.passwordSecret), rendered by the chart only when asterisk.ami/ari.password is passed; no literal in the repo. - The container command renders manager.conf and ari.conf with awk from ENVIRON (no sed replacement, so /, & and \ stay literal), sets systemname to the pod name under [options] of asterisk.conf, then execs asterisk -fpvvv. - replicas: 1, and the StatefulSet fails to render above 1. - PDB keeps minAvailable: 1 as a deliberate interlock and gains unhealthyPodEvictionPolicy: AlwaysAllow plus a comment on the maintenance procedure. - scripts/k8s-apps.sh requires VERBARA_AMI_PASSWORD and VERBARA_ARI_PASSWORD and passes each to both charts with --set-literal, so the Asterisk users and Platform's Asterisk__Ami__Password / Asterisk__Ari__Password cannot drift. Checked: helm lint, the default, password and replicas=2 renders, an unchanged platform chart render, and a local andrius/asterisk:22 boot showing the platform user's classes, system name asterisk-0, AMI login plus Status Success and ARI 200/401. Change: sdk-2-8-0-pin-cascade (decision record Verbara.Sdk/ADR-0040). --- .../templates/asterisk-ami-secret.yaml | 17 ++++++ .../templates/asterisk-ari-secret.yaml | 17 ++++++ .../templates/asterisk-configmap.yaml | 40 ++++++++++++++ .../helm/asterisk/templates/asterisk-pdb.yaml | 8 +++ .../templates/asterisk-statefulset.yaml | 53 ++++++++++++++++++- infra/k8s/helm/asterisk/values.yaml | 24 ++++++++- scripts/k8s-apps.sh | 23 ++++++-- 7 files changed, 176 insertions(+), 6 deletions(-) create mode 100644 infra/k8s/helm/asterisk/templates/asterisk-ami-secret.yaml create mode 100644 infra/k8s/helm/asterisk/templates/asterisk-ari-secret.yaml diff --git a/infra/k8s/helm/asterisk/templates/asterisk-ami-secret.yaml b/infra/k8s/helm/asterisk/templates/asterisk-ami-secret.yaml new file mode 100644 index 00000000..e53a09c6 --- /dev/null +++ b/infra/k8s/helm/asterisk/templates/asterisk-ami-secret.yaml @@ -0,0 +1,17 @@ +{{- /* +Platform's AMI password. Rendered only when asterisk.ami.password is passed at +install; otherwise the Secret named by asterisk.ami.passwordSecret must already +exist. The repository holds no literal AMI password. +*/ -}} +{{- if .Values.asterisk.ami.password }} +apiVersion: v1 +kind: Secret +metadata: + name: {{ .Values.asterisk.ami.passwordSecret }} + namespace: {{ .Values.namespace }} + labels: + {{- include "asterisk.labels" . | nindent 4 }} +type: Opaque +stringData: + {{ .Values.asterisk.ami.passwordKey }}: {{ .Values.asterisk.ami.password | quote }} +{{- end }} diff --git a/infra/k8s/helm/asterisk/templates/asterisk-ari-secret.yaml b/infra/k8s/helm/asterisk/templates/asterisk-ari-secret.yaml new file mode 100644 index 00000000..f7661a1c --- /dev/null +++ b/infra/k8s/helm/asterisk/templates/asterisk-ari-secret.yaml @@ -0,0 +1,17 @@ +{{- /* +Platform's ARI password. Rendered only when asterisk.ari.password is passed at +install; otherwise the Secret named by asterisk.ari.passwordSecret must already +exist. The repository holds no literal ARI password. +*/ -}} +{{- if .Values.asterisk.ari.password }} +apiVersion: v1 +kind: Secret +metadata: + name: {{ .Values.asterisk.ari.passwordSecret }} + namespace: {{ .Values.namespace }} + labels: + {{- include "asterisk.labels" . | nindent 4 }} +type: Opaque +stringData: + {{ .Values.asterisk.ari.passwordKey }}: {{ .Values.asterisk.ari.password | quote }} +{{- end }} diff --git a/infra/k8s/helm/asterisk/templates/asterisk-configmap.yaml b/infra/k8s/helm/asterisk/templates/asterisk-configmap.yaml index 70ebad3e..412ac1b1 100644 --- a/infra/k8s/helm/asterisk/templates/asterisk-configmap.yaml +++ b/infra/k8s/helm/asterisk/templates/asterisk-configmap.yaml @@ -72,3 +72,43 @@ data: [modules] autoload=yes noload => chan_sip.so + + # Platform's AMI user. Same [general] and the same read/write/deny/permit lines + # as docker/asterisk-config/manager.conf.template, no eventfilter (Platform's + # pool sweep needs every Hangup and an AMI write class of system, call or + # reporting to send Status). The container command renders + # /etc/asterisk/manager.conf from this template with the password from the + # Secret; the placeholder never reaches Asterisk. + manager.conf.template: | + [general] + enabled = yes + bindaddr = 0.0.0.0 + port = 5038 + + [{{ .Values.asterisk.ami.username }}] + secret = __AMI_PASSWORD__ + read = system,call,agent,user,config,originate,reporting,command + write = system,call,agent,user,config,originate,command + deny = 0.0.0.0/0.0.0.0 + permit = 0.0.0.0/0.0.0.0 + + # Platform's ARI user, as in docker/asterisk-config/ari.conf.template; the + # password is rendered in from the Secret the same way. + ari.conf.template: | + [general] + enabled = yes + pretty = yes + + [{{ .Values.asterisk.ari.username }}] + type = user + read_only = no + password = __ARI_PASSWORD__ + + # ARI is served by Asterisk's HTTP server, which the image binds to 127.0.0.1 + # only. Plain HTTP on 8088 inside the cluster, as the platform chart's + # api.asterisk.ari.baseUrl expects. + http.conf: | + [general] + enabled = yes + bindaddr = 0.0.0.0 + bindport = 8088 diff --git a/infra/k8s/helm/asterisk/templates/asterisk-pdb.yaml b/infra/k8s/helm/asterisk/templates/asterisk-pdb.yaml index 596202f0..1c97c232 100644 --- a/infra/k8s/helm/asterisk/templates/asterisk-pdb.yaml +++ b/infra/k8s/helm/asterisk/templates/asterisk-pdb.yaml @@ -1,3 +1,10 @@ +# With one Asterisk replica, minAvailable: 1 blocks every voluntary eviction of +# the only Asterisk pod. That is deliberate: a node drain would otherwise drop +# every live call. Draining its node needs a maintenance window (cordon; agents +# Away and queues and campaigns paused; wait for `core show channels count` = 0; +# then `kubectl delete pod asterisk-0` or `kubectl drain --disable-eviction`; +# uncordon) - see the upgrade note in CHANGELOG.md. AlwaysAllow lets an unhealthy +# (not Ready) pod be evicted, so a broken Asterisk never blocks a drain. apiVersion: policy/v1 kind: PodDisruptionBudget metadata: @@ -7,6 +14,7 @@ metadata: {{- include "asterisk.labels" . | nindent 4 }} spec: minAvailable: 1 + unhealthyPodEvictionPolicy: AlwaysAllow selector: matchLabels: app.kubernetes.io/name: asterisk diff --git a/infra/k8s/helm/asterisk/templates/asterisk-statefulset.yaml b/infra/k8s/helm/asterisk/templates/asterisk-statefulset.yaml index eeb4730f..31953da5 100644 --- a/infra/k8s/helm/asterisk/templates/asterisk-statefulset.yaml +++ b/infra/k8s/helm/asterisk/templates/asterisk-statefulset.yaml @@ -1,3 +1,6 @@ +{{- if gt (int .Values.asterisk.replicas) 1 }} +{{- fail (printf "asterisk.replicas=%v is not supported: Platform registers exactly one AMI server for this chart (the \"primary\" node in Verbara.Platform.Api Program.cs), so a second Asterisk would take calls Platform never sees. Keep asterisk.replicas at 1; more than one Asterisk is a follow-up that needs one Platform AMI node per pod." .Values.asterisk.replicas) }} +{{- end }} apiVersion: apps/v1 kind: StatefulSet metadata: @@ -36,7 +39,32 @@ spec: allowPrivilegeEscalation: false seccompProfile: type: RuntimeDefault - command: ["asterisk", "-fpvvv"] + # Renders Platform's AMI and ARI users from the mounted templates with the + # passwords from the Secrets, sets a per-pod systemname (unique channel ids + # if this ever runs more than one Asterisk), then execs Asterisk as before. + # awk reads the passwords from ENVIRON, so "/", "&" and "\" stay literal + # (no sed replacement). Re-run on every container start: idempotent. + command: + - sh + - -c + - | + set -eu + : "${AMI_PASSWORD:?AMI_PASSWORD is empty: set asterisk.ami.password or create the asterisk.ami.passwordSecret Secret}" + : "${ARI_PASSWORD:?ARI_PASSWORD is empty: set asterisk.ari.password or create the asterisk.ari.passwordSecret Secret}" + : "${POD_NAME:?POD_NAME is empty}" + awk '/^secret = /{print "secret = " ENVIRON["AMI_PASSWORD"]; next} {print}' \ + /etc/asterisk/manager.conf.template > /etc/asterisk/manager.conf + awk '/^password = /{print "password = " ENVIRON["ARI_PASSWORD"]; next} {print}' \ + /etc/asterisk/ari.conf.template > /etc/asterisk/ari.conf + chmod 600 /etc/asterisk/manager.conf /etc/asterisk/ari.conf + awk 'BEGIN { name = ENVIRON["POD_NAME"]; done = 0 } + /^[ \t]*systemname[ \t]*=/ { next } + { print } + /^\[options\]/ && !done { print "systemname = " name; done = 1 } + END { if (!done) { print ""; print "[options]"; print "systemname = " name } }' \ + /etc/asterisk/asterisk.conf > /etc/asterisk/asterisk.conf.new + mv /etc/asterisk/asterisk.conf.new /etc/asterisk/asterisk.conf + exec asterisk -fpvvv ports: - name: sip containerPort: 5060 @@ -48,6 +76,20 @@ spec: - name: prom containerPort: {{ .Values.asterisk.prometheus.port }} env: + - name: POD_NAME + valueFrom: + fieldRef: + fieldPath: metadata.name + - name: AMI_PASSWORD + valueFrom: + secretKeyRef: + name: {{ .Values.asterisk.ami.passwordSecret }} + key: {{ .Values.asterisk.ami.passwordKey }} + - name: ARI_PASSWORD + valueFrom: + secretKeyRef: + name: {{ .Values.asterisk.ari.passwordSecret }} + key: {{ .Values.asterisk.ari.passwordKey }} - name: PG_HOST value: {{ .Values.asterisk.postgres.host }} - name: PG_PORT @@ -78,6 +120,15 @@ spec: - name: config mountPath: /etc/asterisk/modules.conf subPath: modules.conf + - name: config + mountPath: /etc/asterisk/manager.conf.template + subPath: manager.conf.template + - name: config + mountPath: /etc/asterisk/ari.conf.template + subPath: ari.conf.template + - name: config + mountPath: /etc/asterisk/http.conf + subPath: http.conf - name: voicemail mountPath: /var/spool/asterisk/voicemail - name: recordings diff --git a/infra/k8s/helm/asterisk/values.yaml b/infra/k8s/helm/asterisk/values.yaml index 922ef8ef..4928f18d 100644 --- a/infra/k8s/helm/asterisk/values.yaml +++ b/infra/k8s/helm/asterisk/values.yaml @@ -4,7 +4,29 @@ asterisk: image: repository: andrius/asterisk tag: "22" - replicas: 2 + # One Asterisk. Platform registers exactly one AMI server for this chart (the + # "primary" node, src/Verbara.Platform.Api/Program.cs), so a second pod would + # take calls Platform never sees. templates/asterisk-statefulset.yaml refuses + # any value above 1; more than one Asterisk is a follow-up that needs one + # Platform AMI node per pod. + replicas: 1 + # Platform's AMI user (manager.conf), the same read/write classes as the Compose + # template docker/asterisk-config/manager.conf.template. The password lives only + # in a Secret: pass asterisk.ami.password at install (scripts/k8s-apps.sh does, + # and passes the same value to the platform chart's api.asterisk.ami.password), + # or leave it empty and create the Secret yourself before installing. + ami: + username: platform + password: "" + passwordSecret: asterisk-ami-credentials + passwordKey: password + # Platform's ARI user (ari.conf), served on http.conf's 8088. Same mechanism as + # the AMI user: the password only in a Secret. + ari: + username: verbara + password: "" + passwordSecret: asterisk-ari-credentials + passwordKey: password resources: requests: cpu: 500m diff --git a/scripts/k8s-apps.sh b/scripts/k8s-apps.sh index c9b91657..071716fa 100755 --- a/scripts/k8s-apps.sh +++ b/scripts/k8s-apps.sh @@ -15,6 +15,13 @@ echo "" kubectl get nodes --no-headers | grep -q "Ready" || { echo "ERROR: No Ready nodes. Run scripts/k8s-up.sh first."; exit 1; } +# Platform's AMI and ARI passwords: one variable each, passed to BOTH charts, so the +# Asterisk users (asterisk chart) and Platform's Asterisk__Ami__Password / +# Asterisk__Ari__Password (platform chart) cannot drift. --set-literal keeps commas, +# backslashes and dots in the value as typed. +: "${VERBARA_AMI_PASSWORD:?ERROR: export VERBARA_AMI_PASSWORD (Platform's AMI password; the Asterisk chart needs it at install)}" +: "${VERBARA_ARI_PASSWORD:?ERROR: export VERBARA_ARI_PASSWORD (Platform's ARI password; the Asterisk chart needs it at install)}" + # --- 1. Storage: local-path-provisioner --- echo "[1/10] Installing local-path-provisioner..." if ! kubectl get storageclass local-path &>/dev/null; then @@ -45,9 +52,13 @@ echo " Done." # --- 4. Asterisk + Kamailio + RTPEngine --- echo "[4/10] Installing Asterisk Helm chart..." if helm status asterisk -n r55-asterisk &>/dev/null; then - helm upgrade asterisk "$INFRA/helm/asterisk" --values "$INFRA/helm/asterisk/values.yaml" + helm upgrade asterisk "$INFRA/helm/asterisk" --values "$INFRA/helm/asterisk/values.yaml" \ + --set-literal asterisk.ami.password="$VERBARA_AMI_PASSWORD" \ + --set-literal asterisk.ari.password="$VERBARA_ARI_PASSWORD" else - helm install asterisk "$INFRA/helm/asterisk" --values "$INFRA/helm/asterisk/values.yaml" + helm install asterisk "$INFRA/helm/asterisk" --values "$INFRA/helm/asterisk/values.yaml" \ + --set-literal asterisk.ami.password="$VERBARA_AMI_PASSWORD" \ + --set-literal asterisk.ari.password="$VERBARA_ARI_PASSWORD" fi kubectl -n r55-asterisk rollout status statefulset/asterisk --timeout=5m echo " Waiting for Kamailio + RTPEngine DaemonSets..." @@ -58,9 +69,13 @@ echo " Done." # --- 5. Platform.Api + Web --- echo "[5/10] Installing Platform Helm chart..." if helm status platform -n r55-platform &>/dev/null; then - helm upgrade platform "$INFRA/helm/platform" --values "$INFRA/helm/platform/values.yaml" + helm upgrade platform "$INFRA/helm/platform" --values "$INFRA/helm/platform/values.yaml" \ + --set-literal api.asterisk.ami.password="$VERBARA_AMI_PASSWORD" \ + --set-literal api.asterisk.ari.password="$VERBARA_ARI_PASSWORD" else - helm install platform "$INFRA/helm/platform" --values "$INFRA/helm/platform/values.yaml" + helm install platform "$INFRA/helm/platform" --values "$INFRA/helm/platform/values.yaml" \ + --set-literal api.asterisk.ami.password="$VERBARA_AMI_PASSWORD" \ + --set-literal api.asterisk.ari.password="$VERBARA_ARI_PASSWORD" fi kubectl -n r55-platform rollout status deploy/platform-api --timeout=5m kubectl -n r55-platform rollout status deploy/web --timeout=2m From a8fc0e793e23c8f8ca4285b979806107fa74faef Mon Sep 17 00:00:00 2001 From: "Harol A. Reina H." Date: Fri, 9 Oct 2026 09:57:47 -0500 Subject: [PATCH 5/5] =?UTF-8?q?docs:=20Sdk=202.8.0=20cascade=20=E2=80=94?= =?UTF-8?q?=20upgrade=20notes=20and=20the=20AMI=20user=20for=20an=20own=20?= =?UTF-8?q?Asterisk?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CHANGELOG [Unreleased] Upgrading: the pool sweep (Sdk #402, H142), the AMI write classes it needs, no Hangup eventfilter, systemname per Asterisk of a pool, the start-up '[LIVE] Status refused' signal; the breaking Asterisk chart change (AMI/ARI passwords at install, systemname per pod, one replica, replicas > 1 refused) and the maintenance procedure the PDB now requires. - CHANGELOG [Unreleased] Dependencies: Sdk 2.8.0 x Pro 2.18.0-pro, no floor moved, exporter pin removed (Sdk #388), Subscribe(Func) forwarded (Sdk #391), the sweep consumed with its defaults (Verbara.Sdk/ADR-0040). - Voice manual 06 §0.1: the whole [platform] AMI user, the reason for each read/write class from Platform's AMI call sites, the Hangup warning, systemname for a pool, what fails without them and how to detect it. - Validation checklist: a start-up log check for 'Status refused' and a two-agent blind-transfer test. - staging-environment.md: the Asterisk PDB blocks a drain (it does not keep voice up), and the chart runs one Asterisk replica. Change: sdk-2-8-0-pin-cascade (decision record Verbara.Sdk/ADR-0040). --- CHANGELOG.md | 49 +++++++++++++++++ docs/manuales/smb/06-canal-voz-sip.md | 54 +++++++++++++++++++ .../smb/checklist-validacion-cliente.md | 6 +++ docs/operations/staging-environment.md | 4 +- 4 files changed, 111 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 001dd78a..24860431 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,34 @@ Versioning: [Semantic Versioning](https://semver.org/spec/v2.0.0.html). before it writes anything, logs both zones as a critical error and reports the close unhealthy in `/health/ready`. See `docs/operations/licensed-agent-ledger.md`. (#348) +- **Calls whose `Hangup` never reached Platform now end on the pool sweep (Verbara.Sdk `2.8.0`).** + Every 30 s each Asterisk server holding calls older than 60 s receives one AMI `Status`; a held call + Asterisk no longer lists is ended and wraps up like any other hang-up (Sdk #402, H142). Before, such a + call stayed open for the life of the process. The sweep needs, on **every** Asterisk Platform + connects to, an AMI user whose `write` includes `system`, `call` or `reporting` and that does **not** + filter out `Hangup` (`eventfilter`); otherwise the sweep fails on that server and those calls stay + open as before. Every Asterisk of a multi-server pool also needs its own `systemname` in + `asterisk.conf` `[options]`, or channel ids collide across servers (Sdk #403 now logs a Warning on a + collision). The signal of a missing permission is `[LIVE] Status refused: …` at Warning in the API's + **start-up** log, because every state load sends `Status`. Platform's Compose `manager.conf` template + already complies; the AMI user and its reasons are in the voice manual + (`docs/manuales/smb/06-canal-voz-sip.md`). +- **Helm: the Asterisk chart changed in a breaking way.** It now ships Platform's AMI user (the same + `read`/`write` classes as the Compose template, no `eventfilter`) and its ARI user, and serves ARI on + port 8088. It needs both passwords at install: pass `asterisk.ami.password` and + `asterisk.ari.password`, or create the Secrets named by `asterisk.ami.passwordSecret` and + `asterisk.ari.passwordSecret` first; without them the Asterisk pod does not start. Keep them equal to + the platform chart's `api.asterisk.ami.password` and `api.asterisk.ari.password`; + `scripts/k8s-apps.sh` now requires `VERBARA_AMI_PASSWORD` and `VERBARA_ARI_PASSWORD` and passes each + to both charts. The chart sets `systemname` to the pod name, runs **one** Asterisk + (`asterisk.replicas: 1`) and refuses to render with `replicas > 1`, because Platform registers one AMI + server. Its PodDisruptionBudget (`minAvailable: 1`) therefore blocks a drain of the only Asterisk on + purpose. To drain its node, plan a maintenance window: cordon the node; set the agents Away and pause + queues and campaigns; wait until `asterisk -rx 'core show channels count'` shows 0 active channels; + run `kubectl delete pod asterisk-0` (or `kubectl drain --disable-eviction`); uncordon. The pod stays + `Pending` until its node is back, because its volumes use `local-path` storage. A pod that is not + Ready can always be evicted (`unhealthyPodEvictionPolicy: AlwaysAllow`). + ### Added - **Licensed-agent ledger.** Every change that can alter who counts as a licensed agent — agent created @@ -85,6 +113,27 @@ Versioning: [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - **The PJSIP desired state enumerates every agent of a tenant.** It was capped at 1000 agents per tenant, so agents beyond the first page were never provisioned. (#348) +### Dependencies + +- **Verbara.Sdk `2.7.0` → `2.8.0`** and **Verbara.Sdk.Pro `2.17.1-pro` → `2.18.0-pro`** — all 29 + consumed packages in one commit (7 `Verbara.Sdk.*` pins + 22 `Verbara.Sdk.Pro.*` pins). The + compatibility triple is **Verbara.Sdk `2.8.0` × Verbara.Sdk.Pro `2.18.0-pro` × Verbara.Platform (the + next version cut from `main`)**; per `Verbara.Sdk/ADR-0040` D3 the cascade cuts no version of its + own. **No floor moved:** `dotnet restore` reported no `NU1605`/`NU1109`, and the per-project + `project.assets.json` diff by package name moves only the Verbara packages; nothing entered or left + the graph. +- **The `OpenTelemetry.Exporter.Prometheus.AspNetCore` pin is removed.** Verbara.Sdk.OpenTelemetry + `2.8.0` declares the exporter at `1.19.1-beta.1` itself (Sdk #388), so the exporter resolves to the + same version without Platform's pin; `GET /metrics` is unchanged and its test stays. +- **`DeferredPrimaryAmiConnection` forwards `IAmiConnection.Subscribe(Func)`**, the member Sdk `2.8.0` added with a default body (Sdk #391). + Without the forwarding, a handler subscribed through the wrapper got a token that is never + cancelled, so the primary connection's fast close never reached it. +- **Behaviour Platform takes on — the pool sweep (H142).** `AddVerbaraSessionsMultiServer()` now + registers the reconciliation sweep for every server, on by default; Platform keeps the Sdk defaults + (`ReconciliationInterval` 30 s, `DialingTimeout` 60 s). A call the sweep ends carries no hangup + cause, so it never triggers a callback-on-drop. Prerequisites and signal: see **Upgrading**. + --- ## [2.26.1] - 2026-10-08 diff --git a/docs/manuales/smb/06-canal-voz-sip.md b/docs/manuales/smb/06-canal-voz-sip.md index 73ed0ac6..5e91b4da 100644 --- a/docs/manuales/smb/06-canal-voz-sip.md +++ b/docs/manuales/smb/06-canal-voz-sip.md @@ -57,6 +57,60 @@ Después de cualquier cambio en trunks/colas/agentes, Asterisk los toma on-deman $ docker exec verbara-asterisk asterisk -rx 'module reload res_pjsip_endpoint_identifier_ip.so' ``` +### 0.1 Un Asterisk propio: usuario AMI y `systemname` + +Si usás el Asterisk del stack de Verbara (Compose), esto ya está hecho: su `manager.conf` se genera en cada arranque desde `docker/asterisk-config/manager.conf.template`. Si conectás Verbara a **tu propio Asterisk** (o a varios), su usuario AMI tiene que ser **igual** a este: + +```ini +; manager.conf +[general] +enabled = yes +bindaddr = 0.0.0.0 +port = 5038 + +[platform] +secret = +read = system,call,agent,user,config,originate,reporting,command +write = system,call,agent,user,config,originate,command +deny = 0.0.0.0/0.0.0.0 +permit = 0.0.0.0/0.0.0.0 +``` + +El `secret` es el mismo valor que Verbara usa como `Asterisk__Ami__Password`. Restringí `permit` a la IP del servidor de Verbara si podés. + +**Por qué cada clase de `write`** (cada acción AMI exige una clase; Asterisk rechaza la acción si el usuario no la tiene): + +| Clase | Qué hace Verbara con ella | +|-------|---------------------------| +| `system` / `call` | `Status`: la carga del estado al arrancar (y en cada reconexión) y el barrido que cierra las llamadas cuyo `Hangup` se perdió (cada 30 s). `Hangup` y `ModuleLoad` (recarga de módulos) también la usan. | +| `call` | `Redirect` + `SetVar`: las transferencias de agente (`VoiceCallControlService`); `GetVar`; `BlindTransfer` hacia la encuesta CSAT por voz. | +| `agent` | `QueuePause`: el estado del agente (Disponible / Ausente) pausa o despausa su miembro de cola (`RealtimeStateBridge`); `QueueAdd`/`QueueRemove` al reconciliar miembros. | +| `originate` | `Originate`: llamadas salientes del agente y callbacks. | +| `command` | `Command`: `core show codecs` (lista de codecs instalados en la consola de administración) y la prueba de conectividad de trunks. | +| `user`, `config` | No encontramos ninguna acción de Verbara que las exija; se mantienen como en la plantilla. | + +**Por qué cada clase de `read`** (qué eventos recibe Verbara): `call` (canales, puentes y cada `Hangup`: las sesiones de llamada), `agent` (`AgentConnect` y el estado de los miembros de cola: sin ella la llamada de cola no tiene dueño ni screen-pop) y `system` (`FullyBooted`: sabe cuándo Asterisk terminó de arrancar). Para `user`, `config`, `originate`, `reporting` y `command` no encontramos un evento que Verbara necesite; se mantienen como en la plantilla. + +> ⚠️ **No filtres el evento `Hangup`.** No agregues un `eventfilter` que lo excluya (por ejemplo `eventfilter=!Event: Hangup`). Verbara cierra la llamada con ese evento; si no llega, sólo el barrido la cierra, y el barrido necesita `Status`. + +**Varios Asterisk (un pool):** cada servidor necesita su propio nombre de sistema, o los ids de canal se repiten entre servidores y las llamadas se mezclan: + +```ini +; asterisk.conf +[options] +systemname = <único por servidor> ; por ejemplo pbx-1, pbx-2 +``` + +**Qué falla sin esto:** sin `write` con `system`, `call` o `reporting`, el `Status` se rechaza: las llamadas cuyo `Hangup` se perdió quedan abiertas (el agente sigue Ocupado). Sin `call` fallan las transferencias; sin `agent` el agente no se pausa ni despausa en la cola; sin `originate` no salen llamadas. Con dos servidores sin `systemname` distinto, Verbara puede mezclar llamadas de servidores distintos y sólo lo avisa con un Warning en el log. + +**Cómo detectarlo:** el `Status` se manda en cada arranque del API, así que un permiso faltante aparece en el **log de arranque**: + +```bash +$ dc logs platform-api | grep 'Status refused' +``` + +✓ Sin salida = el usuario AMI tiene el permiso. Si aparece `[LIVE] Status refused: …` (nivel Warning), corregí el `write` del usuario AMI, recargá el manager en Asterisk (`asterisk -rx 'manager reload'`) y reiniciá el API. + ## 1. Pre-requisitos de red — checklist final Si esto NO está verde, **revisá manual 01 §2 y §3** antes de seguir. diff --git a/docs/manuales/smb/checklist-validacion-cliente.md b/docs/manuales/smb/checklist-validacion-cliente.md index 93d48d7e..5a5dd32d 100644 --- a/docs/manuales/smb/checklist-validacion-cliente.md +++ b/docs/manuales/smb/checklist-validacion-cliente.md @@ -147,6 +147,12 @@ Test inbound (golden path — ✅ V1): - ☐ Hangup limpio - ☐ CDR aparece en `/admin/analytics/calls` +Usuario AMI (manual [06](06-canal-voz-sip.md) §0.1): +- ☐ Log de arranque del Api sin `[LIVE] Status refused`: tras reiniciar el API, `docker logs verbara-platform-api 2>&1 | grep 'Status refused'` no devuelve nada (si aparece, al usuario AMI le falta `write` con `system`, `call` o `reporting`) + +Test transferencia (dos agentes): +- ☐ Una transferencia entre dos agentes completa: un cliente llama al DID y lo atiende el agente A; el agente A hace una **transferencia ciega** al agente B desde la consola (control de llamada); el teléfono o softphone del agente B suena, B atiende y el cliente queda hablando con B con audio en ambas direcciones; A queda libre. Si la transferencia falla o el cliente queda colgado, revisá que el usuario AMI tenga `write` con `call` (manual 06 §0.1) + Test concurrencia mínima (5 calls): - ☐ 5 llamadas simultáneas al DID (SIPp o 5 teléfonos) → 5 entran a la cola - ☐ 5 agentes distintos atienden (5 teléfonos SIP registrados) diff --git a/docs/operations/staging-environment.md b/docs/operations/staging-environment.md index 8a228ef1..1e9f3fb5 100644 --- a/docs/operations/staging-environment.md +++ b/docs/operations/staging-environment.md @@ -161,7 +161,7 @@ A fresh Phase 0L bring-up was the first scenario to exercise them. | DB | CloudNativePG operator → Postgres 17 (3-instance HA) | CNPG 1.25.0 | | DB pool | PgBouncer (CNPG Pooler CR, transaction mode) | (managed) | | Cache | Redis 8 (StatefulSet, AOF persistence) | 8-alpine | -| PBX | Asterisk (StatefulSet, 2 replicas, anti-affinity) | 22 | +| PBX | Asterisk (StatefulSet, 1 replica — the chart refuses more: Platform registers one AMI server) | 22 | | SBC | Kamailio (DaemonSet, hostNetwork) | 5.8.8 | | Media | RTPEngine (DaemonSet, hostNetwork) | fonoster/latest | | API | Platform.Api (Deployment, HPA 2→8) | 1.14.6 | @@ -198,7 +198,7 @@ A fresh Phase 0L bring-up was the first scenario to exercise them. | Feature | Scope | Detail | |---------|-------|--------| -| PodDisruptionBudgets | Asterisk + Platform API | `minAvailable: 1` — protects during node drain/upgrades | +| PodDisruptionBudgets | Asterisk + Platform API | `minAvailable: 1`. Platform API: keeps one pod serving during a node drain. Asterisk (one replica): **blocks** the drain of its node on purpose, so live calls are not dropped — voice does not stay up through a drain; plan a maintenance window (procedure in the CHANGELOG upgrade note of the Sdk 2.8.0 cascade). A pod that is not Ready can always be evicted (`unhealthyPodEvictionPolicy: AlwaysAllow`) | | NetworkPolicies | 17 policies, 4 namespaces | Default-deny ingress + service-level whitelists; standard `networking.k8s.io/v1` | | ResourceQuotas | r55-data, r55-asterisk, r55-platform | Per-namespace CPU/memory/pod caps with headroom for HPA | | SecurityContext | All workloads | `runAsNonRoot`, `seccompProfile: RuntimeDefault`, `allowPrivilegeEscalation: false` |