feat(web-app-nextcloud): run the Talk backend from pinned upstream components

nextcloud/aio-talk bundled signaling, Janus, NATS and an onboard TURN server behind a start script that rewrote their configs, and AIO publishes only build stamps, so the updaters could not raise it and the role had to guard Janus against config drift. The backend now runs as talk (Janus built from a meetecho/janus-gateway tag, files/Dockerfiles/Janus), talk_signaling (upstream strukturag/nextcloud-spreed-signaling image), talk_nats (official nats image) and talk_recording (built from a nextcloud-talk-recording release tag, files/Dockerfiles/Recording), each semver-pinned, single-replica, healthchecked and resource-limited. The Dockerfiles take every value as a build arg from compose, and Janus and the recording server get their config path through `command:`.

The role renders janus.jcfg, the signaling server.conf and the recording server.conf; they match what AIO stamp 20260409_094910 generated apart from the service addresses and the session keys, which are now derived from the signaling secret. Secrets reach the services only through those files. Janus may join the host network in compose mode, which keeps direct media available there, and an assert refuses host mode in swarm. talk and talk_recording keep their keys, so one deploy replaces the AIO containers in place.

The onboard TURN server is gone with its ports, vars and credential, so Talk uses web-svc-coturn only, and the deploy now stops when Talk is enabled without it instead of handing out no STUN or TURN server. The recording image installs the injected root CA as a Firefox policy when the CA is mounted, so recordings work against self-signed TLS, and the recording scratch volume stays off NFS. The Janus drift guard and the janus_config_drift filter are removed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
kevinveenbirkenbachandClaude Opus 5.5 committed 2026-10-29 23:42:04 +01:00
1 parent 4a14a49da9
commit 5728502a19
21 files changed
+1160 -580

No files matched your search

@@ -0,0 +1,61 @@
ARG JANUS_BASE_IMAGE
ARG JANUS_BASE_VERSION
FROM ${JANUS_BASE_IMAGE}:${JANUS_BASE_VERSION} AS build
ARG JANUS_REPOSITORY
ARG JANUS_REF
WORKDIR /src
RUN set -ex; \
apk upgrade --no-cache -a; \
apk add --no-cache \
ca-certificates \
git \
autoconf \
automake \
build-base \
pkgconfig \
libtool \
util-linux \
glib-dev \
zlib-dev \
openssl-dev \
jansson-dev \
libnice-dev \
libconfig-dev \
libsrtp-dev \
libusrsctp-dev \
gengetopt-dev \
libwebsockets-dev; \
git clone --recursive "${JANUS_REPOSITORY}" --depth=1 --single-branch --branch "${JANUS_REF}" /src; \
/src/autogen.sh; \
/src/configure --disable-rabbitmq --disable-mqtt --disable-boringssl; \
make; \
make install; \
make configs; \
rename -v ".jcfg.sample" ".jcfg" /usr/local/etc/janus/*.jcfg.sample
FROM ${JANUS_BASE_IMAGE}:${JANUS_BASE_VERSION}
COPY --from=build /usr/local /usr/local
RUN set -ex; \
apk upgrade --no-cache -a; \
apk add --no-cache \
ca-certificates \
tzdata \
netcat-openbsd \
glib \
zlib \
libssl3 \
libcrypto3 \
jansson \
libnice \
libconfig \
libsrtp \
libusrsctp \
libwebsockets; \
mkdir -p /usr/local/lib/janus/loggers; \
adduser -D -H -u 1000 janus
USER janus
ENTRYPOINT ["janus", "--disable-colors", "--log-stdout", "--full-trickle", "--debug-level", "3"]
@@ -0,0 +1,50 @@
ARG RECORDING_BASE_IMAGE
ARG RECORDING_BASE_VERSION
FROM ${RECORDING_BASE_IMAGE}:${RECORDING_BASE_VERSION}
ARG RECORDING_REPOSITORY
ARG RECORDING_REF
ARG RECORDING_USER_ID
ARG RECORDING_DIRECTORY
ARG RECORDING_CONFIG_DIRECTORY
RUN set -ex; \
apk upgrade --no-cache -a; \
apk add --no-cache \
ca-certificates \
tzdata \
bash \
xvfb \
ffmpeg \
firefox \
font-noto-all \
font-noto-cjk \
font-noto-cjk-extra \
bind-tools \
netcat-openbsd \
git \
shadow \
pulseaudio \
build-base \
linux-headers \
geckodriver; \
useradd -d "${RECORDING_DIRECTORY}" --system recording -u "${RECORDING_USER_ID}"; \
git clone --recursive "${RECORDING_REPOSITORY}" --depth=1 --single-branch --branch "${RECORDING_REF}" /src; \
python3 -m pip install --no-cache-dir /src; \
rm -rf /src; \
mkdir -p "${RECORDING_CONFIG_DIRECTORY}"; \
chown recording:recording -R "${RECORDING_DIRECTORY}"; \
apk del --no-cache \
git \
shadow \
build-base \
linux-headers
ARG RECORDING_CA_CERT
RUN if [ -n "${RECORDING_CA_CERT}" ]; then \
mkdir -p /etc/firefox/policies \
&& printf '{"policies":{"Certificates":{"Install":["%s"]}}}\n' "${RECORDING_CA_CERT}" > /etc/firefox/policies/policies.json; \
fi
WORKDIR ${RECORDING_DIRECTORY}
USER ${RECORDING_USER_ID}
ENTRYPOINT ["python", "-m", "nextcloud.talk.recording"]
@@ -13,8 +13,7 @@ const nextcloudTalkSettingsUrl = decodeDotenvQuotedValue(process.env.NEXTCLOUD_T
const nextcloudTalkExpectedSignalingUrl = decodeDotenvQuotedValue(process.env.NEXTCLOUD_TALK_EXPECTED_SIGNALING_URL);
const nextcloudTalkExpectedStunServer = decodeDotenvQuotedValue(process.env.NEXTCLOUD_TALK_EXPECTED_STUN_SERVER);
const nextcloudTalkExpectedTurnServer = decodeDotenvQuotedValue(process.env.NEXTCLOUD_TALK_EXPECTED_TURN_SERVER);
const nextcloudTalkUnexpectedStunServer = decodeDotenvQuotedValue(process.env.NEXTCLOUD_TALK_UNEXPECTED_STUN_SERVER);
const nextcloudTalkUnexpectedTurnServer = decodeDotenvQuotedValue(process.env.NEXTCLOUD_TALK_UNEXPECTED_TURN_SERVER);
const nextcloudTalkExpectedSignalingVersion = decodeDotenvQuotedValue(process.env.NEXTCLOUD_TALK_EXPECTED_SIGNALING_VERSION);
// Error strings emitted by the spreed admin UI when a signaling / STUN / TURN
// test button reports a failure. Kept in one place so the list stays easy to
@@ -43,8 +42,8 @@ const talkTestServerRequiredOkPattern = /OK:\s*Running version:\s*\S+/i;
// Talk admin settings are partly rendered as plain text and partly as
// `<input value="...">` fields. `innerText()` alone would miss the input
// values, so collect both text and form values before asserting presence or
// absence of configured / legacy endpoints.
// values, so collect both text and form values before asserting the
// configured endpoints.
async function collectNextcloudSettingsText(target) {
const bodyText = await target.locator("body").innerText().catch(() => "");
const formValues = await target.locator("input, textarea, select").evaluateAll((elements) => {
@@ -80,18 +79,6 @@ async function expectNextcloudSettingValue(page, expectedValue, label) {
.toContain(expectedValue);
}
async function expectNextcloudSettingAbsent(page, unexpectedValue, label) {
await expect
.poll(
async () => collectNextcloudSettingsText(page),
{
timeout: resolveTimeout(30_000),
message: `Expected ${label} to stay absent in the Nextcloud Talk admin settings: ${unexpectedValue}`
}
)
.not.toContain(unexpectedValue);
}
async function clickAllTalkTestServerButtonsAndVerify(page) {
// Spreed renders the test result inside `<span class="test-connection">`
// and the explicit re-test button (icon-only NcButton with
@@ -154,6 +141,25 @@ async function clickAllTalkTestServerButtonsAndVerify(page) {
}
}
function runningVersionPattern(version) {
const escaped = version.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
return new RegExp(`Running version:\\s*${escaped}(?:~[^;\\s]*)?(?=;|\\s|$)`, "i");
}
async function expectPinnedSignalingVersion(page) {
const connectionSpans = page.locator("span.test-connection");
await expect
.poll(async () => connectionSpans.allInnerTexts().catch(() => []), {
timeout: resolveTimeout(60_000),
message: `Expected the HPB signaling row to report the pinned version ${nextcloudTalkExpectedSignalingVersion} as its running version`,
})
.toEqual(expect.arrayContaining([expect.stringMatching(runningVersionPattern(nextcloudTalkExpectedSignalingVersion))]));
for (const text of await connectionSpans.allInnerTexts()) {
expect(text, `The HPB signaling server reports chat-relay as missing: ${text}`).not.toMatch(/missing features:[^\n]*\bchat-relay\b/i);
}
}
exports.register = function (shared) {
test("nextcloud talk admin settings", async ({ browser }) => {
test.skip(!nextcloudTalkSettingsCheckEnabled, "Talk admin checks are disabled in the current Playwright env");
@@ -163,6 +169,7 @@ exports.register = function (shared) {
expect(nextcloudTalkExpectedSignalingUrl, "NEXTCLOUD_TALK_EXPECTED_SIGNALING_URL must be set when Talk admin checks are enabled").toBeTruthy();
expect(nextcloudTalkExpectedStunServer, "NEXTCLOUD_TALK_EXPECTED_STUN_SERVER must be set when Talk admin checks are enabled").toBeTruthy();
expect(nextcloudTalkExpectedTurnServer, "NEXTCLOUD_TALK_EXPECTED_TURN_SERVER must be set when Talk admin checks are enabled").toBeTruthy();
expect(nextcloudTalkExpectedSignalingVersion, "NEXTCLOUD_TALK_EXPECTED_SIGNALING_VERSION must be set when Talk admin checks are enabled").toBeTruthy();
const browserContext = await browser.newContext({
ignoreHTTPSErrors: true
@@ -203,15 +210,8 @@ exports.register = function (shared) {
await expectNextcloudSettingValue(adminPage, nextcloudTalkExpectedStunServer, "Talk STUN server");
await expectNextcloudSettingValue(adminPage, nextcloudTalkExpectedTurnServer, "Talk TURN server");
if (nextcloudTalkUnexpectedStunServer) {
await expectNextcloudSettingAbsent(adminPage, nextcloudTalkUnexpectedStunServer, "legacy Talk STUN server");
}
if (nextcloudTalkUnexpectedTurnServer) {
await expectNextcloudSettingAbsent(adminPage, nextcloudTalkUnexpectedTurnServer, "legacy Talk TURN server");
}
await clickAllTalkTestServerButtonsAndVerify(adminPage);
await expectPinnedSignalingVersion(adminPage);
} finally {
await adminPage.close().catch(() => {});
}
@@ -1,121 +0,0 @@
from __future__ import annotations
import re
def _strip_comments(content):
out = []
in_string = in_block = False
index = 0
while index < len(content):
char = content[index]
if in_block:
if content.startswith("*/", index):
in_block = False
index += 1
elif char == "\n":
out.append(char)
elif in_string:
out.append(char)
if char == "\\" and index + 1 < len(content):
out.append(content[index + 1])
index += 1
elif char == '"':
in_string = False
elif char == '"':
in_string = True
out.append(char)
elif content.startswith("/*", index):
in_block = True
index += 1
elif char == "#" or content.startswith("//", index):
while index + 1 < len(content) and content[index + 1] != "\n":
index += 1
else:
out.append(char)
index += 1
return "".join(out)
def _without_blanks(line):
out = []
in_string = False
for index, char in enumerate(line):
if char == '"' and (index == 0 or line[index - 1] != "\\"):
in_string = not in_string
if in_string or not char.isspace():
out.append(char)
return "".join(out).rstrip(";")
def _key(setting):
for index, char in enumerate(setting):
if char in "=:":
return setting[:index]
if char == '"':
return ""
return ""
def janus_settings(content, overrides):
"""Return the settings a Janus ``.jcfg`` file declares, one per line.
Args:
content: text of a Janus libconfig file.
overrides: setting names to leave out, the ones a deployment sets on
top of the stock file; empty to keep every setting.
Returns:
Every line that holds more than comments and blanks, with the comments,
a trailing ``;`` and the whitespace outside quoted strings removed, in
file order.
"""
dropped = {str(name) for name in overrides}
settings = []
for raw in _strip_comments(str(content)).splitlines():
setting = _without_blanks(raw)
if setting and _key(setting) not in dropped:
settings.append(setting)
return settings
def janus_config_drift(template, stock, overrides, launcher, target):
"""Return why a rendered Janus config may not replace an image's own.
Args:
template: the rendered config that is mounted over ``target``.
stock: the image's own file at ``target``.
overrides: setting names the template adds to the stock settings.
launcher: the image's process manager config that starts Janus.
target: the path the template is mounted at.
Returns:
One message per problem; empty when Janus reads ``target`` and the
template without ``overrides`` declares exactly the stock settings.
"""
problems = []
started_with = re.compile(rf"--config={re.escape(str(target))}( |$)", re.MULTILINE)
if not started_with.search(str(launcher)):
problems.append(f"Janus is not started with --config={target}")
expected = janus_settings(stock, [])
rendered = janus_settings(template, overrides)
if expected != rendered:
first = next(
(
f"{index}: image {theirs!r}, template {ours!r}"
for index, (theirs, ours) in enumerate(
zip(expected, rendered, strict=False)
)
if theirs != ours
),
f"{len(expected)} settings in the image, {len(rendered)} in the template",
)
problems.append(f"the stock settings differ at setting {first}")
return problems
class FilterModule:
def filters(self):
return {
"janus_config_drift": janus_config_drift,
}
@@ -22,7 +22,6 @@ config:
configvalue: >-
{{
[
NEXTCLOUD_HPB_STUN_ONBOARD_CONFIG if NEXTCLOUD_HPB_TURN_ONBOARD_ENABLED else none,
NEXTCLOUD_HPB_STUN_STANDALONE_CONFIG if NEXTCLOUD_HPB_TURN_STANDALONE_ENABLED else none
]
| select
@@ -35,7 +34,6 @@ config:
configvalue: >-
{{
[
NEXTCLOUD_HPB_TURN_ONBOARD_CONFIG if NEXTCLOUD_HPB_TURN_ONBOARD_ENABLED else none,
NEXTCLOUD_HPB_TURN_STANDALONE_CONFIG if NEXTCLOUD_HPB_TURN_STANDALONE_ENABLED else none
]
| select
+1 -5
View File
@@ -17,16 +17,12 @@ credentials:
description: Initial password for the Nextcloud administrator (change immediately and enable 2FA)
algorithm: sha256
validation: ^[a-f0-9]{64}$
talk_turn_secret:
description: TURN REST secret for the optional onboard Talk TURN server
algorithm: base64_prefixed_32
validation: ^base64:[A-Za-z0-9+/]{43}=$
talk_signaling_secret:
description: Secret for Talk signaling
algorithm: base64_prefixed_32
validation: ^base64:[A-Za-z0-9+/]{43}=$
talk_internal_secret:
description: Internal secret for AIO Talk
description: Secret internal clients such as the Talk recording server present to the signaling server
algorithm: base64_prefixed_32
validation: ^base64:[A-Za-z0-9+/]{43}=$
context_agent_app_secret:
+69 -25
View File
@@ -101,12 +101,6 @@ nextcloud:
internal:
http: 80
fpm: 9000
public:
stun_turn: 3480
stun_turn_tls: 5350
relay:
start: 50000
end: 59999
run_after:
- web-app-keycloak
- web-app-matomo
@@ -200,34 +194,79 @@ talk:
bond: 1
enabled: "{{ 'web-svc-coturn' in group_names }}"
shared: "{{ 'web-svc-coturn' in group_names }}"
name: nextcloud-talk
image: nextcloud/aio-talk
# nocheck: docker-version -- upstream publishes only AIO build stamps and froze latest at 20250325_084656; later stamps start Janus from a generated /conf/janus.jcfg that the janus_conf mount cannot replace
version: "20260409_094910"
backup:
no_stop_required: false
name: nextcloud-talk-janus
image: alpine
version: 3.23.3
custom: nextcloud-talk-janus
repository: https://github.com/meetecho/janus-gateway.git
ref: v1.4.0
# nocheck: single-replica the signaling server addresses exactly one Janus by URL and keeps every media session on it
replicas: 1
turn_server:
onboard_enabled: "{{ not ( 'web-svc-coturn' in group_names ) }}"
standalone_enabled: "{{ 'web-svc-coturn' in group_names }}"
network_mode: bridge
direct_media: false
cpus: "2.0"
mem_reservation: 1g
mem_limit: 2g
mem_reservation: 512m
mem_limit: 1g
pids_limit: 1024
min_storage: 279MB
min_storage: 600MB
ports:
internal:
signaling: 8081
websocket: 8188
public:
relay:
start: 61000
end: 61999
healthcheck:
flavor: tcp
flavor: nc
port_key: websocket
# nocheck: playwright-service-flag the signaling server is part of the Talk deployment, covered by the Talk admin settings spec
# nocheck: dynamic-flag the signaling server is part of the Talk deployment, so it follows the talk entry rather than a role's presence
talk_signaling:
enabled: "{{ lookup('config', 'web-app-nextcloud', 'services.talk.enabled') | bool }}"
shared: false
name: nextcloud-talk-signaling
image: strukturag/nextcloud-spreed-signaling
version: 2.1.1
# nocheck: single-replica sessions live in this process; a second replica behind the VIP would split one call across servers
replicas: 1
max_stream_bitrate: 1048576
max_screen_bitrate: 2097152
cpus: "1.0"
mem_reservation: 128m
mem_limit: 512m
pids_limit: 256
min_storage: 223MB
ports:
internal:
signaling: 8081
healthcheck:
flavor: http_status
port_key: signaling
# nocheck: healthcheck-timing-override a sidecar of a stack whose main service alone takes minutes; the value predates this default and has not been measured against it
start_period: 20m
path: api/v1/welcome
# nocheck: playwright-service-flag internal message bus, reachable only from the Talk signaling server
# nocheck: dynamic-flag the message bus is part of the Talk deployment, so it follows the talk entry rather than a role's presence
talk_nats:
enabled: "{{ lookup('config', 'web-app-nextcloud', 'services.talk.enabled') | bool }}"
shared: false
name: nextcloud-talk-nats
image: nats
version: 2.12.6-alpine
# nocheck: single-replica an unclustered NATS server; the signaling server connects to exactly one
replicas: 1
cpus: "0.5"
mem_reservation: 32m
mem_limit: 256m
pids_limit: 128
min_storage: 211MB
ports:
internal:
nats: 4222
http: 8222
healthcheck:
flavor: http
path: healthz
whiteboard:
name: nextcloud-whiteboard
image: ghcr.io/nextcloud-releases/whiteboard
@@ -281,11 +320,13 @@ talk_recording:
enabled: "{{ lookup('config', 'web-app-nextcloud', 'services.talk.enabled') | bool }}"
shared: false
name: nextcloud-talk-recording
image: nextcloud/aio-talk-recording
# nocheck: docker-version -- upstream publishes only AIO build stamps and froze latest at 20250325_084656; stays on the AIO release of services.talk.version
version: "20260409_094910"
backup:
no_stop_required: true
image: python
version: 3.14.3-alpine3.23
custom: nextcloud-talk-recording
repository: https://github.com/nextcloud/nextcloud-talk-recording.git
ref: v0.2.1
# nocheck: single-replica Talk starts and stops a recording on the server it reached; a second replica behind the VIP would receive half of the stop requests
replicas: 1
cpus: "2.0"
mem_reservation: 1g
mem_limit: 3g
@@ -294,6 +335,9 @@ talk_recording:
ports:
internal:
recording: 1234
healthcheck:
flavor: nc
port_key: recording
# nocheck: playwright-service-flag: verified by web-svc-css provider spec
css:
bond: 1
+22 -1
View File
@@ -33,6 +33,8 @@ talk_recording_tmp:
name: nextcloud_talk_recording_tmp
type: volume
backup: false
# nocheck: nfs-false-data-loss Reason: scratch for Xvfb sockets, the Firefox profile and the ffmpeg output before its upload; single replica, nothing persists.
nfs: false
mounts:
- service: talk_recording
target: *tmp_target
@@ -68,10 +70,29 @@ infinito_conf:
janus_conf:
type: config
source: "{{ [ lookup('container', 'web-app-nextcloud', 'directories.volumes'), 'janus.jcfg' ] | path_join }}"
mode: "0444"
mounts:
- service: talk
target: /usr/local/etc/janus/janus.jcfg
when: "{{ lookup('config', 'web-app-nextcloud', 'services.talk.direct_media') | bool }}"
when: "{{ lookup('config', 'web-app-nextcloud', 'services.talk.enabled') | bool }}"
talk_signaling_conf:
type: secret
source: "{{ [ lookup('container', 'web-app-nextcloud', 'directories.volumes'), 'signaling.conf' ] | path_join }}"
mode: "0444"
mounts:
- service: talk_signaling
target: /config/server.conf
when: "{{ lookup('config', 'web-app-nextcloud', 'services.talk.enabled') | bool }}"
talk_recording_conf:
type: secret
source: "{{ [ lookup('container', 'web-app-nextcloud', 'directories.volumes'), 'recording.conf' ] | path_join }}"
mode: "0444"
mounts:
- service: talk_recording
target: /conf/recording.conf
when: "{{ lookup('config', 'web-app-nextcloud', 'services.talk.enabled') | bool }}"
php_hardening:
type: config
+61 -54
View File
@@ -1,4 +1,25 @@
---
- name: "🛑 Assert Janus joins the host network only in compose mode"
ansible.builtin.assert:
that: DEPLOYMENT_MODE == 'compose'
fail_msg: >-
services.talk.network_mode host gives Janus the host network namespace,
which docker stack deploy cannot provide. Use bridge in swarm mode.
quiet: true
when:
- NEXTCLOUD_HPB_SIGNALING_ENABLED | bool
- NEXTCLOUD_HPB_JANUS_HOST_NETWORK | bool
- name: "🛑 Assert Talk has the TURN server its calls relay through"
ansible.builtin.assert:
that: NEXTCLOUD_HPB_TURN_ACTIVE_SERVER | length > 0
fail_msg: >-
services.talk.enabled is true, but {{ NEXTCLOUD_HPB_TURN_STANDALONE_ROLE }} is not deployed
on this host, so Talk would hand out no STUN or TURN server and calls behind NAT would carry
no media. Deploy {{ NEXTCLOUD_HPB_TURN_STANDALONE_ROLE }} or set services.talk.enabled to false.
quiet: true
when: NEXTCLOUD_HPB_SIGNALING_ENABLED | bool
- name: "🧩 load docker, db and proxy for {{ application_id }}"
include_role:
name: sys-stk-full
@@ -7,51 +28,6 @@
application_id: web-app-nextcloud
webserver_client_max_body_size: 10G
- name: "🔍 Read the Janus launcher and config from {{ NEXTCLOUD_HPB_IMAGE }}"
ansible.builtin.command:
argv:
- container
- run
- --rm
- --pull
- always
- --entrypoint
- cat
- "{{ NEXTCLOUD_HPB_IMAGE }}"
- "{{ item }}"
environment:
CA_CONTAINER_ENABLED: "0"
loop:
- "{{ NEXTCLOUD_HPB_JANUS_LAUNCHER }}"
- "{{ NEXTCLOUD_HPB_JANUS_CONFIG_TARGET }}"
register: nextcloud_janus_image_files
changed_when: false
retries: 3
delay: 30
until: nextcloud_janus_image_files is succeeded
when:
- NEXTCLOUD_HPB_MEDIA_DIRECT | bool
- IS_STACK_HOST | bool
- name: "🛑 Assert Janus reads the mounted config and the image's copy still matches"
ansible.builtin.assert:
that: nextcloud_janus_drift | length == 0
fail_msg: >-
templates/janus.jcfg.j2 cannot replace {{ NEXTCLOUD_HPB_JANUS_CONFIG_TARGET }}
of {{ NEXTCLOUD_HPB_IMAGE }}: {{ nextcloud_janus_drift | join('; ') }}.
Re-derive the template from the new image before deploying.
quiet: true
vars:
nextcloud_janus_drift: >-
{{ lookup('template', 'janus.jcfg.j2')
| janus_config_drift(nextcloud_janus_image_files.results[1].stdout,
NEXTCLOUD_HPB_JANUS_OVERRIDES,
nextcloud_janus_image_files.results[0].stdout,
NEXTCLOUD_HPB_JANUS_CONFIG_TARGET) }}
when:
- NEXTCLOUD_HPB_MEDIA_DIRECT | bool
- IS_STACK_HOST | bool
- name: "📄 Deploy '{{ NEXTCLOUD_INIT_GATE_HOST }}'"
copy:
src: "{{ NEXTCLOUD_INIT_GATE_SRC }}"
@@ -124,12 +100,43 @@
- src: "fpm-pool.conf.j2"
dest: "{{ NEXTCLOUD_HOST_FPM_POOL_PATH }}"
- name: "📄 Render the Janus config that publishes Talk media directly"
ansible.builtin.include_tasks:
file: "{{ lookup('path_absolute', 'roles/sys-svc-compose/tasks/utils/render_replicated_templates.yml') }}"
vars:
role_templates:
- src: "janus.jcfg.j2"
dest: "{{ NEXTCLOUD_HPB_JANUS_CONFIG_HOST }}"
mode: "0644"
when: NEXTCLOUD_HPB_MEDIA_DIRECT | bool
- when: NEXTCLOUD_HPB_SIGNALING_ENABLED | bool
block:
- name: "📁 Create the Talk build directories"
ansible.builtin.file:
path: "{{ item }}"
state: directory
mode: "0755"
loop:
- "{{ NEXTCLOUD_HPB_JANUS_BUILD_DIRECTORY }}"
- "{{ NEXTCLOUD_RECORDING_BUILD_DIRECTORY }}"
- name: "📄 Deploy the Talk Dockerfiles that build Janus and the recording server"
stack_host_copy:
src: "{{ item.src }}"
dest: "{{ item.dest }}"
loop:
- { src: "Dockerfiles/Janus/Dockerfile", dest: "{{ NEXTCLOUD_HPB_JANUS_DOCKERFILE }}" }
- { src: "Dockerfiles/Recording/Dockerfile", dest: "{{ NEXTCLOUD_RECORDING_DOCKERFILE }}" }
notify:
- compose-build
- compose-up
- name: "📄 Render the Janus, signaling and recording configs (local + swarm peers)"
ansible.builtin.include_tasks:
file: "{{ lookup('path_absolute', 'roles/sys-svc-compose/tasks/utils/render_replicated_templates.yml') }}"
vars:
role_templates:
- src: "janus.jcfg.j2"
dest: "{{ NEXTCLOUD_HPB_JANUS_CONFIG_HOST }}"
mode: "0644"
- src: "signaling.conf.j2"
dest: "{{ NEXTCLOUD_HPB_SIGNALING_CONFIG_HOST }}"
owner: "{{ NEXTCLOUD_HPB_SIGNALING_USER_ID }}"
group: "{{ NEXTCLOUD_HPB_SIGNALING_USER_ID }}"
mode: "0400"
- src: "recording.conf.j2"
dest: "{{ NEXTCLOUD_RECORDING_CONFIG_HOST }}"
owner: "{{ NEXTCLOUD_RECORDING_USER_ID }}"
group: "{{ NEXTCLOUD_RECORDING_USER_ID }}"
mode: "0400"
@@ -11,9 +11,7 @@
{{ lookup('container_volumes', service_name) | indent(4) }}
{{ lookup('container_healthcheck', service_name, samples=(groups[application_id] | default([]) | length) if DEPLOYMENT_MODE == 'swarm' else 1) | indent(4) }}
{{ lookup('container_networks') | indent(4) }}
{{ lookup('container_extra_hosts', extra_hosts=(
['host.docker.internal:host-gateway'] if NEXTCLOUD_HPB_NETWORK_MODE == 'host' else []
)) | indent(4) }}
{{ lookup('container_extra_hosts') | indent(4) }}
{% set service_name = NEXTCLOUD_SERVICE %}
{% set container_healthcheck_start_period = '20m' %}
@@ -74,41 +72,62 @@
{% if NEXTCLOUD_HPB_SIGNALING_ENABLED | bool %}
{% set service_name = NEXTCLOUD_HPB_SERVICE %}
{% set container_port = NEXTCLOUD_HPB_SIGNALING_PORT %}
{{ service_name }}:
{% include 'roles/sys-svc-container/templates/base.yml.j2' %}
{{ lookup('container_healthcheck', service_name) | indent(4) }}
{{ lookup('container_image', application_id, 'talk') }}
build:
context: {{ NEXTCLOUD_HPB_JANUS_BUILD_DIRECTORY }}
dockerfile: Dockerfile
args:
JANUS_BASE_IMAGE: "{{ NEXTCLOUD_HPB_JANUS_BASE_IMAGE }}"
JANUS_BASE_VERSION: "{{ NEXTCLOUD_HPB_JANUS_BASE_VERSION }}"
JANUS_REPOSITORY: "{{ NEXTCLOUD_HPB_JANUS_REPOSITORY }}"
JANUS_REF: "{{ NEXTCLOUD_HPB_JANUS_REF }}"
{{ lookup('compose_only', 'pull_policy', 'never') }}
{{ lookup('container_image', application_id, service_name, version=NEXTCLOUD_HPB_JANUS_TAG) }}
{{ lookup('compose_only', 'container_name', NEXTCLOUD_HPB_CONTAINER) }}
command: ["--config={{ NEXTCLOUD_HPB_JANUS_CONFIG_TARGET }}"]
init: true
{% if NEXTCLOUD_HPB_NETWORK_MODE == 'bridge' %}
{% set _talk_ports = [] %}
{% if NEXTCLOUD_HPB_TURN_ONBOARD_ENABLED | bool and NEXTCLOUD_HPB_TURN_ONBOARD_PUBLISH_PORTS | bool %}
{% set _ = _talk_ports.extend([
{'service': 'nextcloud', 'protocol': 'stun_turn',
'ip': NEXTCLOUD_HPB_TURN_ONBOARD_BIND_HOST,
'transport': ['tcp', 'udp'], 'container': 'same'},
{'service': 'nextcloud', 'protocol': 'relay',
'ip': NEXTCLOUD_HPB_TURN_ONBOARD_BIND_HOST,
'transport': ['udp', 'tcp'], 'container': 'same'},
]) %}
{% endif %}
{% if NEXTCLOUD_HPB_MEDIA_DIRECT | bool %}
{{ lookup('container_healthcheck', service_name) | indent(4) }}
{{ lookup('container_volumes', service_name) | indent(4) }}
{% set _ = _talk_ports.append(
{'service': 'talk', 'protocol': 'relay',
'ip': NEXTCLOUD_HPB_MEDIA_PUBLIC_IP,
'transport': 'udp', 'container': 'same'}) %}
{% endif %}
{% if _talk_ports %}
{{ lookup('container_ports', *_talk_ports) | indent(4) }}
{% if DEPLOYMENT_MODE == 'compose' and NEXTCLOUD_HPB_JANUS_HOST_NETWORK | bool %}
network_mode: host
{% else %}
{% if NEXTCLOUD_HPB_MEDIA_DIRECT | bool %}
{{ lookup('container_ports',
{'service': service_name, 'protocol': 'relay',
'ip': NEXTCLOUD_HPB_MEDIA_PUBLIC_IP,
'transport': 'udp', 'container': 'same'}) | indent(4) }}
{% endif %}
expose:
- "{{ container_port }}"
{{ lookup('container_networks', provider_self_alias=False) | indent(4) }}
{% else %}
network_mode: {{ NEXTCLOUD_HPB_NETWORK_MODE }}
- "{{ NEXTCLOUD_HPB_JANUS_PORT }}"
{{ lookup('container_networks', own_network_only=True, provider_self_alias=False) | indent(4) }}
{% endif %}
{% set service_name = NEXTCLOUD_HPB_SIGNALING_SERVICE %}
{{ service_name }}:
{% include 'roles/sys-svc-container/templates/base.yml.j2' %}
{{ lookup('container_image', application_id, service_name) }}
{{ lookup('compose_only', 'container_name', NEXTCLOUD_HPB_SIGNALING_CONTAINER) }}
user: "{{ NEXTCLOUD_HPB_SIGNALING_USER_ID }}:{{ NEXTCLOUD_HPB_SIGNALING_USER_ID }}"
command: ["{{ NEXTCLOUD_HPB_SIGNALING_BINARY }}", "-config", "{{ NEXTCLOUD_HPB_SIGNALING_CONFIG_TARGET }}"]
{{ lookup('container_healthcheck', service_name) | indent(4) }}
{{ lookup('container_volumes', service_name) | indent(4) }}
expose:
- "{{ NEXTCLOUD_HPB_SIGNALING_PORT }}"
{{ lookup('container_extra_hosts', host_alias=NEXTCLOUD_HPB_JANUS_HOST_NETWORK) | indent(4) }}
{{ lookup('container_networks', provider_self_alias=False) | indent(4) }}
{% set service_name = NEXTCLOUD_HPB_NATS_SERVICE %}
{{ service_name }}:
{% include 'roles/sys-svc-container/templates/base.yml.j2' %}
{{ lookup('container_image', application_id, service_name) }}
{{ lookup('compose_only', 'container_name', NEXTCLOUD_HPB_NATS_CONTAINER) }}
user: "{{ NEXTCLOUD_HPB_NATS_USER_ID }}:{{ NEXTCLOUD_HPB_NATS_USER_ID }}"
command: ["nats-server", "--addr", "0.0.0.0", "--port", "{{ NEXTCLOUD_HPB_NATS_PORT }}", "--http_port", "{{ NEXTCLOUD_HPB_NATS_MONITOR_PORT }}"]
{{ lookup('container_healthcheck', service_name) | indent(4) }}
expose:
- "{{ NEXTCLOUD_HPB_NATS_PORT }}"
{{ lookup('container_networks', own_network_only=True, provider_self_alias=False) | indent(4) }}
{% endif %}
{% if NEXTCLOUD_WHITEBOARD_ENABLED %}
@@ -188,17 +207,28 @@
{{ lookup('container_networks', provider_self_alias=False) | indent(4) }}
{% if NEXTCLOUD_RECORDING_ENABLED | bool %}
{% set service_name = 'talk_recording' %}
{% set service_name = NEXTCLOUD_RECORDING_SERVICE %}
{{ service_name }}:
{% include 'roles/sys-svc-container/templates/base.yml.j2' %}
{{ lookup('container_image', application_id, 'talk_recording') }}
build:
context: {{ NEXTCLOUD_RECORDING_BUILD_DIRECTORY }}
dockerfile: Dockerfile
args:
RECORDING_BASE_IMAGE: "{{ NEXTCLOUD_RECORDING_BASE_IMAGE }}"
RECORDING_BASE_VERSION: "{{ NEXTCLOUD_RECORDING_BASE_VERSION }}"
RECORDING_REPOSITORY: "{{ NEXTCLOUD_RECORDING_REPOSITORY }}"
RECORDING_REF: "{{ NEXTCLOUD_RECORDING_REF }}"
RECORDING_USER_ID: "{{ NEXTCLOUD_RECORDING_USER_ID }}"
RECORDING_DIRECTORY: "{{ NEXTCLOUD_RECORDING_DIRECTORY }}"
RECORDING_CONFIG_DIRECTORY: "{{ NEXTCLOUD_RECORDING_CONFIG_TARGET | dirname }}"
RECORDING_CA_CERT: "{{ NEXTCLOUD_RECORDING_CA_CERT }}"
{{ lookup('compose_only', 'pull_policy', 'never') }}
{{ lookup('container_image', application_id, service_name, version=NEXTCLOUD_RECORDING_TAG) }}
{{ lookup('compose_only', 'container_name', NEXTCLOUD_RECORDING_CONTAINER) }}
command: ["--config", "{{ NEXTCLOUD_RECORDING_CONFIG_TARGET }}"]
environment:
- HPB_DOMAIN={{ NEXTCLOUD_HPB_DOMAIN }}
- NC_DOMAIN={{ NEXTCLOUD_DOMAIN }}
- RECORDING_SECRET={{ NEXTCLOUD_RECORDING_SECRET }}
- INTERNAL_SECRET={{ NEXTCLOUD_HPB_INTERNAL_SECRET }}
- TZ={{ HOST_TIMEZONE }}
TZ: "{{ HOST_TIMEZONE }}"
{{ lookup('container_healthcheck', service_name) | indent(4) }}
expose:
- "{{ NEXTCLOUD_RECORDING_PORT }}"
{{ lookup('container_networks', provider_self_alias=False) | indent(4) }}
@@ -114,13 +114,9 @@ http {
}
{% if NEXTCLOUD_HPB_SIGNALING_ENABLED | bool %}
location {{ NEXTCLOUD_HPB_SIGNALING_LOCATION }} {
{% if NEXTCLOUD_HPB_NETWORK_MODE == 'host' %}
proxy_pass http://host.docker.internal:{{ NEXTCLOUD_HPB_SIGNALING_PORT }}/;
{% else %}
set $upstream_nextcloud_talk http://talk:{{ NEXTCLOUD_HPB_SIGNALING_PORT }};
set $upstream_nextcloud_talk http://{{ NEXTCLOUD_HPB_SIGNALING_SERVICE }}:{{ NEXTCLOUD_HPB_SIGNALING_PORT }};
rewrite ^{{ NEXTCLOUD_HPB_SIGNALING_LOCATION }}(.*)$ /$1 break;
proxy_pass $upstream_nextcloud_talk;
{% endif %}
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header Upgrade $http_upgrade;
-13
View File
@@ -54,19 +54,6 @@ OBJECTSTORE_S3_SECRET={{ lookup('objstore', application_id, 'secret_key') | dote
OBJECTSTORE_S3_AUTOCREATE=false
{% endif %}
{% if NEXTCLOUD_HPB_PLUGIN_ENABLED %}
NC_DOMAIN={{ NEXTCLOUD_DOMAIN }}
TALK_HOST={{ NEXTCLOUD_HPB_DOMAIN }}
SIGNALING_SECRET={{ NEXTCLOUD_HPB_SIGNALING_SECRET }}
INTERNAL_SECRET={{ NEXTCLOUD_HPB_INTERNAL_SECRET }}
TURN_SECRET={{ NEXTCLOUD_HPB_TURN_ONBOARD_SECRET }}
TALK_PORT={{ NEXTCLOUD_HPB_TURN_ONBOARD_PORT }}
TURN_MIN_PORT={{ NEXTCLOUD_HPB_TURN_ONBOARD_RELAY_PORT_START }}
TURN_MAX_PORT={{ NEXTCLOUD_HPB_TURN_ONBOARD_RELAY_PORT_END }}
COTURN_MIN_PORT={{ NEXTCLOUD_HPB_TURN_ONBOARD_RELAY_PORT_START }}
COTURN_MAX_PORT={{ NEXTCLOUD_HPB_TURN_ONBOARD_RELAY_PORT_END }}
{% endif %}
{% if NEXTCLOUD_WHITEBOARD_ENABLED %}
NEXTCLOUD_URL={{ NEXTCLOUD_URL }}
JWT_SECRET_KEY={{ NEXTCLOUD_WHITEBOARD_JWT }}
@@ -34,12 +34,16 @@ certificates: {
}
media: {
{% if NEXTCLOUD_HPB_MEDIA_DIRECT | bool %}
rtp_port_range = "{{ NEXTCLOUD_HPB_MEDIA_PORT_START }}-{{ NEXTCLOUD_HPB_MEDIA_PORT_END }}"
{% endif %}
}
nat: {
{% if NEXTCLOUD_HPB_MEDIA_DIRECT | bool %}
nat_1_1_mapping = "{{ NEXTCLOUD_HPB_MEDIA_PUBLIC_IP }}"
keep_private_host = true
{% endif %}
ice_ignore_list = "vmnet"
}
@@ -51,6 +51,8 @@ NEXTCLOUD_TALK_DIRECT_MEDIA_PORT_START={{ NEXTCLOUD_HPB_MEDIA_PORT_START }}
NEXTCLOUD_TALK_EXPECTED_SIGNALING_URL={{ NEXTCLOUD_HPB_SIGNALING_URL | dotenv_quote }}
NEXTCLOUD_TALK_EXPECTED_SIGNALING_VERSION={{ NEXTCLOUD_HPB_SIGNALING_VERSION | dotenv_quote }}
NEXTCLOUD_TALK_EXPECTED_STUN_SERVER={{ NEXTCLOUD_HPB_STUN_ACTIVE_CONFIG | dotenv_quote }}
NEXTCLOUD_TALK_EXPECTED_TURN_SERVER={{ NEXTCLOUD_HPB_TURN_ACTIVE_SERVER | dotenv_quote }}
@@ -59,10 +61,6 @@ NEXTCLOUD_TALK_SETTINGS_CHECK_ENABLED={{ NEXTCLOUD_HPB_PLUGIN_ENABLED | string |
NEXTCLOUD_TALK_SETTINGS_URL={{ NEXTCLOUD_TALK_ADMIN_URL | dotenv_quote }}
NEXTCLOUD_TALK_UNEXPECTED_STUN_SERVER={{ (NEXTCLOUD_HPB_STUN_ONBOARD_CONFIG if NEXTCLOUD_HPB_TURN_STANDALONE_ENABLED | bool else '') | dotenv_quote }}
NEXTCLOUD_TALK_UNEXPECTED_TURN_SERVER={{ (NEXTCLOUD_HPB_TURN_ONBOARD_CONFIG.server | default('') if NEXTCLOUD_HPB_TURN_STANDALONE_ENABLED | bool else '') | dotenv_quote }}
OIDC_ISSUER_URL={{ OIDC.CLIENT.ISSUER_URL | dotenv_quote }}
OPENPROJECT_BASE_URL={{ (lookup('tls', 'web-app-openproject', 'url.base') if (lookup('config', application_id, 'services.openproject.enabled') | bool) else '') | dotenv_quote }}
@@ -0,0 +1,36 @@
[logs]
level = 30
[http]
listen = 0.0.0.0:{{ NEXTCLOUD_RECORDING_PORT }}
[backend]
allowall = false
secret = {{ NEXTCLOUD_RECORDING_SECRET }}
backends = backend-1
skipverify = false
maxmessagesize = 1024
videowidth = 1920
videoheight = 1080
directory = {{ NEXTCLOUD_RECORDING_DIRECTORY }}
[backend-1]
url = {{ NEXTCLOUD_HPB_BACKEND_URL }}
secret = {{ NEXTCLOUD_RECORDING_SECRET }}
skipverify = false
[signaling]
signalings = signaling-1
[signaling-1]
url = {{ NEXTCLOUD_HPB_BACKEND_URL }}{{ NEXTCLOUD_HPB_SIGNALING_LOCATION }}
internalsecret = {{ NEXTCLOUD_HPB_INTERNAL_SECRET }}
[ffmpeg]
extensionaudio = .ogg
extensionvideo = .webm
[recording]
browser = firefox
driverPath = /usr/bin/geckodriver
browserPath = /usr/bin/firefox
@@ -0,0 +1,34 @@
[http]
listen = 0.0.0.0:{{ NEXTCLOUD_HPB_SIGNALING_PORT }}
[app]
debug = false
[sessions]
hashkey = {{ ((NEXTCLOUD_HPB_SIGNALING_SECRET ~ '/sessions/hashkey') | hash('sha256'))[:32] }}
blockkey = {{ ((NEXTCLOUD_HPB_SIGNALING_SECRET ~ '/sessions/blockkey') | hash('sha256'))[:32] }}
[clients]
internalsecret = {{ NEXTCLOUD_HPB_INTERNAL_SECRET }}
[backend]
backends = backend-1
allowall = false
timeout = 10
connectionsperhost = 8
skipverify = false
[backend-1]
urls = {{ NEXTCLOUD_HPB_BACKEND_URL }}
secret = {{ NEXTCLOUD_HPB_SIGNALING_SECRET }}
maxstreambitrate = {{ NEXTCLOUD_HPB_SIGNALING_MAX_STREAM_BITRATE }}
maxscreenbitrate = {{ NEXTCLOUD_HPB_SIGNALING_MAX_SCREEN_BITRATE }}
[nats]
url = {{ NEXTCLOUD_HPB_NATS_URL }}
[mcu]
type = janus
url = {{ NEXTCLOUD_HPB_JANUS_URL }}
maxstreambitrate = {{ NEXTCLOUD_HPB_SIGNALING_MAX_STREAM_BITRATE }}
maxscreenbitrate = {{ NEXTCLOUD_HPB_SIGNALING_MAX_SCREEN_BITRATE }}
+41 -46
View File
@@ -157,6 +157,7 @@ NEXTCLOUD_HPB_NETWORK_MODE: "{{ lookup('config', application_id, 'services.' ~ N
NEXTCLOUD_HPB_PLUGIN_ENABLED: "{{ lookup('config', application_id, 'addons.spreed.enabled') }}"
NEXTCLOUD_HPB_INTERNAL_SECRET: "{{ lookup('config', application_id, 'secrets.credentials.talk_internal_secret') }}"
NEXTCLOUD_HPB_DOMAIN: "{{ NEXTCLOUD_DOMAIN }}"
NEXTCLOUD_HPB_BACKEND_URL: "{{ NEXTCLOUD_PROTOCOL }}://{{ NEXTCLOUD_HPB_DOMAIN }}"
# nocheck: host-local-fact read only in compose mode, where the host rendering this file is the host Janus publishes its media ports on; Ansible omits the key on a host without the ip binary, which leaves the media on TURN
NEXTCLOUD_HPB_HOST_ADDRESSES: "{{ ansible_facts['all_ipv4_addresses'] | default([]) }}"
@@ -164,7 +165,6 @@ NEXTCLOUD_HPB_MEDIA_DIRECT: >-
{{ (lookup('config', application_id, 'services.' ~ NEXTCLOUD_HPB_SERVICE ~ '.direct_media') | bool)
and (NEXTCLOUD_HPB_SIGNALING_ENABLED | bool)
and (NEXTCLOUD_HPB_TURN_STANDALONE_ENABLED | bool)
and (lookup('config', application_id, 'services.' ~ NEXTCLOUD_HPB_SERVICE ~ '.network_mode') == 'bridge')
and (DEPLOYMENT_MODE == 'compose')
and (networks.internet.ip4 in NEXTCLOUD_HPB_HOST_ADDRESSES) }}
NEXTCLOUD_HPB_MEDIA_PUBLIC_IP: "{{ networks.internet.ip4 }}"
@@ -172,38 +172,39 @@ NEXTCLOUD_HPB_MEDIA_PORT_START: "{{ lookup('config', application_id, 'services.'
NEXTCLOUD_HPB_MEDIA_PORT_END: "{{ lookup('config', application_id, 'services.' ~ NEXTCLOUD_HPB_SERVICE ~ '.ports.public.relay.end') }}"
NEXTCLOUD_HPB_JANUS_CONFIG_HOST: "{{ [ lookup('container', application_id, 'directories.volumes'), 'janus.jcfg' ] | path_join }}"
NEXTCLOUD_HPB_JANUS_CONFIG_TARGET: "{{ lookup('volume', application_id, 'janus_conf').target }}"
NEXTCLOUD_HPB_JANUS_LAUNCHER: "/supervisord.conf"
NEXTCLOUD_HPB_JANUS_OVERRIDES:
- rtp_port_range
- nat_1_1_mapping
- keep_private_host
NEXTCLOUD_HPB_IMAGE: "{{ lookup('image', application_id, NEXTCLOUD_HPB_SERVICE) }}"
NEXTCLOUD_HPB_JANUS_PORT: "{{ lookup('config', application_id, 'services.' ~ NEXTCLOUD_HPB_SERVICE ~ '.ports.internal.websocket') }}"
NEXTCLOUD_HPB_JANUS_HOST_NETWORK: "{{ NEXTCLOUD_HPB_NETWORK_MODE == 'host' }}"
NEXTCLOUD_HPB_JANUS_URL: "ws://{{ 'host.docker.internal' if NEXTCLOUD_HPB_JANUS_HOST_NETWORK | bool else NEXTCLOUD_HPB_SERVICE }}:{{ NEXTCLOUD_HPB_JANUS_PORT }}"
NEXTCLOUD_HPB_JANUS_REPOSITORY: "{{ lookup('config', application_id, 'services.' ~ NEXTCLOUD_HPB_SERVICE ~ '.repository') }}"
NEXTCLOUD_HPB_JANUS_REF: "{{ lookup('config', application_id, 'services.' ~ NEXTCLOUD_HPB_SERVICE ~ '.ref') }}"
NEXTCLOUD_HPB_JANUS_BASE_IMAGE: "{{ lookup('config', application_id, 'services.' ~ NEXTCLOUD_HPB_SERVICE ~ '.image') }}"
NEXTCLOUD_HPB_JANUS_BASE_VERSION: "{{ lookup('config', application_id, 'services.' ~ NEXTCLOUD_HPB_SERVICE ~ '.version') }}"
NEXTCLOUD_HPB_JANUS_TAG: "{{ NEXTCLOUD_HPB_JANUS_REF }}-{{ NEXTCLOUD_HPB_JANUS_BASE_VERSION }}"
NEXTCLOUD_HPB_JANUS_BUILD_DIRECTORY: "{{ [ lookup('container', application_id, 'directories.services'), 'talk-janus' ] | path_join }}"
NEXTCLOUD_HPB_JANUS_DOCKERFILE: "{{ [ NEXTCLOUD_HPB_JANUS_BUILD_DIRECTORY, 'Dockerfile' ] | path_join }}"
NEXTCLOUD_HPB_SIGNALING_SERVICE: "talk_signaling"
NEXTCLOUD_HPB_SIGNALING_CONTAINER: "{{ lookup('config', application_id, 'services.' ~ NEXTCLOUD_HPB_SIGNALING_SERVICE ~ '.name') }}"
NEXTCLOUD_HPB_SIGNALING_VERSION: "{{ lookup('config', application_id, 'services.' ~ NEXTCLOUD_HPB_SIGNALING_SERVICE ~ '.version') }}"
NEXTCLOUD_HPB_SIGNALING_SECRET: "{{ lookup('config', application_id, 'secrets.credentials.talk_signaling_secret') }}"
NEXTCLOUD_HPB_SIGNALING_LOCATION: "/standalone-signaling/"
NEXTCLOUD_HPB_SIGNALING_PORT: "{{ lookup('config', application_id, 'services.talk.ports.internal.signaling') }}"
NEXTCLOUD_HPB_SIGNALING_PORT: "{{ lookup('config', application_id, 'services.' ~ NEXTCLOUD_HPB_SIGNALING_SERVICE ~ '.ports.internal.signaling') }}"
NEXTCLOUD_HPB_SIGNALING_URL: "{{ [ NEXTCLOUD_URL, NEXTCLOUD_HPB_SIGNALING_LOCATION ] | url_join }}"
NEXTCLOUD_HPB_SIGNALING_ENABLED: "{{ NEXTCLOUD_HPB_PLUGIN_ENABLED }}"
NEXTCLOUD_HPB_SIGNALING_MAX_STREAM_BITRATE: "{{ lookup('config', application_id, 'services.' ~ NEXTCLOUD_HPB_SIGNALING_SERVICE ~ '.max_stream_bitrate') }}"
NEXTCLOUD_HPB_SIGNALING_MAX_SCREEN_BITRATE: "{{ lookup('config', application_id, 'services.' ~ NEXTCLOUD_HPB_SIGNALING_SERVICE ~ '.max_screen_bitrate') }}"
NEXTCLOUD_HPB_SIGNALING_USER_ID: 850
NEXTCLOUD_HPB_SIGNALING_BINARY: "/usr/bin/nextcloud-spreed-signaling"
NEXTCLOUD_HPB_SIGNALING_CONFIG_HOST: "{{ [ lookup('container', application_id, 'directories.volumes'), 'signaling.conf' ] | path_join }}"
NEXTCLOUD_HPB_SIGNALING_CONFIG_TARGET: "{{ lookup('volume', application_id, 'talk_signaling_conf').target }}"
NEXTCLOUD_TALK_ADMIN_URL: "{{ [ NEXTCLOUD_URL, 'settings/admin/talk' ] | url_join }}"
NEXTCLOUD_HPB_TURN_ONBOARD_PORT: "{{ lookup('config', application_id, 'services.nextcloud.ports.public.stun_turn') }}"
NEXTCLOUD_HPB_TURN_ONBOARD_BIND_HOST: "{{ DOCKER_PUBLIC_BIND_HOST }}"
NEXTCLOUD_HPB_TURN_ONBOARD_ENABLED: "{{ lookup('config', application_id, 'services.' ~ NEXTCLOUD_HPB_SERVICE ~ '.turn_server.onboard_enabled') if NEXTCLOUD_HPB_PLUGIN_ENABLED
else false }}"
NEXTCLOUD_HPB_TURN_ONBOARD_SECRET: "{{ lookup('config', application_id, 'secrets.credentials.talk_turn_secret') }}"
NEXTCLOUD_HPB_TURN_ONBOARD_PUBLISH_PORTS: "{{ not DOCKER_IN_CONTAINER | bool }}"
NEXTCLOUD_HPB_TURN_ONBOARD_RELAY_PORT_START: "{{ lookup('config', application_id, 'services.nextcloud.ports.public.relay.start') }}"
NEXTCLOUD_HPB_TURN_ONBOARD_RELAY_PORT_END: "{{ lookup('config', application_id, 'services.nextcloud.ports.public.relay.end') }}"
NEXTCLOUD_HPB_STUN_ONBOARD_CONFIG: "{{ NEXTCLOUD_HPB_DOMAIN }}:{{ NEXTCLOUD_HPB_TURN_ONBOARD_PORT }}"
NEXTCLOUD_HPB_TURN_ONBOARD_CONFIG: >-
{{
{
'server': NEXTCLOUD_HPB_DOMAIN ~ ':' ~ NEXTCLOUD_HPB_TURN_ONBOARD_PORT,
'secret': NEXTCLOUD_HPB_TURN_ONBOARD_SECRET,
'ttl': 86400,
'protocols': 'udp,tcp'
}
}}
NEXTCLOUD_HPB_NATS_SERVICE: "talk_nats"
NEXTCLOUD_HPB_NATS_CONTAINER: "{{ lookup('config', application_id, 'services.' ~ NEXTCLOUD_HPB_NATS_SERVICE ~ '.name') }}"
NEXTCLOUD_HPB_NATS_PORT: "{{ lookup('config', application_id, 'services.' ~ NEXTCLOUD_HPB_NATS_SERVICE ~ '.ports.internal.nats') }}"
NEXTCLOUD_HPB_NATS_MONITOR_PORT: "{{ lookup('config', application_id, 'services.' ~ NEXTCLOUD_HPB_NATS_SERVICE ~ '.ports.internal.http') }}"
NEXTCLOUD_HPB_NATS_URL: "nats://{{ NEXTCLOUD_HPB_NATS_SERVICE }}:{{ NEXTCLOUD_HPB_NATS_PORT }}"
NEXTCLOUD_HPB_NATS_USER_ID: 65534
NEXTCLOUD_HPB_TURN_STANDALONE_ROLE: "web-svc-coturn"
NEXTCLOUD_HPB_TURN_STANDALONE_PORT: "{{ lookup('config', NEXTCLOUD_HPB_TURN_STANDALONE_ROLE, 'services.coturn.ports.public.stun_turn') }}"
@@ -221,26 +222,8 @@ NEXTCLOUD_HPB_TURN_STANDALONE_CONFIG: >-
'protocols': 'udp,tcp'
}
}}
NEXTCLOUD_HPB_STUN_ACTIVE_CONFIG: >-
{{
NEXTCLOUD_HPB_STUN_STANDALONE_CONFIG
if NEXTCLOUD_HPB_TURN_STANDALONE_ENABLED | bool
else (
NEXTCLOUD_HPB_STUN_ONBOARD_CONFIG
if NEXTCLOUD_HPB_TURN_ONBOARD_ENABLED | bool
else ''
)
}}
NEXTCLOUD_HPB_TURN_ACTIVE_CONFIG: >-
{{
NEXTCLOUD_HPB_TURN_STANDALONE_CONFIG
if NEXTCLOUD_HPB_TURN_STANDALONE_ENABLED | bool
else (
NEXTCLOUD_HPB_TURN_ONBOARD_CONFIG
if NEXTCLOUD_HPB_TURN_ONBOARD_ENABLED | bool
else {}
)
}}
NEXTCLOUD_HPB_STUN_ACTIVE_CONFIG: "{{ NEXTCLOUD_HPB_STUN_STANDALONE_CONFIG if NEXTCLOUD_HPB_TURN_STANDALONE_ENABLED | bool else '' }}"
NEXTCLOUD_HPB_TURN_ACTIVE_CONFIG: "{{ NEXTCLOUD_HPB_TURN_STANDALONE_CONFIG if NEXTCLOUD_HPB_TURN_STANDALONE_ENABLED | bool else {} }}"
NEXTCLOUD_HPB_TURN_ACTIVE_SERVER: "{{ NEXTCLOUD_HPB_TURN_ACTIVE_CONFIG.server | default('') }}"
NEXTCLOUD_WHITEBOARD_SERVICE: "whiteboard"
@@ -261,6 +244,18 @@ NEXTCLOUD_RECORDING_CONTAINER: "{{ lookup('config', application_id, 'services.'
NEXTCLOUD_RECORDING_ENABLED: "{{ NEXTCLOUD_HPB_PLUGIN_ENABLED }}"
NEXTCLOUD_RECORDING_PORT: "{{ lookup('config', application_id, 'services.talk_recording.ports.internal.recording') }}"
NEXTCLOUD_RECORDING_SECRET: "{{ lookup('config', application_id, 'secrets.credentials.talk_recording_secret') }}"
NEXTCLOUD_RECORDING_REPOSITORY: "{{ lookup('config', application_id, 'services.' ~ NEXTCLOUD_RECORDING_SERVICE ~ '.repository') }}"
NEXTCLOUD_RECORDING_REF: "{{ lookup('config', application_id, 'services.' ~ NEXTCLOUD_RECORDING_SERVICE ~ '.ref') }}"
NEXTCLOUD_RECORDING_BASE_IMAGE: "{{ lookup('config', application_id, 'services.' ~ NEXTCLOUD_RECORDING_SERVICE ~ '.image') }}"
NEXTCLOUD_RECORDING_BASE_VERSION: "{{ lookup('config', application_id, 'services.' ~ NEXTCLOUD_RECORDING_SERVICE ~ '.version') }}"
NEXTCLOUD_RECORDING_TAG: "{{ NEXTCLOUD_RECORDING_REF }}-{{ NEXTCLOUD_RECORDING_BASE_VERSION }}"
NEXTCLOUD_RECORDING_BUILD_DIRECTORY: "{{ [ lookup('container', application_id, 'directories.services'), 'talk-recording' ] | path_join }}"
NEXTCLOUD_RECORDING_DOCKERFILE: "{{ [ NEXTCLOUD_RECORDING_BUILD_DIRECTORY, 'Dockerfile' ] | path_join }}"
NEXTCLOUD_RECORDING_USER_ID: 122
NEXTCLOUD_RECORDING_CONFIG_HOST: "{{ [ lookup('container', application_id, 'directories.volumes'), 'recording.conf' ] | path_join }}"
NEXTCLOUD_RECORDING_CONFIG_TARGET: "{{ lookup('volume', application_id, 'talk_recording_conf').target }}"
NEXTCLOUD_RECORDING_DIRECTORY: "{{ lookup('volume', application_id, 'talk_recording_tmp').target }}"
NEXTCLOUD_RECORDING_CA_CERT: "{{ CA_TRUST.inject_cert_container if lookup('ca_injected', application_id) else '' }}"
NEXTCLOUD_COLLABORA_URL: "{{ lookup('tls', 'web-svc-collabora', 'url.base') }}"
@@ -1,138 +0,0 @@
from __future__ import annotations
import importlib.util
import sys
import unittest
from jinja2 import Environment, FileSystemLoader, StrictUndefined, select_autoescape
from . import PROJECT_ROOT
OVERRIDES = ("rtp_port_range", "nat_1_1_mapping", "keep_private_host")
TARGET = "/usr/local/etc/janus/janus.jcfg"
LAUNCHER = (
"[program:janus]\n"
"command=janus --config=/usr/local/etc/janus/janus.jcfg --disable-colors"
" --log-stdout --full-trickle --debug-level 3\n"
)
def _load_module(rel_path: str, name: str):
path = PROJECT_ROOT / rel_path
spec = importlib.util.spec_from_file_location(name, str(path))
mod = importlib.util.module_from_spec(spec)
assert spec and spec.loader
sys.modules[name] = mod
spec.loader.exec_module(mod)
return mod
MODULE = _load_module(
"roles/web-app-nextcloud/filter_plugins/nextcloud_janus.py", "nextcloud_janus"
)
def _rendered_template() -> str:
env = Environment(
loader=FileSystemLoader(
str(PROJECT_ROOT / "roles/web-app-nextcloud/templates")
),
undefined=StrictUndefined,
keep_trailing_newline=True,
autoescape=select_autoescape(default_for_string=False),
)
return env.get_template("janus.jcfg.j2").render(
NEXTCLOUD_HPB_MEDIA_PORT_START=61000,
NEXTCLOUD_HPB_MEDIA_PORT_END=61999,
NEXTCLOUD_HPB_MEDIA_PUBLIC_IP="203.0.113.7",
)
def _stock_like() -> str:
lines = ["/* a block comment the image ships", " over two lines */"]
for line in _rendered_template().splitlines():
if line.strip().split(" ", 1)[0] in OVERRIDES:
lines.append("\t#" + line.strip() + "\t\t# commented out upstream")
continue
lines.append("\t\t# a comment line the image ships")
lines.append(line.replace(" = ", " = ") + "\t// trailing remark")
return "\n".join(lines) + "\n"
def _drift(stock: str, launcher: str = LAUNCHER) -> list[str]:
return MODULE.janus_config_drift(
_rendered_template(), stock, OVERRIDES, launcher, TARGET
)
class TestJanusSettings(unittest.TestCase):
def test_comments_and_blank_lines_are_dropped(self):
content = "# head\n\ngeneral: {\n\tdebug_level = 4\t\t# level\n}\n"
self.assertEqual(
MODULE.janus_settings(content, []), ["general:{", "debug_level=4", "}"]
)
def test_block_comments_and_a_trailing_semicolon_are_dropped(self):
content = "/* head\n tail */\ndebug_level = 4; /* level */\n"
self.assertEqual(MODULE.janus_settings(content, []), ["debug_level=4"])
def test_a_hash_inside_a_quoted_value_is_kept(self):
content = 'log_prefix = "[janus] #1" # prefix\n'
self.assertEqual(
MODULE.janus_settings(content, []), ['log_prefix="[janus] #1"']
)
def test_named_overrides_are_left_out(self):
content = 'nat: {\n\tnat_1_1_mapping = "1.2.3.4"\n\tnice_debug = false\n}\n'
self.assertEqual(
MODULE.janus_settings(content, ["nat_1_1_mapping"]),
["nat:{", "nice_debug=false", "}"],
)
def test_the_template_sets_every_override_once(self):
keys = [
setting.split("=", 1)[0]
for setting in MODULE.janus_settings(_rendered_template(), [])
]
for override in OVERRIDES:
self.assertEqual(keys.count(override), 1, override)
class TestJanusConfigDrift(unittest.TestCase):
def test_a_stock_file_that_differs_only_in_comments_and_layout_passes(self):
self.assertEqual(_drift(_stock_like()), [])
def test_a_moved_plugin_folder_stops_the_deploy(self):
stock = _stock_like().replace(
'"/usr/local/lib/janus/plugins"', '"/usr/lib/janus/plugins"'
)
self.assertEqual(len(_drift(stock)), 1)
def test_an_upstream_value_for_a_masked_key_stops_the_deploy(self):
stock = _stock_like().replace("\t#rtp_port_range", "\trtp_port_range", 1)
self.assertEqual(len(_drift(stock)), 1)
def test_a_setting_upstream_added_stops_the_deploy(self):
stock = _stock_like().replace("nat: {", "nat: {\n\tice_tcp = true", 1)
self.assertEqual(len(_drift(stock)), 1)
def test_an_empty_read_stops_the_deploy(self):
self.assertEqual(len(_drift("")), 1)
def test_janus_reading_a_generated_config_stops_the_deploy(self):
launcher = LAUNCHER.replace(TARGET, "/conf/janus.jcfg")
self.assertEqual(
_drift(_stock_like(), launcher),
[f"Janus is not started with --config={TARGET}"],
)
def test_a_config_path_that_only_shares_the_prefix_stops_the_deploy(self):
launcher = LAUNCHER.replace(f"{TARGET} ", f"{TARGET}.generated ")
self.assertEqual(len(_drift(_stock_like(), launcher)), 1)
def test_a_launcher_without_janus_stops_the_deploy(self):
self.assertEqual(len(_drift(_stock_like(), "")), 1)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,381 @@
from __future__ import annotations
import configparser
import hashlib
import posixpath
import re
import unittest
from typing import ClassVar
from jinja2 import Environment, StrictUndefined, select_autoescape
from utils.cache.files import PROJECT_ROOT, read_text
TEMPLATES = PROJECT_ROOT / "roles" / "web-app-nextcloud" / "templates"
FILES = PROJECT_ROOT / "roles" / "web-app-nextcloud" / "files"
SIGNALING_SECRET = "base64:signaling-fixture-secret"
INTERNAL_SECRET = "base64:internal-fixture-secret"
RECORDING_SECRET = "recording-fixture-secret"
VARIABLES = {
"NEXTCLOUD_HPB_SIGNALING_PORT": 8081,
"NEXTCLOUD_HPB_SIGNALING_SECRET": SIGNALING_SECRET,
"NEXTCLOUD_HPB_INTERNAL_SECRET": INTERNAL_SECRET,
"NEXTCLOUD_HPB_BACKEND_URL": "https://next.example.org",
"NEXTCLOUD_HPB_SIGNALING_LOCATION": "/standalone-signaling/",
"NEXTCLOUD_HPB_SIGNALING_MAX_STREAM_BITRATE": 1048576,
"NEXTCLOUD_HPB_SIGNALING_MAX_SCREEN_BITRATE": 2097152,
"NEXTCLOUD_HPB_NATS_URL": "nats://talk_nats:4222",
"NEXTCLOUD_HPB_JANUS_URL": "ws://talk:8188",
"NEXTCLOUD_RECORDING_PORT": 1234,
"NEXTCLOUD_RECORDING_SECRET": RECORDING_SECRET,
"NEXTCLOUD_RECORDING_DIRECTORY": "/tmp",
"NEXTCLOUD_HPB_MEDIA_PORT_START": 61000,
"NEXTCLOUD_HPB_MEDIA_PORT_END": 61999,
"NEXTCLOUD_HPB_MEDIA_PUBLIC_IP": "203.0.113.7",
"NEXTCLOUD_HPB_JANUS_BASE_IMAGE": "alpine",
"NEXTCLOUD_HPB_JANUS_BASE_VERSION": "3.23.3",
"NEXTCLOUD_HPB_JANUS_REPOSITORY": "https://github.com/meetecho/janus-gateway.git",
"NEXTCLOUD_HPB_JANUS_REF": "v1.4.0",
"NEXTCLOUD_HPB_JANUS_CONFIG_TARGET": "/usr/local/etc/janus/janus.jcfg",
"NEXTCLOUD_RECORDING_BASE_IMAGE": "python",
"NEXTCLOUD_RECORDING_BASE_VERSION": "3.14.3-alpine3.23",
"NEXTCLOUD_RECORDING_REPOSITORY": "https://github.com/nextcloud/nextcloud-talk-recording.git",
"NEXTCLOUD_RECORDING_REF": "v0.2.1",
"NEXTCLOUD_RECORDING_USER_ID": 122,
"NEXTCLOUD_RECORDING_CONFIG_TARGET": "/conf/recording.conf",
"NEXTCLOUD_RECORDING_CA_CERT": "/tmp/infinito/ca/root-ca.crt",
}
AIO_SIGNALING = {
"http": {"listen": "0.0.0.0:8081"},
"app": {"debug": "false"},
"clients": {"internalsecret": INTERNAL_SECRET},
"backend": {
"backends": "backend-1",
"allowall": "false",
"timeout": "10",
"connectionsperhost": "8",
"skipverify": "false",
},
"backend-1": {
"urls": "https://next.example.org",
"secret": SIGNALING_SECRET,
"maxstreambitrate": "1048576",
"maxscreenbitrate": "2097152",
},
"nats": {"url": "nats://127.0.0.1:4222"},
"mcu": {
"type": "janus",
"url": "ws://127.0.0.1:8188",
"maxstreambitrate": "1048576",
"maxscreenbitrate": "2097152",
},
}
AIO_RECORDING = {
"logs": {"level": "30"},
"http": {"listen": "0.0.0.0:1234"},
"backend": {
"allowall": "false",
"secret": RECORDING_SECRET,
"backends": "backend-1",
"skipverify": "false",
"maxmessagesize": "1024",
"videowidth": "1920",
"videoheight": "1080",
"directory": "/tmp",
},
"backend-1": {
"url": "https://next.example.org",
"secret": RECORDING_SECRET,
"skipverify": "false",
},
"signaling": {"signalings": "signaling-1"},
"signaling-1": {
"url": "https://next.example.org/standalone-signaling/",
"internalsecret": INTERNAL_SECRET,
},
"ffmpeg": {"extensionaudio": ".ogg", "extensionvideo": ".webm"},
"recording": {
"browser": "firefox",
"driverPath": "/usr/bin/geckodriver",
"browserPath": "/usr/bin/firefox",
},
}
STOCK_JANUS = [
"general:{",
'configs_folder="/usr/local/etc/janus"',
'plugins_folder="/usr/local/lib/janus/plugins"',
'transports_folder="/usr/local/lib/janus/transports"',
'events_folder="/usr/local/lib/janus/events"',
'loggers_folder="/usr/local/lib/janus/loggers"',
"debug_level=4",
'admin_secret="janusoverlord"',
"protected_folders=[",
*[
f'"{folder}",'
for folder in (
"/bin",
"/boot",
"/dev",
"/etc",
"/initrd",
"/lib",
"/lib32",
"/lib64",
"/proc",
"/sbin",
"/sys",
"/usr",
"/var",
"/opt/janus/bin",
"/opt/janus/etc",
"/opt/janus/include",
"/opt/janus/lib",
"/opt/janus/lib32",
"/opt/janus/lib64",
)
],
'"/opt/janus/sbin"',
"]",
"}",
"certificates:{",
"}",
"media:{",
"}",
"nat:{",
'ice_ignore_list="vmnet"',
"}",
"plugins:{",
"}",
"transports:{",
"}",
"loggers:{",
"}",
"events:{",
"}",
]
def _render(source: str, **overrides) -> str:
env = Environment(
trim_blocks=True,
keep_trailing_newline=True,
undefined=StrictUndefined,
autoescape=select_autoescape(default_for_string=False),
)
env.filters["hash"] = lambda value, algorithm: hashlib.new(
algorithm, str(value).encode()
).hexdigest()
env.filters["bool"] = bool
env.filters["dirname"] = posixpath.dirname
return env.from_string(source).render({**VARIABLES, **overrides})
def _template(name: str) -> str:
return read_text(str(TEMPLATES / name))
def _build_args(dockerfile: str) -> str:
compose = _template("compose.yml.j2")
args: dict[str, str] = {}
for arg in re.findall(r"^ARG (\w+)$", dockerfile, re.MULTILINE):
passed = re.search(rf'^\s+{arg}: "(.*)"$', compose, re.MULTILINE)
if passed is None:
raise AssertionError(f"compose.yml.j2 passes no build arg {arg}")
args[arg] = _render(passed.group(1))
return re.sub(r"\$\{(\w+)\}", lambda match: args[match.group(1)], dockerfile)
def _build(name: str) -> str:
return _build_args(read_text(str(FILES / "Dockerfiles" / name / "Dockerfile")))
def _ini(text: str) -> dict[str, dict[str, str]]:
parser = configparser.ConfigParser(interpolation=None, strict=True)
parser.optionxform = str
parser.read_string(text)
return {section: dict(parser[section]) for section in parser.sections()}
def _settings(text: str) -> list[str]:
return [joined for line in text.splitlines() if (joined := "".join(line.split()))]
class TestSignalingConfig(unittest.TestCase):
def test_matches_what_the_aio_start_script_wrote_apart_from_the_split(self):
rendered = _ini(_render(_template("signaling.conf.j2")))
sessions = rendered.pop("sessions")
expected = {
**AIO_SIGNALING,
"nats": {"url": "nats://talk_nats:4222"},
"mcu": {**AIO_SIGNALING["mcu"], "url": "ws://talk:8188"},
}
self.assertEqual(rendered, expected)
self.assertEqual(sorted(sessions), ["blockkey", "hashkey"])
def test_session_keys_have_the_shape_aio_generated_and_stay_stable(self):
first = _ini(_render(_template("signaling.conf.j2")))["sessions"]
second = _ini(_render(_template("signaling.conf.j2")))["sessions"]
self.assertEqual(first, second)
for key in ("hashkey", "blockkey"):
self.assertRegex(first[key], r"^[0-9a-f]{32}$")
self.assertNotEqual(first["hashkey"], first["blockkey"])
self.assertNotIn(first["hashkey"], SIGNALING_SECRET)
def test_negative_control_another_signaling_secret_changes_the_keys(self):
first = _ini(_render(_template("signaling.conf.j2")))["sessions"]
other = _ini(
_render(
_template("signaling.conf.j2"),
NEXTCLOUD_HPB_SIGNALING_SECRET="base64:another-secret",
)
)["sessions"]
self.assertNotEqual(first["hashkey"], other["hashkey"])
self.assertNotEqual(first["blockkey"], other["blockkey"])
def test_negative_control_a_changed_setting_breaks_parity(self):
drifted = _template("signaling.conf.j2").replace(
"allowall = false", "allowall = true"
)
self.assertNotEqual(drifted, _template("signaling.conf.j2"))
rendered = _ini(_render(drifted))
rendered.pop("sessions")
self.assertNotEqual(rendered["backend"], AIO_SIGNALING["backend"])
class TestRecordingConfig(unittest.TestCase):
def test_matches_what_the_aio_start_script_wrote(self):
self.assertEqual(_ini(_render(_template("recording.conf.j2"))), AIO_RECORDING)
def test_negative_control_a_dropped_setting_breaks_parity(self):
drifted = _template("recording.conf.j2").replace("maxmessagesize = 1024\n", "")
self.assertNotEqual(drifted, _template("recording.conf.j2"))
self.assertNotEqual(_ini(_render(drifted)), AIO_RECORDING)
class TestJanusConfig(unittest.TestCase):
def test_without_direct_media_it_equals_the_stock_janus_config(self):
rendered = _render(_template("janus.jcfg.j2"), NEXTCLOUD_HPB_MEDIA_DIRECT=False)
self.assertEqual(_settings(rendered), STOCK_JANUS)
def test_direct_media_adds_exactly_the_three_overrides(self):
rendered = _settings(
_render(_template("janus.jcfg.j2"), NEXTCLOUD_HPB_MEDIA_DIRECT=True)
)
expected = list(STOCK_JANUS)
expected.insert(expected.index("media:{") + 1, 'rtp_port_range="61000-61999"')
expected.insert(expected.index("nat:{") + 1, 'nat_1_1_mapping="203.0.113.7"')
expected.insert(expected.index("nat:{") + 2, "keep_private_host=true")
self.assertEqual(rendered, expected)
def test_negative_control_an_unguarded_override_breaks_the_stock_config(self):
drifted = _template("janus.jcfg.j2").replace(
"{% if NEXTCLOUD_HPB_MEDIA_DIRECT | bool %}\n\trtp_port_range",
"{% if true %}\n\trtp_port_range",
)
self.assertNotEqual(drifted, _template("janus.jcfg.j2"))
rendered = _render(drifted, NEXTCLOUD_HPB_MEDIA_DIRECT=False)
self.assertNotEqual(_settings(rendered), STOCK_JANUS)
class TestTalkDockerfiles(unittest.TestCase):
AIO_JANUS_BUILD_PACKAGES: ClassVar[list[str]] = [
"ca-certificates",
"git",
"autoconf",
"automake",
"build-base",
"pkgconfig",
"libtool",
"util-linux",
"glib-dev",
"zlib-dev",
"openssl-dev",
"jansson-dev",
"libnice-dev",
"libconfig-dev",
"libsrtp-dev",
"libusrsctp-dev",
"gengetopt-dev",
"libwebsockets-dev",
]
def test_janus_builds_the_pinned_tag_on_the_pinned_base(self):
rendered = _build("Janus")
self.assertEqual(
re.findall(r"^FROM (\S+)", rendered, re.MULTILINE),
["alpine:3.23.3", "alpine:3.23.3"],
)
self.assertIn(
'git clone --recursive "https://github.com/meetecho/janus-gateway.git" '
'--depth=1 --single-branch --branch "v1.4.0" /src',
rendered,
)
self.assertIn(
"/src/configure --disable-rabbitmq --disable-mqtt --disable-boringssl",
rendered,
)
build_stage = rendered.split("\nFROM ")[1]
for package in self.AIO_JANUS_BUILD_PACKAGES:
with self.subTest(package=package):
self.assertRegex(build_stage, rf"\n\s+{re.escape(package)}( \\|;)")
self.assertIn(
'ENTRYPOINT ["janus", "--disable-colors", "--log-stdout", '
'"--full-trickle", "--debug-level", "3"]',
rendered,
)
def test_recording_installs_the_pinned_release_on_the_pinned_base(self):
rendered = _build("Recording")
self.assertEqual(
re.findall(r"^FROM (\S+)", rendered, re.MULTILINE),
["python:3.14.3-alpine3.23"],
)
self.assertIn(
'git clone --recursive "https://github.com/nextcloud/nextcloud-talk-recording.git" '
'--depth=1 --single-branch --branch "v0.2.1" /src',
rendered,
)
self.assertIn('useradd -d "/tmp" --system recording -u "122"', rendered)
self.assertIn('mkdir -p "/conf"', rendered)
self.assertIn(
"""printf '{"policies":{"Certificates":{"Install":["%s"]}}}\\n' """
'"/tmp/infinito/ca/root-ca.crt" > /etc/firefox/policies/policies.json',
rendered,
)
self.assertIn("USER 122", rendered)
self.assertIn(
'ENTRYPOINT ["python", "-m", "nextcloud.talk.recording"]', rendered
)
def test_the_compose_command_points_each_binary_at_its_rendered_config(self):
compose = _template("compose.yml.j2")
self.assertIn(
'command: ["--config={{ NEXTCLOUD_HPB_JANUS_CONFIG_TARGET }}"]', compose
)
self.assertIn(
'command: ["--config", "{{ NEXTCLOUD_RECORDING_CONFIG_TARGET }}"]', compose
)
def test_no_image_ships_a_start_script_that_writes_its_config(self):
for name in ("Janus", "Recording"):
with self.subTest(dockerfile=name):
rendered = _build(name)
self.assertNotRegex(rendered, r"ENTRYPOINT \[\"[^\"]*\.sh\"")
self.assertNotIn(
"COPY",
rendered.split("\nFROM ")[-1].replace("COPY --from=build", ""),
)
self.assertNotIn("SECRET", rendered)
def test_negative_control_an_arg_compose_does_not_pass_fails_the_build(self):
with self.assertRaisesRegex(AssertionError, "JANUS_UNPASSED"):
_build_args("ARG JANUS_UNPASSED\nFROM ${JANUS_UNPASSED}\n")
if __name__ == "__main__":
unittest.main()
@@ -28,48 +28,100 @@ ROLE = "roles/web-app-nextcloud"
TEMPLATE = f"{ROLE}/{ROLE_FILE_TEMPL_COMPOSE}"
PUBLIC_IP = "203.0.113.7"
SERVICES = load_yaml_any(str(PROJECT_ROOT / ROLE / ROLE_FILE_META_SERVICES))
TALK_SERVICES = ("talk", "talk_signaling", "talk_nats", "talk_recording")
LEGACY_BRIDGE_ONBOARD = """ talk:
JANUS_HEAD = """ talk:
<container-base>
<lookup container_healthcheck ('talk',)>
<lookup container_image ('web-app-nextcloud', 'talk')>
build:
context: <NEXTCLOUD_HPB_JANUS_BUILD_DIRECTORY>
dockerfile: Dockerfile
args:
JANUS_BASE_IMAGE: "<NEXTCLOUD_HPB_JANUS_BASE_IMAGE>"
JANUS_BASE_VERSION: "<NEXTCLOUD_HPB_JANUS_BASE_VERSION>"
JANUS_REPOSITORY: "<NEXTCLOUD_HPB_JANUS_REPOSITORY>"
JANUS_REF: "<NEXTCLOUD_HPB_JANUS_REF>"
<lookup compose_only ('pull_policy', 'never')>
<lookup container_image ('web-app-nextcloud', 'talk') {'version': <NEXTCLOUD_HPB_JANUS_TAG>}>
<lookup compose_only ('container_name', <NEXTCLOUD_HPB_CONTAINER>)>
command: ["--config=<NEXTCLOUD_HPB_JANUS_CONFIG_TARGET>"]
init: true
ports:
- "203.0.113.7:3480:3480/tcp"
- "203.0.113.7:3480:3480/udp"
- "203.0.113.7:50000-59999:50000-59999/udp"
- "203.0.113.7:50000-59999:50000-59999/tcp"
<lookup container_healthcheck ('talk',)>
\n configs:
- source: janus_conf
target: /usr/local/etc/janus/janus.jcfg
mode: 292"""
JANUS_HOST = JANUS_HEAD + "\n network_mode: host"
JANUS_BRIDGE = (
JANUS_HEAD
+ """
expose:
- "8081"
<lookup container_networks ()>"""
LEGACY_BRIDGE = """ talk:
<container-base>
<lookup container_healthcheck ('talk',)>
<lookup container_image ('web-app-nextcloud', 'talk')>
<lookup compose_only ('container_name', <NEXTCLOUD_HPB_CONTAINER>)>
init: true
expose:
- "8081"
<lookup container_networks ()>"""
LEGACY_HOST = """ talk:
<container-base>
<lookup container_healthcheck ('talk',)>
<lookup container_image ('web-app-nextcloud', 'talk')>
<lookup compose_only ('container_name', <NEXTCLOUD_HPB_CONTAINER>)>
init: true
network_mode: host"""
DIRECT_MOUNT = (
" \n"
" configs:\n"
" - source: janus_conf\n"
" target: /usr/local/etc/janus/janus.jcfg"
- "<NEXTCLOUD_HPB_JANUS_PORT>"
<lookup container_networks () {'own_network_only': True, 'provider_self_alias': False}>"""
)
DIRECT_PORT = ' - "203.0.113.7:61000-61999:61000-61999/udp"'
DIRECT_PORT = (
' ports:\n - "203.0.113.7:61000-61999:61000-61999/udp"\n expose:'
)
SIGNALING = """ talk_signaling:
<container-base>
<lookup container_image ('web-app-nextcloud', 'talk_signaling')>
<lookup compose_only ('container_name', <NEXTCLOUD_HPB_SIGNALING_CONTAINER>)>
user: "<NEXTCLOUD_HPB_SIGNALING_USER_ID>:<NEXTCLOUD_HPB_SIGNALING_USER_ID>"
command: ["<NEXTCLOUD_HPB_SIGNALING_BINARY>", "-config", "<NEXTCLOUD_HPB_SIGNALING_CONFIG_TARGET>"]
<lookup container_healthcheck ('talk_signaling',)>
\n secrets:
- source: talk_signaling_conf
target: /config/server.conf
mode: 292
expose:
- "<NEXTCLOUD_HPB_SIGNALING_PORT>"
<lookup container_extra_hosts () {'host_alias': {host_alias}}>
<lookup container_networks () {'provider_self_alias': False}>"""
NATS = """ talk_nats:
<container-base>
<lookup container_image ('web-app-nextcloud', 'talk_nats')>
<lookup compose_only ('container_name', <NEXTCLOUD_HPB_NATS_CONTAINER>)>
user: "<NEXTCLOUD_HPB_NATS_USER_ID>:<NEXTCLOUD_HPB_NATS_USER_ID>"
command: ["nats-server", "--addr", "0.0.0.0", "--port", "<NEXTCLOUD_HPB_NATS_PORT>", "--http_port", "<NEXTCLOUD_HPB_NATS_MONITOR_PORT>"]
<lookup container_healthcheck ('talk_nats',)>
expose:
- "<NEXTCLOUD_HPB_NATS_PORT>"
<lookup container_networks () {'own_network_only': True, 'provider_self_alias': False}>"""
RECORDING = """ talk_recording:
<container-base>
build:
context: <NEXTCLOUD_RECORDING_BUILD_DIRECTORY>
dockerfile: Dockerfile
args:
RECORDING_BASE_IMAGE: "<NEXTCLOUD_RECORDING_BASE_IMAGE>"
RECORDING_BASE_VERSION: "<NEXTCLOUD_RECORDING_BASE_VERSION>"
RECORDING_REPOSITORY: "<NEXTCLOUD_RECORDING_REPOSITORY>"
RECORDING_REF: "<NEXTCLOUD_RECORDING_REF>"
RECORDING_USER_ID: "<NEXTCLOUD_RECORDING_USER_ID>"
RECORDING_DIRECTORY: "<NEXTCLOUD_RECORDING_DIRECTORY>"
RECORDING_CONFIG_DIRECTORY: "<dirname <NEXTCLOUD_RECORDING_CONFIG_TARGET>>"
RECORDING_CA_CERT: "<NEXTCLOUD_RECORDING_CA_CERT>"
<lookup compose_only ('pull_policy', 'never')>
<lookup container_image ('web-app-nextcloud', 'talk_recording') {'version': <NEXTCLOUD_RECORDING_TAG>}>
<lookup compose_only ('container_name', <NEXTCLOUD_RECORDING_CONTAINER>)>
command: ["--config", "<NEXTCLOUD_RECORDING_CONFIG_TARGET>"]
environment:
TZ: "<HOST_TIMEZONE>"
<lookup container_healthcheck ('talk_recording',)>
expose:
- "<NEXTCLOUD_RECORDING_PORT>"
<lookup container_networks () {'provider_self_alias': False}>
\n volumes:
- talk_recording_tmp:/tmp
secrets:
- source: talk_recording_conf
target: /conf/recording.conf
mode: 292"""
class _Marker(ChainableUndefined):
@@ -87,9 +139,7 @@ def _bool(value):
return str(value).strip().lower() in {"y", "yes", "true", "on", "1"}
def _render(
template_text: str, *, onboard: bool, direct: bool, mode: str, network: str
) -> str:
def _render(template_text: str, *, direct: bool, mode: str, network: str) -> str:
applications = {"web-app-nextcloud": {"services": SERVICES}}
env = Environment(
loader=ChoiceLoader(
@@ -113,6 +163,7 @@ def _render(
)
env.filters["bool"] = _bool
env.filters["path_join"] = lambda parts: "/".join(str(part) for part in parts)
env.filters["dirname"] = lambda value: f"<dirname {value}>"
variables = {
"application_id": "web-app-nextcloud",
"NEXTCLOUD_PROXY_SERVICE": "proxy",
@@ -122,11 +173,12 @@ def _render(
"DEPLOYMENT_MODE": mode,
"NEXTCLOUD_HPB_SIGNALING_ENABLED": True,
"NEXTCLOUD_HPB_SERVICE": "talk",
"NEXTCLOUD_HPB_SIGNALING_PORT": 8081,
"NEXTCLOUD_HPB_SIGNALING_SERVICE": "talk_signaling",
"NEXTCLOUD_HPB_NATS_SERVICE": "talk_nats",
"NEXTCLOUD_RECORDING_SERVICE": "talk_recording",
"NEXTCLOUD_RECORDING_ENABLED": True,
"NEXTCLOUD_HPB_NETWORK_MODE": network,
"NEXTCLOUD_HPB_TURN_ONBOARD_ENABLED": onboard,
"NEXTCLOUD_HPB_TURN_ONBOARD_PUBLISH_PORTS": True,
"NEXTCLOUD_HPB_TURN_ONBOARD_BIND_HOST": PUBLIC_IP,
"NEXTCLOUD_HPB_JANUS_HOST_NETWORK": network == "host",
"NEXTCLOUD_HPB_MEDIA_DIRECT": direct,
"NEXTCLOUD_HPB_MEDIA_PUBLIC_IP": PUBLIC_IP,
}
@@ -144,7 +196,10 @@ def _render(
def lookup(name, *args, **kwargs):
if name == "config":
return {"services.talk.direct_media": direct}[args[1]]
return {
"services.talk.direct_media": direct,
"services.talk.enabled": True,
}[args[1]]
if name == "container_ports":
with mock.patch.object(container_ports_lookup, "lookup_loader", _Loader()):
return container_ports_lookup.LookupModule().run(
@@ -155,15 +210,16 @@ def _render(
applications, "web-app-nextcloud", args[0], render_jinja=render_jinja
)
return "\n" + block if block else block
return f"<lookup {name} {args!r}>"
suffix = f" {dict(sorted(kwargs.items()))!r}" if kwargs else ""
return f"<lookup {name} {args!r}{suffix}>"
env.globals["lookup"] = lookup
return env.get_template("compose.yml.j2").render(variables)
def _talk_block(rendered: str) -> str:
def _block(rendered: str, service: str) -> str:
lines = rendered.splitlines()
start = lines.index(" talk:")
start = lines.index(f" {service}:")
end = next(
index
for index in range(start + 1, len(lines))
@@ -172,72 +228,80 @@ def _talk_block(rendered: str) -> str:
return "\n".join(lines[start:end]).rstrip()
def _cells(direct: bool):
for onboard, mode, network in itertools.product(
def _cells():
for direct, mode, network in itertools.product(
(True, False), ("compose", "swarm"), ("bridge", "host")
):
yield {"onboard": onboard, "direct": direct, "mode": mode, "network": network}
yield {"direct": direct, "mode": mode, "network": network}
def _legacy(cell) -> str:
if cell["network"] == "host":
return LEGACY_HOST
return LEGACY_BRIDGE_ONBOARD if cell["onboard"] else LEGACY_BRIDGE
def _expected_janus(cell) -> str:
if cell["mode"] == "compose" and cell["network"] == "host":
return JANUS_HOST
if cell["direct"]:
return JANUS_BRIDGE.replace(" expose:", DIRECT_PORT)
return JANUS_BRIDGE
class TestTalkComposeBlock(unittest.TestCase):
def test_every_cell_without_direct_media_renders_the_legacy_block(self):
template = read_text(str(PROJECT_ROOT / TEMPLATE))
for cell in _cells(direct=False):
def _template() -> str:
return read_text(str(PROJECT_ROOT / TEMPLATE))
class TestTalkComposeBlocks(unittest.TestCase):
def test_janus_block_follows_network_mode_and_direct_media(self):
for cell in _cells():
with self.subTest(**cell):
self.assertEqual(_talk_block(_render(template, **cell)), _legacy(cell))
def test_host_networking_ignores_direct_media(self):
template = read_text(str(PROJECT_ROOT / TEMPLATE))
for cell in _cells(direct=True):
if cell["network"] == "host":
with self.subTest(**cell):
self.assertEqual(
_talk_block(_render(template, **cell)), LEGACY_HOST
)
def test_direct_media_adds_only_the_janus_mount_and_the_udp_range(self):
template = read_text(str(PROJECT_ROOT / TEMPLATE))
for cell in _cells(direct=True):
if cell["network"] != "bridge":
continue
with self.subTest(**cell):
block = _talk_block(_render(template, **cell))
legacy = _legacy(cell)
expected = legacy.replace(
" init: true", " init: true\n" + DIRECT_MOUNT
self.assertEqual(
_block(_render(_template(), **cell), "talk"), _expected_janus(cell)
)
if cell["onboard"]:
expected = expected.replace(
" expose:", DIRECT_PORT + "\n expose:"
)
else:
expected = expected.replace(
" expose:", " ports:\n" + DIRECT_PORT + "\n expose:"
)
self.assertEqual(block, expected)
def test_negative_control_an_unguarded_mount_breaks_the_legacy_block(self):
template = read_text(str(PROJECT_ROOT / TEMPLATE)).replace(
" {% if NEXTCLOUD_HPB_MEDIA_DIRECT | bool %}\n"
" {{ lookup('container_volumes', service_name) | indent(4) }}\n",
" {{ lookup('container_volumes', service_name) | indent(4) }}\n"
" {% if NEXTCLOUD_HPB_MEDIA_DIRECT | bool %}\n",
def test_signaling_block_is_the_same_in_every_cell(self):
for cell in _cells():
with self.subTest(**cell):
expected = SIGNALING.replace(
"{host_alias}", str(cell["network"] == "host")
)
self.assertEqual(
_block(_render(_template(), **cell), "talk_signaling"), expected
)
def test_nats_and_recording_blocks_are_the_same_in_every_cell(self):
for cell in _cells():
with self.subTest(**cell):
rendered = _render(_template(), **cell)
self.assertEqual(_block(rendered, "talk_nats"), NATS)
self.assertEqual(_block(rendered, "talk_recording"), RECORDING)
def test_no_talk_service_passes_a_secret_through_compose(self):
for cell in _cells():
rendered = _render(_template(), **cell)
for service in TALK_SERVICES:
with self.subTest(service=service, **cell):
self.assertNotIn("SECRET", _block(rendered, service))
def test_negative_control_an_ungated_host_network_reaches_swarm(self):
template = _template().replace(
"{% if DEPLOYMENT_MODE == 'compose' and NEXTCLOUD_HPB_JANUS_HOST_NETWORK | bool %}",
"{% if NEXTCLOUD_HPB_JANUS_HOST_NETWORK | bool %}",
)
cell = {
"onboard": False,
"direct": False,
"mode": "compose",
"network": "bridge",
}
rendered = _render(template, **cell)
self.assertNotIn("janus_conf", rendered)
self.assertNotEqual(_talk_block(rendered), _legacy(cell))
cell = {"direct": False, "mode": "swarm", "network": "host"}
self.assertNotEqual(template, _template())
block = _block(_render(template, **cell), "talk")
self.assertIn("network_mode: host", block)
self.assertNotEqual(block, _expected_janus(cell))
def test_negative_control_a_secret_in_the_environment_is_caught(self):
template = _template().replace(
' TZ: "{{ HOST_TIMEZONE }}"',
' TZ: "{{ HOST_TIMEZONE }}"\n'
' RECORDING_SECRET: "{{ NEXTCLOUD_RECORDING_SECRET }}"',
)
self.assertNotEqual(template, _template())
block = _block(
_render(template, direct=False, mode="compose", network="bridge"),
"talk_recording",
)
self.assertIn("SECRET", block)
ROLE_VARS = load_yaml_any(str(PROJECT_ROOT / ROLE / ROLE_FILE_VARS_MAIN))
@@ -294,9 +358,9 @@ class TestDirectMediaFlag(unittest.TestCase):
def test_opting_in_with_standalone_turn_and_an_owned_address_turns_it_on(self):
self.assertIs(_direct_media(list(STANDALONE_TURN), applications=OPTED_IN), True)
def test_host_networking_keeps_it_off(self):
def test_host_networking_keeps_it_on(self):
host = _talk(direct_media=True, network_mode="host")
self.assertIs(_direct_media(list(STANDALONE_TURN), applications=host), False)
self.assertIs(_direct_media(list(STANDALONE_TURN), applications=host), True)
def test_an_address_the_host_does_not_own_keeps_it_off(self):
self.assertIs(
@@ -318,7 +382,7 @@ class TestDirectMediaFlag(unittest.TestCase):
False,
)
def test_onboard_turn_keeps_it_off(self):
def test_talk_without_coturn_keeps_it_off(self):
forced = _talk(enabled=True, direct_media=True)
self.assertIs(_direct_media(["web-app-nextcloud"], applications=forced), False)
@@ -0,0 +1,137 @@
from __future__ import annotations
import os
import re
import unittest
import unittest.mock as mock
from typing import ClassVar
from utils.cache.files import PROJECT_ROOT, read_text
from utils.cache.yaml import load_yaml_any
from utils.roles.mapping import ROLE_FILE_META_SERVICES
from utils.update import docker, repository
ROLE = "web-app-nextcloud"
SERVICES_FILE = PROJECT_ROOT / "roles" / ROLE / ROLE_FILE_META_SERVICES
JANUS = "https://github.com/meetecho/janus-gateway.git"
RECORDING = "https://github.com/nextcloud/nextcloud-talk-recording.git"
def _pinned(service: str, key: str) -> str:
return str(load_yaml_any(str(SERVICES_FILE))[service][key])
class TestTalkPinsAreWatched(unittest.TestCase):
IMAGES: ClassVar[dict[str, str]] = {
"talk_signaling": "strukturag/nextcloud-spreed-signaling",
"talk_nats": "nats",
"talk": "alpine",
"talk_recording": "python",
}
REFS: ClassVar[dict[str, str]] = {"talk": JANUS, "talk_recording": RECORDING}
def test_the_docker_updater_watches_every_talk_image(self):
entries = {
(entry.service, entry.image): entry.version
for entry in docker.collect_entries(PROJECT_ROOT)
if entry.role == ROLE
}
for service, image in self.IMAGES.items():
with self.subTest(service=service):
self.assertEqual(
entries.get((service, image)), _pinned(service, "version")
)
def test_the_repository_updater_watches_both_built_sources(self):
entries = {
entry.entity_path: (entry.repository, entry.ref)
for entry in repository.collect_entries(PROJECT_ROOT)
if entry.role == ROLE
}
for service, url in self.REFS.items():
with self.subTest(service=service):
self.assertEqual(
entries.get((service,)), (url, _pinned(service, "ref"))
)
def test_no_talk_pin_opts_out_of_the_updaters(self):
lines = read_text(str(SERVICES_FILE)).splitlines()
owner = None
for number, line in enumerate(lines):
if match := re.match(r"^([a-z_]+):", line):
owner = match.group(1)
if owner in self.IMAGES and re.match(r"^\s+(version|ref):", line):
with self.subTest(service=owner, line=number + 1):
self.assertNotIn("nocheck", lines[number - 1])
self.assertNotIn("nocheck", line)
def test_a_newer_upstream_release_is_reported_for_every_pin(self):
newer = {
"strukturag/nextcloud-spreed-signaling": ["2.1.1", "9.0.0", "9.0.0-proxy"],
"nats": ["2.12.6-alpine", "9.0.0-alpine", "9.1.0"],
"alpine": ["3.23.3", "9.0.0"],
"python": ["3.14.3-alpine3.23", "3.14.9-alpine3.23", "3.15.0-slim"],
}
tags = {JANUS: ["v1.4.0", "v9.0.0"], RECORDING: ["v0.2.1", "v9.0.0"]}
with (
mock.patch.dict(os.environ, {"INFINITO_WORKER_FETCH": "2"}),
mock.patch.object(
docker, "fetch_dockerhub_tags", lambda image: newer.get(image, [])
),
mock.patch.object(docker, "fetch_ghcr_tags", lambda image: []),
mock.patch.object(docker, "fetch_mcr_tags", lambda image: []),
mock.patch.object(
docker, "fetch_registry_tags", lambda image, last=None: []
),
mock.patch.object(
repository, "git_ls_remote_tags", lambda url: tags.get(url, [])
),
):
images = {
(update.entry.service, update.latest)
for update in docker.find_outdated_updates(PROJECT_ROOT)
if update.entry.role == ROLE
}
refs = {
(update.entry.entity_path, update.latest)
for update in repository.find_outdated_updates(PROJECT_ROOT)
if update.entry.role == ROLE
}
self.assertTrue(
{
("talk_signaling", "9.0.0"),
("talk_nats", "9.0.0-alpine"),
("talk", "9.0.0"),
("talk_recording", "3.14.9-alpine3.23"),
}
<= images,
images,
)
self.assertEqual(refs, {(("talk",), "v9.0.0"), (("talk_recording",), "v9.0.0")})
def test_negative_control_current_tags_report_nothing(self):
current = {
image: [_pinned(service, "version")]
for service, image in self.IMAGES.items()
}
with (
mock.patch.dict(os.environ, {"INFINITO_WORKER_FETCH": "2"}),
mock.patch.object(
docker, "fetch_dockerhub_tags", lambda image: current.get(image, [])
),
mock.patch.object(docker, "fetch_ghcr_tags", lambda image: []),
mock.patch.object(docker, "fetch_mcr_tags", lambda image: []),
mock.patch.object(
docker, "fetch_registry_tags", lambda image, last=None: []
),
):
reported = {
update.entry.service
for update in docker.find_outdated_updates(PROJECT_ROOT)
if update.entry.role == ROLE
}
self.assertFalse(reported & set(self.IMAGES), reported)
if __name__ == "__main__":
unittest.main()