1 - Getting Started

Introduction to AgileTV CDN Manager

Overview

The AgileTV CDN Manager (product code ESB3027) is a cloud-native control plane for managing CDN deployments. It provides centralized orchestration for authentication, configuration, routing, and metrics collection across CDN infrastructure.

Before You Start:

  • Deployment type: Lab (single-node) or Production (multi-node)? See Installation Guide
  • Hardware: Nodes meeting specifications for your deployment type
  • OS: RHEL 9.4 or later, or a compatible clone (Oracle Linux, AlmaLinux, Rocky Linux)
  • Software: Installation ISO from AgileTV customer portal
  • Network: A default route on every node and, optional but recommended, a backplane network for node-to-node traffic, separate from the public network. See the Networking Guide

Deployment Models

Deployment ModelDescriptionTypical Use Case
Self-HostedK3s Kubernetes cluster on customer premisesProduction deployments
Lab/Single-NodeMinimal single-node installationAcceptance testing, demonstrations, development

Functionality remains consistent across deployment models.

Prerequisites

  • Installation ISO: Obtain esb3027-cdn-manager-X.Y.Z.iso from AgileTV customer portal
  • OS: RHEL 9.4 or later, or a compatible clone (Oracle Linux, AlmaLinux, Rocky Linux)
  • Kubernetes familiarity: Basic understanding of pods and deployments helps with troubleshooting

For detailed hardware, network, and operating system requirements, see the System Requirements Guide.

Installation

Ready to install? The Quickstart takes three freshly installed machines to a working cluster, step by step, with the deploy command on the installation ISO. The Installation Guide covers the details and variations, including single-node labs.

Accessing the System

Following successful deployment, the following interfaces are available:

ServiceURL PathAuthentication
MIB Frontend/guiZitadel SSO
API Gateway/apiBearer token
Zitadel Console/ui/consoleSee Glossary
Grafana/grafanaSee Glossary

All services are accessed via https://<cluster-host><path>.

Important: <cluster-host> must be the main manager hostname, the first entry of manager_hostnames in the site settings. Pages may load under another name, but signing in only works using the configured hostname. To reach the system by IP address, configure that IP address at installation time.

Note: Without a TLS certificate of your own, a temporary self-signed certificate is served, and browsers show a warning. For production deployments, install a certificate from a trusted certificate authority before exposing the system to users. See TLS Certificates.

Initial user configuration is performed through Zitadel. See Next Steps for securing the administrator account and adding users. For detailed guidance on managing users, roles, and permissions in the Zitadel Console, see Zitadel’s User Management Documentation.

Documentation Navigation

The following guides provide detailed information for specific operational tasks:

GuideDescription
System RequirementsHardware, operating system, and network specifications
ArchitectureDetailed system architecture and scaling guidance
InstallationStep-by-step installation and upgrade procedures
ConfigurationSystem configuration and customization
Performance TuningOptimization tips for improved performance
API GuideREST API reference and integration examples
OperationsDay-to-day operational procedures
Metrics & MonitoringMonitoring dashboards and alerting configuration
TroubleshootingCommon issues and resolution procedures
GlossaryDefinitions of technical terms
Release NotesVersion-specific changes and known issues

2 - System Requirements Guide

Hardware, operating system, and networking requirements

Overview

This document specifies the hardware, operating system, and networking requirements for deploying the AgileTV CDN Manager (ESB3027). Requirements vary based on deployment type and node role within the cluster.

Cluster Sizing

Production Deployments

Production deployments require a minimum of three nodes to achieve high availability. The cluster architecture employs distinct node roles:

RoleDescription
Server Node (Control Plane Only)Runs control plane components (etcd, Kubernetes API server) only; does not host application workloads; requires separate Agent nodes
Server Node (Combined)Runs control plane components and hosts application workloads; default configuration
Agent NodeExecutes application workloads only; does not participate in cluster quorum

Server nodes can be deployed in either Control Plane Only or Combined role configurations. The choice depends on your deployment requirements:

  • Control Plane Only: Dedicated control plane nodes with lower resource requirements; requires separate Agent nodes for workloads
  • Combined: Server nodes run both control plane and workloads; minimum 3 nodes required for HA

Why Use Control Plane Only Nodes?

Dedicated Control Plane Only nodes provide several benefits for larger deployments:

  • Resource Isolation: Control plane components (etcd, API server, scheduler) run on dedicated hardware without competing with application workloads for CPU and memory
  • Stability: Application workload spikes or misbehaving pods cannot impact control plane performance
  • Security: Smaller attack surface on control plane nodes; fewer containers and services running
  • Predictable Performance: Control plane responsiveness remains consistent regardless of application load
  • Flexible Sizing: Control Plane Only nodes can use lower-specification hardware (2 cores, 4 GiB) since they don’t run application workloads

For most small to medium deployments, Combined role servers are simpler and more cost-effective. Control Plane Only nodes are recommended for larger deployments with significant workload requirements or where control plane stability is critical.

High Availability Considerations

Production deployments require 3 nodes running control plane (etcd) and 3 nodes capable of running workloads. These can be the same nodes (Combined role) or separate nodes (CP-Only + Agent).

Node Role Combinations:

ConfigurationControl Plane NodesWorkload NodesTotal Nodes
All Combined3 Combined servers3 Combined servers3
Separated3 CP-Only servers3 Agent nodes6
Hybrid2 CP-Only + 1 Combined1 Combined + 2 Agent5

Any combination works as long as you have 3 control plane nodes and 3 workload-capable nodes.

Note: Regardless of the deployment configuration, a minimum of 3 nodes capable of running workloads is required for production deployments. This ensures both high availability and sufficient capacity for application pods.

For detailed fault tolerance information and data replication strategies, see the Architecture Guide.

Hardware Requirements

Single-Node Lab Deployment

Lab deployments are intended for acceptance testing, demonstrations, and development only. These configurations are not suitable for production workloads.

ResourceMinimumRecommended
CPU8 cores12 cores
Memory16 GiB24 GiB
Disk*128 GiB128 GiB

Production Cluster - Server Node (Control Plane Only)

Server nodes dedicated to control plane functions have modest resource requirements:

ResourceMinimumRecommended
CPU2 cores4 cores
Memory4 GiB8 GiB
Disk*64 GiB128 GiB

These nodes run only control plane components and require separate Agent nodes to run application workloads.

Production Cluster - Server Node (Control Plane + Workloads)

Combined role nodes require resources for both control plane and application workloads:

ResourceMinimumRecommended
CPU4 cores16 cores
Memory8 GiB32 GiB
Disk*100 GiB500 GiB

Production Cluster - Agent Node

Agent nodes execute application workloads and require the following resources:

ResourceMinimumRecommended
CPU4 cores16 cores
Memory8 GiB32 GiB
Disk*100 GiB500 GiB

Swap

Swap may stay on: deploy leaves it as it is, and the cluster runs with or without it. Size memory so that swap is not used in normal operation. Swap in use is a sign of memory pressure.

Storage Notes

* Disk Space: All disk space values must be available in the /var/lib/longhorn partition. It is recommended that /var/lib/longhorn be a separate partition on a fast SSD for optimal performance, though SSD is not strictly required.

Longhorn Capacity: Longhorn storage requires an additional 30% capacity headroom for internal operations and scaling. If less than 30% of the total partition capacity is available, Longhorn may mark volumes as “full” and prevent further writes. Plan disk capacity accordingly.

Storage Performance

For optimal performance, the following storage characteristics are recommended:

  • Disk Type: SSD or NVMe storage for Longhorn volumes
  • Filesystem: XFS or ext4 with default mount options
  • Partition Layout: Dedicated /var/lib/longhorn partition for persistent storage

Virtual machines and bare-metal hardware are both supported. Nested virtualization (running multiple nodes under a single hypervisor) may impact performance and is not recommended for production deployments.

Operating System Requirements

Supported Operating Systems

The CDN Manager supports Red Hat Enterprise Linux and compatible distributions:

Operating SystemStatus
Red Hat Enterprise Linux 9.4 or later 9.xSupported
Red Hat Enterprise Linux 10Untested
Red Hat Enterprise Linux 8Not supported

Compatible Clones

The following RHEL-compatible distributions are supported at release 9.4 or later:

  • Oracle Linux 9
  • AlmaLinux 9
  • Rocky Linux 9

A minimal installation is enough. Keep firewalld and NetworkManager installed and running: deploy configures the firewall and network through them.

Air-Gapped Deployments

Important: deploy installs a few packages from the operating system’s repositories on every node. For air-gapped deployments (no internet access), make the OS installation media available to dnf on all nodes first. See Air-Gapped Installation.

Oracle Linux UEK Kernel

On Oracle Linux 9 running Unbreakable Enterprise Kernel 8 (UEK 8), install the netfilter kernel modules that K3s and Longhorn need on every node before running the installer:

dnf install kernel-uek-modules-extra-netfilter-$(uname -r)

The package comes from Oracle’s UEK 8 repository, ol9_UEKR8. If dnf cannot find it, check that the repository is enabled, and enable it if not:

dnf repolist
dnf config-manager --enable ol9_UEKR8

Without internet access, that repository is not available. Make the package available to dnf from a local repository first, as for the other packages in Air-Gapped Installation, and check that dnf finds it:

dnf -q repoquery kernel-uek-modules-extra-netfilter-$(uname -r)

SELinux

SELinux is supported in any mode, “Enforcing” included. The installation process installs the SELinux policy for the cluster automatically.

Networking Requirements

Network Interface

Every cluster node needs an IPv4 default route on its public interface, the interface that clients reach it on. The route does not need to lead to the internet: on an isolated network, use your site’s router or any gateway address on that subnet. deploy uses this route to find each machine’s public interface and address, and stops with [ACD-NO-DEFAULT-ROUTE] without one.

A second interface on a backplane network, separate from the public network, is optional but recommended for production. All node-to-node traffic then uses the backplane, and every node must be reachable on it. Other hosts may share it. See Configuring Segregated Networks.

Port Requirements

The cluster requires the following network connectivity:

CategoryPortsPurpose
Inter-Node2379-2380, 6443, 8472/UDP, 10250, 5001, 9100etcd, API server, Flannel VXLAN, Kubelet, Spegel, node-exporter
External Access80, 443, 8086HTTP and HTTPS ingress, metrics from the CDN Directors
CDN Directors9092, 30000-30003 (one more per broker beyond three)Kafka

By default, deploy configures the host firewall for the inter-node and external ports itself. The host firewall does not filter the Kafka ports: restrict them to your CDN Directors on your network’s own firewall. See Kafka Ports. The same applies to the two ingress NodePorts, chosen at installation: see Ingress NodePorts.

Important: Complete port requirements, network ranges, and firewall configuration procedures are provided in the Networking Guide. Do not expose VictoriaMetrics (8428, 8429), Grafana (3000), or PostgreSQL (5432) directly—access these services only through the secure HTTPS ingress (port 443).

Resource Planning

Calculating Cluster Capacity

When planning cluster capacity, consider the following factors:

  1. Base Overhead: Kubernetes system components consume approximately 1-2 cores and 2-4 GiB memory per node
  2. Application Workloads: Refer to individual component resource requirements in the Architecture Guide
  3. Headroom: Maintain 20-30% resource headroom for workload spikes and automatic scaling

Scaling Considerations

The CDN Manager supports horizontal scaling for most components. The Horizontal Pod Autoscaler (HPA) can automatically adjust replica counts based on resource utilization. Detailed scaling guidance is available in the Architecture Guide.

Example Production Deployment

A minimal production deployment with 3 server nodes (combined role) and 2 agent nodes would require:

Node TypeCountCPU TotalMemory TotalDisk Total
Server (Combined)312 cores24 GiB300 GiB
Agent28 cores16 GiB200 GiB
Total520 cores40 GiB500 GiB

This configuration provides:

  • High availability (survives loss of 1 server node)
  • Capacity for application workloads across all nodes
  • Headroom for horizontal scaling

Next Steps

After verifying system requirements:

  1. Review the Installation Guide for deployment procedures
  2. Consult the Networking Guide for firewall configuration
  3. Examine the Architecture Guide for component resource requirements

3 - Networking Guide

Network layout, firewall and ports

Overview

deploy configures each machine’s network for the cluster: its firewall, its routing rules for cluster traffic, and the kernel network settings. The site setting backplane_network chooses the layout. A second setting, manage_firewall, chooses whether deploy manages the firewall or you do.

LayoutGuideRecommended for
Segregated: a backplane network for traffic between the machines, separate from the public network for clientsConfiguring Segregated NetworksProduction. Cluster traffic is kept off the public network.
Shared: one network for everythingShared Interface SetupMachines with a single network interface.

A backplane is optional, but we recommend one wherever the machines can have a second network. The backplane is the network the cluster machines use to reach each other (east-west traffic). Every machine must be reachable on it, and it should be a separate network from the public one. Setting it to the public network’s range works, but then every host on the public network is trusted for all traffic. It does not have to be reserved for the cluster: other hosts may also be on it, but with the firewall managed they can reach every port on the machines, including the cluster datastore (etcd, 2379-2380/tcp), the Kubernetes API (6443/tcp) and the kubelet (10250/tcp).

Interfaces and Addresses

Every machine needs an IPv4 default route on its public interface, the interface that clients reach it on. The route does not need to lead to the internet: on an isolated network, use your site’s router or any gateway address on that subnet. deploy uses this route to find each machine’s public interface and address, and stops with [ACD-NO-DEFAULT-ROUTE] without one.

From that route, deploy works out each machine’s two addresses:

  • The public interface is the one holding the default route. Its address is the machine’s public address, which clients reach.
  • The cluster address is the address other machines reach it on: its address inside backplane_network when that is set, and otherwise its public address.

Interfaces that are neither the public interface nor the backplane are left as they are.

The cluster also uses two internal address ranges, which must not be in use anywhere on your network:

NetworkDefaultSetting
Pods10.42.0.0/16cluster_pod_cidr
Services10.43.0.0/16cluster_svc_cidr

Firewall

With manage_firewall: true, the default, deploy starts and enables firewalld on every machine and keeps two things as its own:

  • The acd-cluster zone, with target ACCEPT, which trusts all traffic from the cluster: the backplane interface (or, without a backplane, the other machines’ addresses), and the pod and service networks.
  • The acd-public policy, from the public zone to the machine, which opens the ports clients need:
    PortPurpose
    80/tcpHTTP ingress
    443/tcpHTTPS ingress: the GUI, the API, Zitadel and Grafana
    8086/tcpMetrics pushed by the CDN Directors (Vector)

It also places each machine’s public interface in the public zone, on its NetworkManager connection. Before the first run, make sure the public zone allows everything else you need on that interface, SSH included. To check, run firewall-cmd --zone=public --list-all on each machine: ssh should be listed under services, or port 22 under ports.

deploy removes anything in acd-cluster and acd-public that it did not put there. Add your own rules to other zones, such as public: deploy never changes their rules.

Ports Between the Machines

Within the cluster, the machines trust each other for all traffic. When you manage the firewall yourself, the ports the cluster needs between machines are:

PortBetweenPurpose
6443/tcpall machinesKubernetes API, also used by the image registry mirror on every machine
2379-2380/tcpserversCluster datastore (etcd)
8472/udpall machinesPod network (Flannel VXLAN)
10250/tcpall machinesKubelet
5001/tcpall machinesImage registry mirror (Spegel)
9100/tcpall machinesHost metrics (node-exporter)

The pod and service networks must also be in a zone with target ACCEPT.

Kafka Ports

The CDN Directors reach Kafka on these ports, on every machine:

PortPurpose
9092/tcpKafka bootstrap
30000/tcpKafka bootstrap, external
30001/tcp and upOne port per Kafka broker, numbered consecutively: 30001 for the first broker, 30002 for the second, and so on

With the default three brokers, the broker ports are 30001-30003, so the full range is 30000-30003. With more brokers, the range grows by one port per broker: six brokers use 30000-30006. In the rest of this documentation, “30000-30003” means this range for the default three brokers. Widen it if your cluster runs more.

deploy neither opens nor checks these ports, and the host firewall does not filter them: as on any host running containers, traffic that Kubernetes forwards to its services passes before the firewall’s zone rules apply. These ports are reachable from every network that can reach the machines.

Important: Restrict 9092/tcp and the whole 30000 range (30000-30003 with three brokers) to your CDN Directors on your network’s own firewall. The same applies to any other port a Kubernetes service publishes on the machines.

Ingress NodePorts

The ingress service (Traefik) also publishes two NodePorts on every machine, which forward to its HTTP and HTTPS ports, 80 and 443. Kubernetes picks them at installation from 30000-32767, so they differ from one cluster to another, and the host firewall does not filter them, as for the Kafka ports. Find them on a server:

kubectl get service traefik -n kube-system
NAME      TYPE           CLUSTER-IP    EXTERNAL-IP                          PORT(S)                      AGE
traefik   LoadBalancer   10.43.12.34   192.0.2.11,192.0.2.12,192.0.2.13     80:31873/TCP,443:30306/TCP   3d

Here they are 31873 (HTTP) and 30306 (HTTPS). Clients need only 80 and 443: block the two NodePorts on your network’s own firewall.

Managing the Firewall Yourself

Set manage_firewall: false to configure firewalld yourself. deploy then only checks that the ports between the machines and the ports clients need are open in the zone their traffic arrives in, and that the pod and service networks are in a zone with target ACCEPT. It stops before changing anything, and shows the firewall-cmd commands to run, if they are not. Only firewalld is checked: if it is not running, the check is skipped, and your own firewall must allow the same traffic.

To hand the firewall of a running cluster over to yourself, on each machine:

  1. Move the pod and service networks to a zone with target ACCEPT, such as firewalld’s own trusted. For the default ranges:

    for network in 10.42.0.0/16 10.43.0.0/16; do
        firewall-cmd --permanent --zone=acd-cluster --remove-source="$network"
        firewall-cmd --permanent --zone=trusted --add-source="$network"
        firewall-cmd --zone=trusted --change-source="$network"
    done
    
  2. Open the ports in the zone their traffic arrives in, with firewall-cmd --zone=<zone> --add-port=<port>, once with --permanent and once without.

  3. On a machine with a backplane, put the backplane interface in the zone you want: nmcli connection modify <its connection> connection.zone <zone>.

  4. Set manage_firewall: false in group_vars/all.yaml and run deploy.

deploy then empties the acd-cluster zone and the acd-public policy, and moves any interface still in acd-cluster to firewalld’s default zone, with a warning.

Routing

With manage_routes: true, the default, deploy adds lookup main routing rules at priority 50, so that cluster traffic always uses the main routing table, even on machines that use policy routing. On a machine with a backplane, ip rule then shows:

0:	from all lookup local
50:	from 10.42.0.0/16 lookup main proto static
50:	from all to 10.42.0.0/16 lookup main proto static
50:	from 10.43.0.0/16 lookup main proto static
50:	from all to 10.43.0.0/16 lookup main proto static
50:	from all to 10.10.0.0/24 lookup main proto static
32766:	from all lookup main
32767:	from all lookup default

Keep your own rules at other priorities. See manage_routes for how these rules interact with your own.

Managing Routing Yourself

If your own policy routing rules must sit at priority 50 or below, set manage_routes: false and add the cluster’s rules yourself. Add them before you change the setting on a running cluster: deploy then removes every priority-50 rule from the connections it manages.

Every machine needs five lookup main rules:

RuleDefault value
from <cluster_pod_cidr>from 10.42.0.0/16
to <cluster_pod_cidr>to 10.42.0.0/16
from <cluster_svc_cidr>from 10.43.0.0/16
to <cluster_svc_cidr>to 10.43.0.0/16
to <cluster network>backplane_network if set, such as to 10.10.0.0/24. Without a backplane, the subnet of the machine’s public interface, such as to 192.0.2.0/24.

Give them a priority other than 50, and a lower number than any of your own rules that send traffic to another table, such as your from <address> lookup <table> source rules. Store them on the NetworkManager connection of the interface the machines reach each other on: the backplane interface, or the public interface without a backplane. For example, at priority 40 on a backplane connection named eth1 (table 254 is main):

nmcli connection modify eth1 +ipv4.routing-rules "priority 40 from 10.42.0.0/16 table 254"
nmcli connection modify eth1 +ipv4.routing-rules "priority 40 to 10.42.0.0/16 table 254"
nmcli connection modify eth1 +ipv4.routing-rules "priority 40 from 10.43.0.0/16 table 254"
nmcli connection modify eth1 +ipv4.routing-rules "priority 40 to 10.43.0.0/16 table 254"
nmcli connection modify eth1 +ipv4.routing-rules "priority 40 to 10.10.0.0/24 table 254"

Stored rules take effect when the connection is next activated. To activate it now, run nmcli connection up eth1 in a maintenance window: it re-applies the whole connection, and briefly interrupts traffic on that interface. Then check the rules with ip rule show.

Whichever way the rules are managed, deploy checks before changing anything that cluster traffic resolves through the main routing table, and stops with [ACD-ROUTING-CONFLICT], naming the rule in the way, if it does not.

Changes Are Checked

After changing a machine’s network, deploy compares the live state with what it planned: the interface zones, the firewall, the routing rules and the kernel settings. It stops before installing anything on the cluster if they differ. This check cannot be skipped. See the [ACD-VERIFY-...] codes in Installation Troubleshooting.

Boot-Time Network Tools

deploy stores its interface zones and routing rules on the machines’ NetworkManager connections. Tools that rewrite those connections at boot, such as cloud-init or a virtualization platform’s contextualization, can undo them after a reboot: an interface back in the wrong zone, or the priority-50 rules gone. Nothing checks for this between deploy runs.

Disable such regeneration of the network connections on the cluster machines. Where you cannot, run deploy again after a reboot: it puts the settings back, prints Host network changed on <machine>: ... naming what it changed, and carries on.

3.1 - Shared Interface Setup

One network interface for both cluster and client traffic

Overview

In a shared layout, each machine has a single network: the cluster’s own traffic and client traffic both use the interface holding the default route. This is the simplest layout, for machines with one network interface.

Security note: Cluster traffic shares the public network, so it is not isolated from clients. For production, a segregated backplane is recommended.

Configuration

Leave backplane_network empty in /etc/acd/deploy/group_vars/all.yaml. This is the default:

backplane_network: ""

Each machine’s cluster address is then its public address. The machines may be on one subnet or on routed subnets, as long as each can reach the others’ addresses.

What deploy Sets Up

On each machine, with manage_firewall: true:

  • The public interface is placed in firewalld’s public zone, with the ports clients need opened through the acd-public policy.
  • The other machines’ addresses, and the pod and service networks, are trusted in the acd-cluster zone, which accepts all traffic. Hosts other than the cluster machines get only the ports open in public.
  • Routing rules keep traffic to the pod and service networks, and to the machine’s own subnet, on the main routing table. If your machines are on different subnets and use policy routing, make sure traffic to the other machines’ subnets also uses the main table.

Verification

After deploy, on any machine:

firewall-cmd --get-active-zones
firewall-cmd --zone=acd-cluster --list-sources

The acd-cluster zone lists the pod and service networks (10.42.0.0/16 and 10.43.0.0/16 by default) and the other cluster machines’ addresses, and the public interface is in the public zone.

For the ports the cluster uses, and for managing the firewall yourself, see the Networking Guide.

3.2 - Configuring Segregated Networks

A private backplane for cluster traffic, separate from the public network

Overview

In a segregated layout, each machine has two networks:

  • A public network, holding the default route, which clients and CDN Directors use to reach the manager.
  • A backplane network, separate from the public network, which carries all traffic between the machines (east-west traffic): the Kubernetes API, the cluster datastore, the pod network and storage replication.

This keeps cluster traffic off the public network, and lets the firewall trust the backplane completely while the public interface opens only the ports clients need. A backplane is optional, but it is the recommended layout for production.

The backplane does not have to be reserved for the cluster machines. Other hosts may be on it too, such as your CDN Directors: it is simply the network the cluster machines use to reach each other.

flowchart LR
    clients[Users and CDN Directors] -->|public network| eth0
    subgraph machine[Each cluster machine]
        eth0[eth0: public zone]
        eth1[eth1: acd-cluster zone]
    end
    eth1 <-->|backplane network| peers[Other cluster machines]

Requirements

  • The backplane is a separate network from the public network.
  • Every machine has exactly one interface with an address in the backplane network, and every machine is reachable from the others on it.
  • The default route is on the public interface, not the backplane.
  • Only hosts you trust are on the backplane. With manage_firewall: true, every host on the backplane can reach every port on every cluster machine, including the cluster datastore (etcd, 2379-2380/tcp), the Kubernetes API (6443/tcp) and the kubelet (10250/tcp), so connect only hosts you trust to it.
  • Both interfaces are managed by NetworkManager, with an active connection.

Configuration

Give the backplane’s address range in /etc/acd/deploy/group_vars/all.yaml, before the first deploy:

backplane_network: "10.10.0.0/24"

Nothing else is needed: no per-machine settings and no firewall commands. deploy finds each machine’s interface in that range and sets up the rest.

Important: backplane_network is locked at the first installation. A cluster cannot be moved on to or off a backplane later without reinstalling it.

What deploy Sets Up

On each machine, with manage_firewall: true:

  • Kubernetes uses the backplane: each machine’s cluster address is its backplane address, the pod network (Flannel) runs over the backplane interface, and the public address is advertised as the machine’s external address.
  • The backplane interface is placed in the acd-cluster zone, which accepts all traffic, together with the pod and service networks.
  • The public interface is placed in firewalld’s public zone, with the ports clients need opened through the acd-public policy.
  • Routing rules keep traffic to the pod, service and backplane networks on the main routing table.

Other interfaces are left as they are.

Verification

After deploy, on any machine:

firewall-cmd --get-active-zones

Expected output:

acd-cluster
  interfaces: eth1
  sources: 10.42.0.0/16 10.43.0.0/16
public
  interfaces: eth0

Check the cluster addresses Kubernetes uses:

grep -E 'node-ip|external|flannel' /etc/rancher/k3s/config.yaml.d/20-acd-node.yaml

Expected output, on manager-1:

node-ip: "10.10.0.11"
node-external-ip: "192.0.2.11"
flannel-iface: "eth1"

And on a server:

kubectl get nodes -o wide

INTERNAL-IP shows each machine’s backplane address, and EXTERNAL-IP its public address.

Problems

CodeCauseFix
[ACD-BACKPLANE-NO-MATCH]No interface on a machine has an address in backplane_network.Fix backplane_network, or the machine’s addresses. Every machine needs an address in it.
[ACD-BACKPLANE-AMBIGUOUS]More than one interface on a machine matches backplane_network.Narrow backplane_network, or set node_ip to the right address on that machine’s line in the inventory.
[ACD-BACKPLANE-SPLIT]The machines’ cluster addresses are not all in one network.Put every machine’s backplane address inside backplane_network.
[ACD-JOIN-UNREACHABLE]A joining machine cannot reach the server over the backplane.Check the backplane’s switching and addressing, and that reverse path filtering does not drop the replies.

For every code, see Installation Troubleshooting.

4 - Architecture Guide

Detailed system architecture and component overview

Overview

The AgileTV CDN Manager (ESB3027) is a cloud-native Kubernetes application designed for managing CDN operations. This guide provides a detailed description of the system architecture, component interactions, and scaling considerations.

High-Level Architecture

The CDN Manager follows a microservices architecture deployed on Kubernetes. The system is organized into logical layers:

graph LR
    Clients[API Clients] --> Ingress[Ingress Controller]
    Ingress --> Manager[Core Manager]
    Ingress --> Frontend[MIB Frontend]
    Ingress --> Grafana[Grafana]
    Manager --> Redis[(Redis)]
    Manager --> Kafka[(Kafka)]
    Manager --> PostgreSQL[(PostgreSQL)]
    Manager --> Zitadel[Zitadel IAM]
    Manager --> Confd[Configuration Service]
    Grafana --> VM[(VictoriaMetrics)]
    Confd -.-> Gateway[NGinx Gateway]
    Gateway --> Director[CDN Director]

Component Architecture

Ingress Layer

The ingress layer manages all incoming traffic to the cluster:

ComponentRole
Ingress ControllerPrimary ingress for all cluster traffic; routes requests to internal services based on path
NGinx GatewayReverse proxy for routing traffic to external CDN Directors; used by MIB Frontend to communicate with remote Confd instances on CDN Director nodes

Traffic flow:

  • API clients and Operator UI connect via the Ingress Controller at /api and /gui paths respectively
  • Grafana dashboards are accessed via the Ingress Controller at /grafana
  • Zitadel authentication console is accessed via the Ingress Controller at /ui/console

Application Services

The application layer contains the core CDN Manager services:

ComponentRoleScaling
Core ManagerMain REST API server (v1/v2 endpoints); handles authentication, configuration, routing, and discoveryHorizontally scalable via HPA
MIB FrontendWeb-based configuration GUI for operatorsHorizontally scalable via HPA
ConfdConfiguration service for routing configuration; synchronizes with Core Manager applicationSingle instance
GrafanaMonitoring and visualization dashboardsSingle instance
Selection Input WorkerConsumes selection input events from Kafka and updates configurationSingle instance
Metrics AggregatorCollects and aggregates metrics from CDN componentsSingle instance
TelegrafSystem-level metrics collection from cluster nodesDaemonSet (one per node)
AlertmanagerAlert routing and notification managementSingle instance

Data Layer

The data layer provides persistent and ephemeral storage:

ComponentRoleScaling
RedisIn-memory caching, session storage, and ephemeral stateSingle instance
KafkaEvent streaming for selection input and subnet replication; provides durable message queueController cluster (odd count)
PostgreSQLPersistent configuration and state storage3-node cluster with HA
VictoriaMetrics (Analytics)Real-time and short-term metrics for operational dashboardsSingle instance
VictoriaMetrics (Billing)Long-term metrics retention (1+ years) for billing and license complianceSingle instance

External Integrations

ComponentRole
Zitadel IAMIdentity and access management; provides OAuth2/OIDC authentication
ESB3024 CDN DirectorEdge routing infrastructure; receives configuration from Confd

Detailed Component Descriptions

Core Manager

The Core Manager is the central application server that exposes the REST API. It is implemented in Rust using the Actix-web framework.

Key Responsibilities:

  • Authentication and session management via Zitadel
  • Configuration document storage and retrieval
  • Selection input CRUD operations
  • Routing rule evaluation and GeoIP lookups
  • Host and namespace discovery
  • Operator UI helper endpoints

API Endpoints:

  • /api/v1/auth/* - Authentication (login, token, logout)
  • /api/v1/configuration - Configuration management
  • /api/v1/selection_input/* - Selection input operations
  • /api/v2/selection_input/* - Enhanced selection input with list operations
  • /api/v1/routing/* - Routing evaluation and validation
  • /api/v1/discovery/* - Host and namespace discovery
  • /api/v1/metrics - System metrics
  • /api/v1/health/* - Liveness and readiness probes
  • /api/v1/operator_ui/* - Operator helper endpoints

Runtime Modes: The Core Manager supports multiple runtime modes, each deployed as a separate container:

  • http-server - Primary HTTP API server (default)
  • metrics-aggregator - Background worker for metrics collection
  • selection-input - Background worker for Kafka selection input consumption

MIB Frontend

The MIB Frontend provides a web-based GUI for configuration management.

Key Features:

  • Intuitive web interface for CDN configuration
  • Real-time configuration validation
  • Integration with Zitadel for SSO authentication

Confd (Configuration Service)

Confd provides routing configuration services and synchronizes with the Core Manager application.

Key Responsibilities:

  • Hosts the service configuration for routing decisions
  • Provides API and CLI for configuration management
  • Synchronizes routing configuration with Core Manager
  • Maintains configuration state in PostgreSQL

Selection Input Worker

The Selection Input Worker processes selection input events from the Kafka stream.

Key Responsibilities:

  • Consumes messages from the selection_input Kafka topic
  • Validates and transforms input data
  • Updates configuration in the data store
  • Maintains message ordering within partitions

Scaling Limitation: The Selection Input Worker cannot be scaled beyond a single consumer per Kafka partition, as message ordering must be preserved.

Metrics Aggregator

The Metrics Aggregator collects and processes metrics from CDN components.

Key Responsibilities:

  • Polls metrics from CDN Director instances
  • Aggregates usage statistics
  • Writes data to VictoriaMetrics (Analytics) for dashboards
  • Writes long-term data to VictoriaMetrics (Billing) for compliance

Telegraf

Telegraf is deployed as a DaemonSet to collect host-level metrics.

Key Responsibilities:

  • CPU, memory, disk, and network metrics from each node
  • Container-level resource usage
  • Kubernetes cluster metrics
  • Forwards metrics to VictoriaMetrics

Grafana

Grafana provides visualization and dashboard capabilities.

Features:

  • Pre-built dashboards for CDN monitoring
  • Custom dashboard support
  • VictoriaMetrics as data source
  • Alerting integration with Alertmanager

Access: https://<host>/grafana

Alertmanager

Alertmanager handles alert routing and notifications.

Key Responsibilities:

  • Receives alerts from Grafana and other sources
  • Deduplicates and groups alerts
  • Routes to notification channels (email, webhook, etc.)
  • Manages alert silencing and inhibition

Data Storage

Redis

Redis provides in-memory storage for:

  • User sessions and authentication tokens
  • Ephemeral configuration cache
  • Real-time state synchronization

Deployment: Single instance, with no replicas. Redis is unavailable while its pod restarts or moves to another node.

Kafka

Kafka provides durable event streaming for:

  • Selection input events
  • Metrics data streams
  • Inter-service communication

Deployment: Controller cluster with 3 replicas for production, 1 replica for lab deployments

Node Affinity: Kafka replicas must be scheduled on separate nodes to ensure high availability. The Helm chart configures topology spread constraints to enforce this distribution.

Topics:

  • selection_input - Selection input events. One replica, by design: see Data Replication.
  • subnets - Subnet changes. Three replicas.

Note: For lab/single-node deployments, the Kafka replica count must be set to 1 in the Helm values. Production deployments require 3 replicas for fault tolerance.

PostgreSQL

PostgreSQL provides persistent storage for:

  • Configuration documents
  • User and permission data
  • System state
  • The subnet map

The cluster holds four databases: manager (the manager’s own data, including the subnet map), app (Grafana), zitadel and confsej.

Deployment: 3-node cluster managed by Cloudnative PG (CNPG) operator

High Availability: The CNPG operator manages automatic failover and ensures high availability:

  • One primary node handles read/write operations
  • Two replica nodes provide redundancy and can be promoted to primary on failure
  • Automatic failover occurs within seconds of primary node failure
  • Synchronous replication ensures data consistency

Note: The PostgreSQL cluster is deployed and managed automatically by the CNPG operator. Manual intervention is typically not required for normal operations.

VictoriaMetrics

Two VictoriaMetrics instances serve different purposes:

VictoriaMetrics (Analytics):

  • Real-time and short-term metrics storage
  • Supports Grafana dashboards
  • Retention: Configurable (typically 30-90 days)

VictoriaMetrics (Billing):

  • Long-term metrics retention
  • Billing and license compliance data
  • Retention: Minimum 1 year

Authentication and Authorization

Zitadel Integration

Zitadel provides identity and access management:

Authentication Flow:

  1. User accesses MIB Frontend or API
  2. Redirected to Zitadel for authentication
  3. Zitadel validates credentials and issues session token
  4. Session token exchanged for access token
  5. Access token included in API requests (Bearer authentication)

Default Credentials: See the Glossary for default login credentials.

Access Paths:

  • Zitadel Console: /ui/console
  • API authentication: /api/v1/auth/*

External Hostname

Zitadel validates the URL that clients connect to against its configured external hostname. deploy sets it from the first entry of manager_hostnames in the site settings, which must be the hostname clients use to reach the system.

Network Architecture

Traffic Flow

graph TB
    External[External Clients] --> Ingress[Ingress Controller]
    External --> Redis[(Redis)]
    External --> Kafka[(Kafka)]
    External --> Telegraf[Telegraf]
    Ingress --> Manager[Core Manager]
    Ingress --> Frontend[MIB Frontend]
    Ingress --> Grafana[Grafana]
    Ingress --> Zitadel[Zitadel]

Note: Certain services (Redis, Kafka, Telegraf) can be accessed directly by external clients without traversing the ingress controller. This is typically used for metrics collection, event streaming, and direct data access scenarios.

Internal Communication

All internal services communicate over the Kubernetes overlay network (Flannel VXLAN). Services discover each other via Kubernetes DNS.

External Communication

  • CDN Directors: Accessed via NGinx Gateway for simplified routing.
  • MaxMind GeoIP: Local database files (no external calls)

Scaling

Horizontal Pod Autoscaler (HPA)

The following components support automatic horizontal scaling via HPA:

ComponentMinimumMaximumScale Metrics
Core Manager38CPU (50%), Memory (80%)
NGinx Gateway24CPU (75%), Memory (80%)
MIB Frontend24CPU (75%), Memory (90%)

Note: HPA is enabled by default in the Helm chart. The default configuration is tuned for production deployments. Adjust min/max values based on expected load and available cluster capacity.

Manual Scaling

Components can also be scaled manually by setting replica counts in the Helm values:

cdn-manager:
  manager:
    replicaCount: 3
  mibFrontend:
    replicaCount: 2

Important: When manually setting replica counts, you must disable the Horizontal Pod Autoscaler (HPA) for the corresponding component. If HPA remains enabled, it will override manual replica settings. To disable HPA, set autoscaling.hpa.enabled: false for the component in your Helm values.

Components That Do Not Scale

The following components do not support horizontal scaling:

ComponentReason
ConfdSingle instance required for configuration consistency
PostgreSQLCloudnative PG cluster; scaled by adding replicas via operator configuration
KafkaScaled by adding controllers, not via replica count
VictoriaMetricsStateful; single instance per role
RedisSingle instance
GrafanaSingle instance sufficient for dashboard access
AlertmanagerSingle instance for alert routing
Selection Input WorkerKafka message ordering requires single consumer
Metrics AggregatorSingle instance for consistent metrics aggregation

Node Scaling

Additional Agent nodes can be added to the cluster at any time to increase workload capacity. Kubernetes automatically schedules pods to nodes with available resources.

Cluster Balancing

The CDN Manager deployment includes the Kubernetes Descheduler to maintain balanced resource utilization across cluster nodes:

  • Automatic Rebalancing: The descheduler periodically analyzes pod distribution and evicts pods from overutilized nodes
  • Node Balance: Helps prevent resource hotspots by redistributing workloads across available nodes
  • Integration with HPA: Works in conjunction with Horizontal Pod Autoscaler to optimize both pod count and placement

The descheduler runs as a background process and does not require manual intervention under normal operating conditions.

Resource Configuration

For detailed resource preset configurations and planning guidance, see the Configuration Guide.

High Availability

Server Node Redundancy

Production deployments require a minimum of 3 Server nodes:

  • Survives loss of 1 server node
  • Maintains quorum for etcd and Kafka

For enhanced availability, use 5 Server nodes:

  • Survives loss of 2 server nodes
  • Recommended for critical production environments

For large-scale deployments, 7 or more Server nodes can be used:

  • Survives loss of 3+ server nodes
  • Suitable for high-capacity production environments

Pod Distribution

Kubernetes automatically distributes pods across nodes to maximize availability:

  • Pods with the same deployment are scheduled on different nodes when possible
  • Pod Disruption Budgets (PDB) ensure minimum availability during maintenance

Data Replication

ComponentReplication Strategy
RedisSingle instance
KafkaThree replicas per topic, except selection_input, which has one
PostgreSQL3-node cluster via Cloudnative PG; covers every database, including the subnet map in manager
VictoriaMetricsSingle instance
LonghornThree replicas per volume, except the Kafka data volumes, which have one

Longhorn Storage: Each volume keeps three replicas on different nodes, so it survives the loss of a node. The three Kafka data volumes keep one replica each, because Kafka copies its topics across its brokers itself. Longhorn tries to keep one replica on the node where the volume’s pod runs, but a pod can run on a node with no replica of its volume.

selection_input has one replica by design. The topic is high-volume and short-lived, so it keeps a single replica, on one broker’s single-replica volume. The cost: selection input is unavailable while that broker restarts or moves to another node, and its messages are lost if that broker’s volume is lost. Every other topic survives the loss of one broker. See Safety before you restart Kafka or drain a node.

Next Steps

After understanding the architecture:

  1. Installation Guide - Deploy the CDN Manager
  2. Configuration Guide - Configure components for your environment
  3. Operations Guide - Day-to-day operational procedures
  4. Performance Tuning Guide - Optimize system performance
  5. Metrics & Monitoring - Set up monitoring and alerting

5 - Installation Guide

Installing the CDN Manager with the deploy command

Overview

The CDN Manager is installed with deploy, a command on the installation ISO. You describe your machines and your site in a few files on the first machine, and deploy does the rest over SSH: it configures each machine’s firewall and network, builds the Kubernetes (k3s) cluster, installs the CDN Manager, and checks that it is healthy.

deploy is the only supported way to install the CDN Manager. Running the ISO’s install, join-server and join-agent scripts by hand is no longer supported.

New here? Start with the Quickstart. It walks you through a complete installation on three machines, from minimal operating system installs to signing in to the GUI. The other pages cover the details and the variations.

Upgrading from 1.6 or earlier? Version 1.8 cannot be installed over an earlier release. Install it on a new cluster. See the Upgrade Guide.

How It Works

flowchart LR
    A[Mount the ISO<br>on the first server] --> B[deploy init]
    B --> C[Edit /etc/acd/deploy]
    C --> D[deploy]
    D --> E[Sign in to the GUI]
  1. Mount the ISO on the machine that will be the first server.
  2. Run deploy init once. It installs Ansible and creates the configuration folder /etc/acd/deploy.
  3. Edit the configuration: the machines and their roles, your site settings, and optionally a TLS certificate, MaxMind databases and application settings.
  4. Run deploy. It checks everything first, takes a backup, then installs or updates the cluster to match your configuration.

To change anything later, edit the configuration and run deploy again. Every run is safe to repeat.

Guides

GuideWhat it covers
QuickstartA complete installation, start to finish
InventoryListing your machines and their roles, and per-machine options
Site SettingsEvery setting in group_vars/all.yaml
TLS CertificatesServing HTTPS with your own certificate
MaxMind GeoIP DatabasesEnabling geographic routing
Application SettingsPresets and values.override.yaml
Running deployWhat a run does, checking and restoring backups, and what to send support
Changing the ClusterAdding machines and changing a machine’s role
Air-Gapped InstallationInstalling without internet access
Upgrade GuideMoving to a new release
Installation TroubleshootingWhat each failure code means and what to do
Next StepsSecuring the administrator account and adding users

Prerequisites

  • Machines meeting the System Requirements: three servers for production, optionally with agents for extra capacity. A single server is enough for a lab.
  • Operating system: a minimal install of Red Hat Enterprise Linux 9.4 or later, or a compatible rebuild, with firewalld and NetworkManager running.
  • Network: a default route on every machine and, optional but recommended, a backplane network, separate from the public network, that all the machines are reachable on. See the Networking Guide.
  • Access: passwordless root SSH from the first server to every machine, itself included.
  • Software: the installation ISO from the AgileTV customer portal, and dnf access to the operating system’s repositories on every machine.
  • Recommended: a TLS certificate from a trusted certificate authority, and MaxMind GeoIP databases from your own MaxMind subscription.

Getting Help

  • Installation Troubleshooting lists every failure code deploy reports.
  • If the final health check fails, deploy writes /etc/acd/deploy/support-bundle-acd.tar.gz. Send it to AgileTV support.
  • The Release Notes list known issues for each version.

5.1 - Quickstart

From three minimal Enterprise Linux 9 machines to a working CDN Manager cluster

Overview

This guide takes three freshly installed Enterprise Linux 9 machines and turns them into a production CDN Manager cluster, ending with you signed in to the web GUI. Follow it from top to bottom. Each step shows the commands to run and the output to expect, and links to a page with the details and the alternatives.

The whole installation is driven by one command, deploy, which ships on the installation ISO. You run it on the first machine, and it configures every machine over SSH: the host firewall and network, the Kubernetes cluster, and the CDN Manager itself. You do not run anything by hand on the other machines.

Estimated time: about 30 minutes, of which deploy itself takes 15 to 20.

The example cluster

The commands below use this example. Replace the names and addresses with your own as you go.

MachineRolePublic address (eth0)Backplane address (eth1)
manager-1server192.0.2.1110.10.0.11
manager-2server192.0.2.1210.10.0.12
manager-3server192.0.2.1310.10.0.13
  • Manager hostname: manager.example.com, the name users and CDN Directors use to reach the manager. Its DNS record points at the public address of one or more of the machines.
  • Backplane network: 10.10.0.0/24, the network the machines use to talk to each other. A backplane is optional, but recommended. When you use one, all node-to-node (east-west) traffic stays on it, and every machine must be reachable on it. It should be a separate network from the public one. It does not have to be reserved for the cluster, so other hosts may also be on it.
  • CDN Director: director-1 at 192.0.2.201. It runs on its own machine, outside this cluster.

Single network? A dedicated backplane is recommended, but not required. If each machine has only one network interface, follow the guide as written and leave backplane_network empty in Step 6. See the Networking Guide for both layouts.

Before You Begin

Make sure you have:

  • Three machines that meet the System Requirements, each with a minimal install of Red Hat Enterprise Linux 9.4 or later, or a compatible rebuild such as Oracle Linux, AlmaLinux or Rocky Linux. Keep the defaults: firewalld and NetworkManager running, and any SELinux mode.
  • Final hostnames on every machine, each one unique. A machine cannot be renamed after it joins the cluster.
  • A default route on every machine, on the public interface. It does not need to lead to the internet: on an isolated network, use your site’s router or any gateway address on that subnet.
  • Root access to every machine, by password or key, to set up SSH in Step 1.
  • Package repositories: every machine must be able to install packages with dnf from the operating system’s repositories. deploy installs a few packages, such as ansible-core on the first machine and the storage tools on all of them. Without internet access, see Air-Gapped Installation.
  • A DNS name for the manager, such as manager.example.com.
  • The installation ISO, esb3027-cdn-manager-1.8.0.iso, from the AgileTV customer portal.
  • A TLS certificate for the manager hostname, issued by a certificate authority your users trust, with its private key. You can start without one; see Step 7.
  • MaxMind GeoIP databases from your own MaxMind subscription: City, ASN and Anonymous IP. You can start without them; see Step 8.

Run every command in this guide as root on manager-1, unless a step says otherwise.

Step 1: Set Up SSH Keys

deploy connects from manager-1 to every machine as root, manager-1 itself included, and needs to log in without a password prompt.

Create a key on manager-1:

ssh-keygen -t ed25519 -N '' -C "acd-deploy@$(hostname)" -f /root/.ssh/id_ed25519

Expected output:

Generating public/private ed25519 key pair.
Your identification has been saved in /root/.ssh/id_ed25519
Your public key has been saved in /root/.ssh/id_ed25519.pub
The key fingerprint is:
SHA256:igFCBNWbW74iWVSPZdMrGhtpKXiPQedvUZEGPmzppsI acd-deploy@manager-1
...

Copy it to all three machines. Enter each machine’s root password when prompted:

for ip in 192.0.2.11 192.0.2.12 192.0.2.13; do
    ssh-copy-id -o StrictHostKeyChecking=accept-new -i /root/.ssh/id_ed25519.pub root@$ip
done

Expected output, once per machine:

/usr/bin/ssh-copy-id: INFO: Source of key(s) to be installed: "/root/.ssh/id_ed25519.pub"
/usr/bin/ssh-copy-id: INFO: attempting to log in with the new key(s), to filter out any that are already installed
/usr/bin/ssh-copy-id: INFO: 1 key(s) remain to be installed -- if you are prompted now it is to install the new keys
root@192.0.2.11's password:

Number of key(s) added: 1

Check that each login now works without a prompt:

for ip in 192.0.2.11 192.0.2.12 192.0.2.13; do
    ssh -o BatchMode=yes root@$ip hostname
done

Expected output:

manager-1
manager-2
manager-3

If a machine asks for a password or prints Permission denied, fix that before going on. Otherwise deploy stops with UNREACHABLE.

Step 2: Check the Machines

Confirm that each machine runs a supported release, has its addresses and default route, and runs firewalld and NetworkManager:

for ip in 192.0.2.11 192.0.2.12 192.0.2.13; do
    echo "===== $ip"
    ssh root@$ip 'cat /etc/redhat-release; ip -br -4 addr; ip route show default; systemctl is-active firewalld NetworkManager'
done

Expected output, for each machine:

===== 192.0.2.11
Red Hat Enterprise Linux release 9.4 (Plow)
lo               UNKNOWN        127.0.0.1/8
eth0             UP             192.0.2.11/24
eth1             UP             10.10.0.11/24
default via 192.0.2.1 dev eth0 proto static
active
active

Check for:

  • A release of 9.4 or later.
  • The default route on the public interface (eth0).
  • The backplane address on another interface (eth1), inside the backplane network.
  • active twice: firewalld, then NetworkManager.

deploy checks all of this again before it changes anything, along with processor cores, memory and kernel modules, and stops with a message naming the problem.

Step 3: Mount the Installation ISO

Copy the ISO to manager-1, for example with scp from your workstation:

scp esb3027-cdn-manager-1.8.0.iso root@192.0.2.11:/root/

Then, on manager-1, mount it at /mnt/esb3027:

mkdir -p /mnt/esb3027
mount -o loop,ro /root/esb3027-cdn-manager-1.8.0.iso /mnt/esb3027
ls -1 /mnt/esb3027

Expected output:

ansible
BUILD_ARCH
charts
common.sh
deploy
install
join-agent
join-server
k3s_installer.sh
load-maxmind-databases
openapi.yaml
Packages
rootfs.tar
values-lab.yaml
values-production.yaml
values.yaml
VERSION.txt

The ISO is needed only on manager-1. deploy copies what the other machines need.

Important: Keep the ISO mounted at /mnt/esb3027 whenever you run deploy, now and later. The deployment’s configuration reads its application settings from the ISO, and deploy stops if the ISO is not mounted. The mount does not survive a reboot, so mount it again before the next run.

Step 4: Initialize the Deployment

Initialize manager-1 with the production preset:

/mnt/esb3027/deploy init prod

This installs ansible-core from the operating system’s repositories, installs the Ansible collections bundled on the ISO, and creates the configuration folder /etc/acd/deploy. Expected output (the package list is shortened here):

###############################################################
            ESB3027 AgileTV CDN Manager -- Deployer
 Version: 1.8.0
###############################################################

Installing ansible-core from the OS repositories ...
Dependencies resolved.
================================================================================
 Package               Arch    Version                 Repository          Size
================================================================================
Installing:
 ansible-core          x86_64  1:2.14.18-3.el9         appstream          3.9 M
...
Complete!
Installing bundled Ansible collections ...

Initialization complete, using the production preset. Next:
  1. Edit /etc/acd/deploy/inventory.ini        - your node addresses and roles.
  2. Edit /etc/acd/deploy/group_vars/all.yaml  - at minimum, manager_hostnames.
  3. (Optional) drop a TLS certificate + key into /etc/acd/deploy/certs/
     and the three MaxMind .mmdb files into /etc/acd/deploy/maxmind/.
  4. (Optional) change application settings in /etc/acd/deploy/values.override.yaml.
  5. Run: /mnt/esb3027/deploy

See /etc/acd/deploy/README.md for the full input contract.

WARNING: deploy connects over SSH from this node to every node in
inventory.ini, this node included, as root unless ansible_user says
otherwise. Make sure SSH keys are set up so each of those logins works
without a password prompt before running deploy.

The next four steps follow that list. Everything you configure lives in /etc/acd/deploy:

/etc/acd/deploy/
├── inventory.ini          # Step 5: your machines and their roles
├── group_vars/all.yaml    # Step 6: your site settings
├── certs/                 # Step 7: your TLS certificate and key
├── maxmind/               # Step 8: your MaxMind databases
├── values.override.yaml   # optional application settings, empty to start
├── preset.yaml            # link to the production preset on the ISO; do not edit
└── README.md              # the full reference for this folder

certs/ and maxmind/ each start with a README.txt: short instructions for that folder, which deploy ignores. Leave it there or delete it.

For other options, such as the lab preset, see the deploy reference.

Step 5: List Your Machines

Open /etc/acd/deploy/inventory.ini in an editor. It is a commented example with three sections. Edit the machine lines so that your three machines are listed under [server], with the address deploy reaches each one on over SSH, and the other two sections are empty:

[server]
manager-1 ansible_host=192.0.2.11
manager-2 ansible_host=192.0.2.12
manager-3 ansible_host=192.0.2.13

[agent]

[director]
  • [server]: the first server listed must be the machine you run deploy on, here manager-1. The number of servers must be odd: one for a lab, three for production.
  • [agent]: lists any additional machines beyond the three servers. It may be left empty, as here.
  • [director]: leave it empty. CDN Directors run on their own machines and are listed in the next step.

To add agent machines later, or to set per-machine options, see Inventory.

Step 6: Enter Your Site Settings

Open /etc/acd/deploy/group_vars/all.yaml. Every setting in it is explained in its comments and already holds a working default. For this cluster, change three lines:

manager_hostnames: [manager.example.com]
external_directors: [{name: director-1, address: 192.0.2.201}]
backplane_network: "10.10.0.0/24"
SettingWhat to enter
manager_hostnamesEvery hostname users and CDN Directors use to reach the manager. The first one is the main hostname: it must match your TLS certificate, and it is the only name you can sign in under.
external_directorsEach CDN Director, with a name of your choosing and its IPv4 address (not its hostname). Leave the list empty, [], if you have none yet.
backplane_networkThe address range of the backplane network, which should be separate from the public network. Leave it empty, "", if you have no backplane.

To review your changes, print the settings without their comments:

grep -Ev '^\s*(#|$)' /etc/acd/deploy/group_vars/all.yaml

Expected output:

---
manager_hostnames: [manager.example.com]
manage_firewall: true
manage_routes: true
external_directors: [{name: director-1, address: 192.0.2.201}]
backplane_network: "10.10.0.0/24"
cluster_pod_cidr: 10.42.0.0/16
cluster_svc_cidr: 10.43.0.0/16
firewall_cluster_zone: acd-cluster
firewall_public_policy: acd-public
tls_cert_file: ""
tls_key_file: ""
maxmind_city_db: ""
maxmind_asn_db: ""
maxmind_anon_db: ""
backup_dir: /var/lib/acd/backups

Important: backplane_network, cluster_pod_cidr, cluster_svc_cidr, firewall_cluster_zone and firewall_public_policy are locked at the first installation. Changing one later means reinstalling the cluster, which starts empty: the PostgreSQL databases, Kafka’s messages and everything else stored in the cluster are lost unless you have backed them up. Check them now.

With manage_firewall: true, the default, deploy configures firewalld on every machine for you. The backplane interface is trusted for all traffic, and the public interface stays in firewalld’s public zone with only the ports the manager needs opened. Before you run deploy, make sure the public zone allows everything else you need on the public interface, SSH included: firewall-cmd --zone=public --list-all on each machine should list ssh under services.

For every setting, see Site Settings.

Step 7: Add Your TLS Certificate

Copy your certificate and its private key into /etc/acd/deploy/certs/:

cp manager-fullchain.crt manager.key /etc/acd/deploy/certs/
chmod 600 /etc/acd/deploy/certs/manager.key
  • Both files must be PEM (text beginning with -----BEGIN), in two separate files. The file names do not matter.
  • Use the full chain: your certificate followed by any intermediate certificates.
  • The key must not have a passphrase.
  • The certificate must cover the first entry of manager_hostnames, directly or by a wildcard.

Check what the certificate covers:

openssl x509 -in /etc/acd/deploy/certs/manager-fullchain.crt -noout -subject -issuer -dates -ext subjectAltName

Expected output:

subject=CN = manager.example.com
issuer=CN = Example Issuing CA
notBefore=Oct 14 18:57:23 2026 GMT
notAfter=Jan 16 18:57:23 2027 GMT
X509v3 Subject Alternative Name:
    DNS:manager.example.com

deploy checks the certificate and key before it changes anything.

No certificate yet? Add nothing to the folder: its README.txt can stay. deploy then serves a temporary self-signed certificate, which browsers warn about. Add the real one later and run deploy again to replace it. For production, use a certificate from a certificate authority your users already trust. See TLS Certificates.

Step 8: Add Your MaxMind Databases

The manager uses MaxMind GeoIP databases for geographic routing. Copy the three .mmdb files from your MaxMind subscription into /etc/acd/deploy/maxmind/:

cp GeoIP2-City.mmdb GeoLite2-ASN.mmdb GeoIP2-Anonymous-IP.mmdb /etc/acd/deploy/maxmind/
ls -l /etc/acd/deploy/maxmind/

Expected output:

total 126236
-rw-r--r--. 1 root root      4382 Oct 14 18:57 GeoIP2-Anonymous-IP.mmdb
-rw-r--r--. 1 root root 121642973 Oct 14 18:57 GeoIP2-City.mmdb
-rw-r--r--. 1 root root   7609502 Oct 14 18:57 GeoLite2-ASN.mmdb
-rw-r--r--. 1 root root       400 Oct 14 18:57 README.txt

Files are matched by name: one containing City, one ASN and one Anon. Provide all three or none. With none, geographic routing is off until you add them and run deploy again. See MaxMind GeoIP Databases.

Step 9: Run the Deployment

Start the installation:

/mnt/esb3027/deploy

deploy works through fixed stages, and stops at the first problem with a message naming it:

  1. Gather inputs: reads every machine and works out the settings. Changes nothing.
  2. Preflight: checks the machines and your settings. Changes nothing.
  3. Backup: archives each machine’s configuration before any change.
  4. Host network: configures the firewall zones, routing rules and kernel network settings on each machine, then checks the result.
  5. Cluster: installs Kubernetes (k3s) on manager-1, then joins the other servers one at a time.
  6. Platform: installs the CDN Manager, loads the MaxMind databases, and runs a final health check.

The output is long. Shortened, it looks like this:

###############################################################
            ESB3027 AgileTV CDN Manager -- Deployer
 Version: 1.8.0
###############################################################

Running the operator playbook (log: /etc/acd/deploy/deploy.log) ...
[WARNING]: Collection ansible.posix does not support Ansible version 2.14.18

PLAY [Gather inputs - host probes, host network plans and k3s] *****************

TASK [Check that the node can be reached] **************************************
ok: [manager-1]
ok: [manager-3]
ok: [manager-2]
...
PLAY [Join or upgrade the other servers, one at a time] ************************
...
TASK [cluster : Join or upgrade this node with join-server] ********************
changed: [manager-2]
FAILED - RETRYING: [manager-2]: Wait for this server to become Ready (20 retries left).
FAILED - RETRYING: [manager-2]: Wait for this server to become Ready (19 retries left).
...
TASK [cluster : Wait for this server to become Ready] **************************
ok: [manager-2]
...
TASK [acceptance : Report the go verdict] **************************************
ok: [manager-1] => {
    "msg": "Deployment gate PASSED for cdn-platform. GeoIP is serving."
}
...
PLAY RECAP *********************************************************************
manager-1                  : ok=185  changed=28   unreachable=0    failed=0    skipped=106  rescued=0    ignored=0
manager-2                  : ok=116  changed=16   unreachable=0    failed=0    skipped=48   rescued=0    ignored=0
manager-3                  : ok=116  changed=16   unreachable=0    failed=0    skipped=48   rescued=0    ignored=0

The installation succeeded when you see Deployment gate PASSED and the recap shows unreachable=0 and failed=0 for every machine. Your other counts may differ.

Some lines look alarming but are expected:

  • FAILED - RETRYING: ... Wait for this server to become Ready: deploy polls each joining server until it is ready. Ten or more of these per server is normal.
  • [WARNING]: Collection ansible.posix does not support Ansible version ...: harmless.
  • [WARNING]: Could not match supplied host pattern, ignoring: acd_host_network_verify: harmless.

The whole run is also appended to /etc/acd/deploy/deploy.log.

If deploy stops, its message starts with a code in square brackets, such as [ACD-TLS-HOSTNAME]. Look the code up in Installation Troubleshooting, fix the cause, and run deploy again. Running it again is safe: it picks up where the cluster is and re-applies your settings.

Step 10: Verify the Cluster

Check that all three machines are Ready:

kubectl get nodes -o wide

Expected output (the columns right of EXTERNAL-IP are trimmed here):

NAME        STATUS   ROLES                AGE     VERSION        INTERNAL-IP   EXTERNAL-IP
manager-1   Ready    control-plane,etcd   12m     v1.35.0+k3s1   10.10.0.11    192.0.2.11
manager-2   Ready    control-plane,etcd   8m47s   v1.35.0+k3s1   10.10.0.12    192.0.2.12
manager-3   Ready    control-plane,etcd   6m3s    v1.35.0+k3s1   10.10.0.13    192.0.2.13

INTERNAL-IP is each machine’s backplane address, and EXTERNAL-IP its public address.

Check every component of the CDN Manager:

acd-admin status

Every admin target should show ok under HEALTH, and every node yes under READY. acd-admin status exits 0 either way, so read the output. See acd-admin status for what each column means.

Check that the manager answers on its hostname:

curl https://manager.example.com/api/v1/health/ready; echo

Expected output:

{"status":"Ok"}

Note: If your certificate was issued by a private certificate authority that this machine does not trust, add --cacert <your CA certificate> to the curl commands. If you started without a certificate, use -k instead.

If you listed a CDN Director in external_directors, check that the manager reaches it. The trailing slash is required:

curl -s -o /dev/null -w '%{http_code}\n' https://manager.example.com/confd/director-1/config/__active/

Expected output:

200

200 shows that the manager reached the Director and read its configuration. A 5xx status comes from the manager’s gateway when it cannot reach the Director: check the Director’s address in external_directors, that the Director is running, and that the network between them allows the connection. Connecting the Director to the manager is covered in the CDN Director documentation.

Important: Your CDN Directors reach Kafka on ports 9092/tcp and 30000-30003/tcp on every machine (one more port per broker if you run more than the default three). The host firewall does not filter these ports, so they are open to any network that can reach the machines. Restrict them to your CDN Directors on your network’s own firewall. See the Networking Guide.

Step 11: Back Up the Zitadel Master Key

Zitadel, the manager’s identity service, encrypts its stored credentials with a master key. If the key is lost, it cannot be recovered. Print it:

kubectl get secret zitadel-masterkey --namespace default -o jsonpath="{.data.masterkey}" | base64 --decode; echo

Store the output somewhere safe, away from the cluster.

Step 12: Sign In to the GUI

In a browser, open https://manager.example.com/gui/:

The CDN Manager GUI sign-in page

Sign in with the default administrator account:

  • Username: admin@zitadel.manager.example.com, that is, admin@zitadel. followed by your main manager hostname.
  • Password: Password1!

Note: Use the manager hostname you configured, not a machine’s IP address or another name: signing in only works under the main manager hostname. The GUI also needs the full login name above; the email address or admin alone is rejected.

After signing in, the GUI home page appears:

The CDN Manager GUI home page

Your CDN Manager cluster is installed.

What’s Next

  • Secure the administrator account. The default credentials are published. Create your own administrator, then disable the default account: see Next Steps.
  • Add capacity with agent machines: see Inventory.
  • Change application settings, such as retention or disk sizes: see Application Settings.
  • Change your configuration later: edit the files in /etc/acd/deploy and run deploy again. That is the only way to change what is installed. See Running deploy.

5.2 - Inventory

Listing the machines in the cluster and the role each one plays

Overview

The inventory, /etc/acd/deploy/inventory.ini, lists every machine in the cluster and its role. deploy init creates it as a commented example. Edit it before the first deploy, and again whenever you add a machine.

Each machine is one line under the heading for its role:

[server]
manager-1 ansible_host=192.0.2.11
manager-2 ansible_host=192.0.2.12
manager-3 ansible_host=192.0.2.13

[agent]
manager-4 ansible_host=192.0.2.14

[director]
  • The first word is the machine’s name. Use its hostname.
  • ansible_host is the address deploy reaches the machine on over SSH. It does not decide which network the cluster uses: see Site Settings for that.
  • Everything after a # is a comment, and indentation does not matter.

Important: The inventory also has a [director] section. Leave it empty. Installing the CDN Director in Kubernetes is not supported in this release. CDN Directors run on their own machines and are listed in external_directors in Site Settings.

Roles

Servers

Servers run the Kubernetes control plane and the cluster datastore, and by default also run the CDN Manager’s workloads.

  • The first server listed is the one you run deploy on. It holds the ISO and the configuration folder.
  • The number of servers must be odd, so the control plane can always reach a majority. Use three for production: the cluster then survives losing any one machine. One server is enough for a lab, with no redundancy.
  • deploy stops with [ACD-INVENTORY-EVEN-SERVERS] on an even number, and with [ACD-INVENTORY-NO-SERVER] if there is none.

Agents

Agents run workloads only: they add capacity, but take no part in the control plane. List any machines beyond the servers under [agent], or leave the section empty.

To add an agent to a running cluster, add its line under [agent], set up passwordless SSH to it from the first server, and run deploy again. See Changing the Cluster.

A machine cannot be listed under both [server] and [agent] ([ACD-INVENTORY-SERVER-AND-AGENT]). Changing a machine’s role on a running cluster takes a few extra steps: see Changing a Machine’s Role.

How Many of Each

  • Three servers are enough for most clusters. The cluster then survives losing any one server. You are unlikely to need more than five: five servers survive losing two, and more add little except overhead.
  • Add servers to improve redundancy, two at a time, since the count must stay odd.
  • Add agents to expand capacity. Agents take on workloads without adding to the control plane, so they are the way to grow a cluster.

Per-Machine Options

Each option is added to the end of a machine’s line, separated by a space. None is needed for a normal installation.

node_ip and node_external_ip

The machine’s cluster address (the one other machines reach it on) and its public address (the one clients use). Both are detected automatically:

  • The public address is the address of the interface holding the default route.
  • The cluster address is the machine’s address inside backplane_network if that is set, and otherwise its public address.

Set them only to correct one machine where the wrong address is chosen:

manager-2 ansible_host=192.0.2.12 node_ip=10.10.0.12

Every machine’s cluster address must be on one network that all of them share. deploy stops with [ACD-BACKPLANE-SPLIT] before changing anything if they are not.

Important: These addresses are locked at the first installation. Changing one later means reinstalling the cluster. See Locked at First Install.

node_labels

Your own Kubernetes labels for the machine, as a name-and-value mapping in single quotes:

manager-1 ansible_host=192.0.2.11 node_labels='{"rack": "a1"}'

For example, zone labels for Topology Aware Hints are described in the Performance Tuning Guide.

node_taints

Keeps ordinary workloads off the machine, for a server dedicated to the control plane. The platform’s own cluster services tolerate the taint below and keep running there. The value is a list in single quotes:

manager-1 ansible_host=192.0.2.11 node_taints='["CriticalAddonsOnly=true:NoSchedule"]'

Use it only when there are enough other machines to run the workloads, for example three tainted servers and at least three agents. Never taint every machine.

ansible_user

The account deploy logs in as on this machine. The default is root. Another account needs passwordless sudo:

manager-4 ansible_host=192.0.2.14 ansible_user=operator

To change the account for every machine at once, add it under an [all:vars] heading at the end of the file:

[all:vars]
ansible_user=operator

A setting on a machine’s own line still wins for that machine.

SSH Access

deploy logs in to every machine in the inventory from the first server, the first server included, without a password prompt. For each machine, on the first server:

ssh-copy-id root@<machine address>
ssh -o BatchMode=yes root@<machine address> true

The second command must succeed without a prompt. Otherwise deploy stops with UNREACHABLE for that machine.

5.3 - Site Settings

The settings in group_vars/all.yaml

Overview

Your site settings live in /etc/acd/deploy/group_vars/all.yaml. They are the only deployment settings you set: everything that depends on them, such as the ingress hostnames, the TLS secret, Zitadel’s external domain and the list of CDN Directors, is worked out from them, so related values cannot fall out of step.

Every setting appears once in the file, with comments, and already holds a working default. Change a setting by editing its value in place, then run deploy.

A list can be written on one line or one entry per line. Both mean the same:

manager_hostnames: [manager.example.com, cdn.example.com]

manager_hostnames:
  - manager.example.com
  - cdn.example.com

Settings

manager_hostnames

Required. Every hostname that users and CDN Directors use to reach the manager.

manager_hostnames: [manager.example.com]
  • The first entry is the main hostname. Your TLS certificate must cover it, and it is the only name users can sign in under: Zitadel rejects sign-ins made under any other name, including a machine’s IP address.
  • Each name needs a DNS record pointing at the public address of one or more of the machines.
  • Leave the list empty, [], only for a quick lab try-out. A temporary hostname and a self-signed certificate are then used.

external_directors

The CDN Directors that use this manager. They run on their own machines, outside the cluster: deploy installs nothing on them, and the manager routes to them.

external_directors:
  - {name: director-1, address: 192.0.2.201}
  - {name: director-2, address: 192.0.2.202}
  • name is your own name for the Director. The manager publishes its configuration under it, for example at https://<manager hostname>/confd/director-1/config/__active/.
  • address must be the Director’s IPv4 address, not a hostname. deploy stops with [ACD-DIRECTOR-ADDRESS] otherwise.
  • Leave the list empty, [], if you have none yet, then add your Directors later and run deploy again. Until you do, the preset’s placeholder Directors stay in the configuration: director-1 and director-2 at 192.0.2.1 and 192.0.2.2 with the production preset, router-1 at 192.0.2.1 with the lab preset. Those addresses are reserved for documentation and reach nothing.

The Directors reach Kafka on 9092/tcp and 30000-30003/tcp on every machine, with one more port per broker beyond the default three. The host firewall does not filter these ports: restrict them on your network’s own firewall. See Kafka Ports.

Important: Each CDN Director must also be able to reach the manager’s ingress (HTTPS on 443/tcp) under one of the names in manager_hostnames. The ingress answers on every address of every cluster machine, public and backplane alike. So Directors on the backplane network can reach the manager there, instead of over the public network. In that case:

  • Give the Directors a hostname that resolves, for them, to the machines’ backplane addresses, and add it to manager_hostnames, after the main hostname.
  • Make sure your TLS certificate covers that name too, as a Subject Alternative Name (SAN). Otherwise the Directors’ certificate checks fail. See TLS Certificates.

manage_firewall

Whether deploy configures firewalld for you. The default is true.

  • true: deploy starts and enables firewalld (it must be installed) and configures it on every machine. The machines trust each other for all traffic, the public interface is placed in firewalld’s public zone, and the ports clients need are opened. Before the first run, make sure the public zone allows everything else you need on the public interface, SSH included: firewall-cmd --zone=public --list-all should list ssh under services. See Firewall for exactly what is set up.
  • false: you configure the firewall yourself. deploy only checks that the required ports are open and the pod and service networks are trusted, and stops before changing anything if not. Only firewalld is checked: if it is not running, the check is skipped, and your own firewall must allow the same traffic.

To switch a running cluster from true to false, follow Managing the Firewall Yourself first.

manage_routes

Whether deploy keeps cluster traffic on the main routing table. The default is true; leave it on.

With true, deploy adds lookup main routing rules at priority 50 for the pod network, the service network, and the network the machines reach each other on. This matters on machines that use policy routing, where traffic from an address is sent through its own routing table: without these rules, cluster traffic can leave through the wrong interface. On machines without policy routing, the rules are harmless.

  • deploy owns priority 50 on the NetworkManager profiles of the backplane and public interfaces, and replaces any rule there at that priority. Keep your own rules at other priorities.
  • Your own rules that cluster traffic can match, such as from <address> lookup <table>, must have a priority number of 51 or more. deploy stops with [ACD-ROUTING-CONFLICT], naming the rule, otherwise.
  • If your rules must stay at 50 or below, set manage_routes: false and add your own lookup main rules for those three networks, at a priority other than 50 and ahead of your source rules. See Managing Routing Yourself for the rules and the commands.

Either way, deploy checks that cluster traffic uses the main routing table before it changes anything.

Locked at First Install

The settings below are marked as a separate section in the file. Set them before the first deploy and never change them afterwards: the cluster is built around them. Changing one later means uninstalling k3s from every machine and installing again. A reinstall starts with an empty cluster: the PostgreSQL databases, Kafka’s messages and everything else stored in the cluster are lost unless you have backed them up.

The same applies to each machine’s node_ip and node_external_ip in the inventory, and to the machines’ hostnames. On a running cluster, deploy compares these with what the cluster uses, and stops before changing anything if they differ.

backplane_network

Optional, but recommended. The address range of the backplane network, the network the cluster machines use to talk to each other, such as "10.10.0.0/24". Leave it empty, "", if you have no backplane: the machines then talk to each other over the public network.

When it is set, deploy finds each machine’s interface in that range and configures the cluster to use it: all node-to-node (east-west) traffic runs over it, including Kubernetes, its pod network and the cluster datastore.

  • The backplane should be a separate network from the public network, the one holding the default route. Setting it to the public network’s range works, but then every host on the public network is trusted for all traffic.
  • Every machine needs exactly one interface in the range, and must be reachable from the others on it. deploy stops with [ACD-BACKPLANE-NO-MATCH] or [ACD-BACKPLANE-AMBIGUOUS] otherwise.
  • The backplane does not have to be reserved for the cluster: other hosts, such as your CDN Directors, may also be on it. With manage_firewall: true, every host on the backplane can reach every port on every cluster machine, including the cluster datastore (etcd, 2379-2380/tcp), the Kubernetes API (6443/tcp) and the kubelet (10250/tcp), so connect only hosts you trust to it.

See Configuring Segregated Networks.

cluster_pod_cidr and cluster_svc_cidr

The address ranges Kubernetes gives its pods and its services. The defaults are 10.42.0.0/16 and 10.43.0.0/16.

Change them only if your network already uses addresses in these ranges. The two must not overlap each other, or any address or route on any machine. deploy stops with [ACD-CIDR-OVERLAP] or [ACD-CIDR-IN-USE] before changing anything if they do.

firewall_cluster_zone and firewall_public_policy

The names of the firewalld zone and policy that deploy keeps as its own. The defaults are acd-cluster and acd-public. The zone trusts the cluster, and the policy opens the ports reached from outside.

deploy removes anything in them that it did not put there, so pick names nothing else on the machines uses. Never use one of firewalld’s own names, such as public or trusted. The zone name can be up to 17 characters and the policy name up to 18, using letters, digits, - and _. The defaults suit most sites.

File Locations

Most sites leave these empty: the files are picked up from the configuration folder.

SettingDefaultSet it to use a file kept elsewhere
tls_cert_file, tls_key_file"", from certs/Your certificate and key. See TLS Certificates.
maxmind_city_db, maxmind_asn_db, maxmind_anon_db"", from maxmind/Your three MaxMind databases. See MaxMind GeoIP Databases.
backup_dir/var/lib/acd/backupsWhere each run’s backup is written on the first server. See Backups.

5.4 - TLS Certificates

Serving HTTPS with your own certificate

Overview

The manager serves all its interfaces (the GUI, the API, Zitadel and Grafana) over HTTPS on port 443, under the hostnames in manager_hostnames. Give it a certificate by putting the certificate and its key in /etc/acd/deploy/certs/ and running deploy.

We recommend a certificate from a certificate authority (CA) your users and CDN Directors already trust, such as your organization’s own CA or a public one. Without a certificate, deploy serves a temporary self-signed one, which every browser and client warns about.

Requirements

  • PEM format: text files beginning with -----BEGIN. Convert DER or PKCS#12 files to PEM first.
  • Two files: the certificate in one, the private key in the other. The file names do not matter: deploy identifies them by their contents.
  • The full chain: your certificate followed by any intermediate certificates, often supplied as fullchain.
  • No passphrase on the private key.
  • The right names: the certificate must cover the first entry of manager_hostnames, directly or by a wildcard such as *.example.com. Cover the other entries too, as Subject Alternative Names (SANs), or clients using them fail their certificate check. This includes any name your CDN Directors use to reach the manager, such as a name for the backplane addresses.
  • Exactly one certificate and one key in the folder.

Adding or Replacing the Certificate

  1. Copy the files into the folder, and keep the key readable only by root:

    cp manager-fullchain.crt manager.key /etc/acd/deploy/certs/
    chmod 600 /etc/acd/deploy/certs/manager.key
    

    When replacing a certificate, remove the old files first, so the folder holds only one pair.

  2. Check the names and dates:

    openssl x509 -in /etc/acd/deploy/certs/manager-fullchain.crt -noout -subject -issuer -dates -ext subjectAltName
    

    Expected output:

    subject=CN = manager.example.com
    issuer=CN = Example Issuing CA
    notBefore=Oct 14 18:57:23 2026 GMT
    notAfter=Jan 16 18:57:23 2027 GMT
    X509v3 Subject Alternative Name:
        DNS:manager.example.com
    
  3. Run deploy:

    /mnt/esb3027/deploy
    

deploy checks the pair before it changes anything: that both are readable, that they match, that the certificate has not expired, and that it covers the main hostname. It then stores them in the cluster as the ingress certificate for every manager hostname.

The certificate and key are not included in the backups deploy takes. Keep your own copies.

Checking the Served Certificate

From any machine that can reach the manager:

openssl s_client -connect manager.example.com:443 -servername manager.example.com </dev/null 2>/dev/null | grep -E 'subject=|issuer=|Verify return code'

Expected output:

subject=CN = manager.example.com
issuer=CN = Example Issuing CA
Verify return code: 0 (ok)

A verify return code other than 0 means this machine does not trust the issuing CA. With a private CA, add -CAfile <your CA certificate> to the command, and install the CA certificate on every client that connects to the manager, CDN Directors included.

A Certificate for a Test Cluster

For a lab or test cluster without a certificate, you can create a private CA and issue a certificate from it. Clients then trust the manager once they trust the CA. Do not use this for production.

mkdir -p /root/test-ca && cd /root/test-ca

# A private CA, valid for ten years
openssl req -x509 -newkey rsa:4096 -sha256 -days 3650 -nodes \
    -keyout test-ca.key -out test-ca.crt -subj "/CN=Test CA"

# A key and certificate request for the manager
openssl req -newkey rsa:2048 -sha256 -nodes \
    -keyout manager.key -out manager.csr -subj "/CN=manager.example.com"

# Sign it, naming every manager hostname
cat > manager.ext <<'EOF'
basicConstraints=CA:FALSE
keyUsage=digitalSignature,keyEncipherment
extendedKeyUsage=serverAuth
subjectAltName=DNS:manager.example.com
EOF
openssl x509 -req -in manager.csr -CA test-ca.crt -CAkey test-ca.key -CAcreateserial \
    -days 825 -sha256 -extfile manager.ext -out manager.crt

# Install the full chain and the key
cat manager.crt test-ca.crt > /etc/acd/deploy/certs/manager-fullchain.crt
install -m 600 manager.key /etc/acd/deploy/certs/manager.key

Then run deploy. Pass --cacert /root/test-ca/test-ca.crt to curl, and import test-ca.crt into your browser, to connect without warnings.

Problems

CodeCauseFix
[ACD-TLS-NOT-PEM]No file in certs/ is a readable PEM certificate or key.Convert the files to PEM.
[ACD-TLS-COMBINED-FILE]One file holds both the certificate and the key.Split them into two files.
[ACD-TLS-TOO-MANY]More than one certificate or key in certs/.Leave exactly one of each.
[ACD-TLS-UNPAIRED]A certificate without a key, or a key without a certificate.Provide both, or neither for a self-signed certificate.
[ACD-TLS-KEY-UNREADABLE]The key is not PEM, or has a passphrase.Remove the passphrase: openssl pkey -in <key> -out <key>.plain.
[ACD-TLS-KEY-MISMATCH]The certificate and key are not a pair.Use the key the certificate was requested with.
[ACD-TLS-CERT-EXPIRED]The certificate has expired.Replace it with a current one.
[ACD-TLS-HOSTNAME]The certificate does not cover the first entry of manager_hostnames.Reissue it for that name, or for a wildcard covering it.

5.5 - MaxMind GeoIP Databases

Enabling geographic routing with your own MaxMind databases

Overview

The manager looks up the location, network and anonymity of client addresses in MaxMind GeoIP databases, for geographic routing and the GeoIP API. The databases are not shipped with the CDN Manager: obtain them from your own MaxMind subscription.

Three databases are needed, as .mmdb files:

DatabaseExample file nameName must contain
CityGeoIP2-City.mmdbCity
ASNGeoLite2-ASN.mmdb or GeoIP2-ASN.mmdbASN
Anonymous IPGeoIP2-Anonymous-IP.mmdbAnon

Provide all three or none. Without them, the CDN Manager installs and runs, but geographic routing is off.

Adding or Updating the Databases

  1. Copy the three files into /etc/acd/deploy/maxmind/, replacing any older copies:

    cp GeoIP2-City.mmdb GeoLite2-ASN.mmdb GeoIP2-Anonymous-IP.mmdb /etc/acd/deploy/maxmind/
    ls -l /etc/acd/deploy/maxmind/
    

    Expected output:

    total 126236
    -rw-r--r--. 1 root root      4382 Oct 14 18:57 GeoIP2-Anonymous-IP.mmdb
    -rw-r--r--. 1 root root 121642973 Oct 14 18:57 GeoIP2-City.mmdb
    -rw-r--r--. 1 root root   7609502 Oct 14 18:57 GeoLite2-ASN.mmdb
    -rw-r--r--. 1 root root       400 Oct 14 18:57 README.txt
    
  2. Run deploy:

    /mnt/esb3027/deploy
    

deploy loads the databases into the cluster after installing the CDN Manager, and loads them again only when they have changed. MaxMind publishes updates regularly: repeat these steps to apply them.

The databases are not included in the backups deploy takes. Keep your own copies.

Checking the Databases

When the databases are loaded, the final health check of deploy reports:

"msg": "Deployment gate PASSED for cdn-platform. GeoIP is serving."

To look up an address yourself:

curl 'https://manager.example.com/api/v1/routing/geoip?ip=8.8.8.8'; echo

Expected output (shortened):

{"city":{"continent":{"code":"NA",...},"country":{"iso_code":"US",...},...},"asn":{"autonomous_system_number":15169,"autonomous_system_organization":"GOOGLE"},"anon":{}}

See the Routing API for the full response.

Keeping the Files Elsewhere

To use files kept in another folder, set their paths in Site Settings instead of copying them:

maxmind_city_db: "/opt/maxmind/GeoIP2-City.mmdb"
maxmind_asn_db: "/opt/maxmind/GeoLite2-ASN.mmdb"
maxmind_anon_db: "/opt/maxmind/GeoIP2-Anonymous-IP.mmdb"

Problems

CodeCauseFix
[ACD-MAXMIND-INCOMPLETE]maxmind/ holds some, but not all three, databases.Provide all three, or none.
[ACD-MAXMIND-AMBIGUOUS]More than one file matches the same database.Leave one file per database.

5.6 - Application Settings

Presets, values.override.yaml, and the generated values.yaml

Overview

The CDN Manager is a Helm release, cdn-platform, configured by Helm values. deploy builds those values from three layers, each overriding the one before it:

LayerFileWho writes it
1. Presetpreset.yaml, a link to a file on the ISOAgileTV
2. Your overridesvalues.override.yamlYou
3. Derived settingsworked out from group_vars/all.yaml and the certs/ folderdeploy

deploy merges them into /etc/acd/deploy/values.yaml on every run and installs the release with that one file.

Most sites need nothing beyond the preset and their site settings.

Presets

The preset is the complete starting configuration, chosen once with deploy init and remembered for every later run:

PresetCommandISO fileUse it for
Productiondeploy init prod (the default)values-production.yamlProduction clusters of three or more machines. Replicated services are spread across machines, with production resource sizing.
Labdeploy init labvalues-lab.yamlA single-machine lab, demonstration or acceptance test. Single replicas and small resource requests.

The preset is read from the ISO through the preset.yaml link, never copied. A new release’s ISO therefore brings its own defaults with it. This is also why the ISO must be mounted whenever you run deploy.

Overriding Settings

Write only the settings you want to change in /etc/acd/deploy/values.override.yaml, keeping the same nesting as in the preset. It starts empty. For example, to make the manager log in more detail while you investigate a problem:

cdn-manager:
  manager:
    logLevel: debug

Then run deploy. To undo it, remove the setting and run deploy again.

Warning: Set volume sizes before the first deploy. Helm cannot change the size of a persistent volume claim that already exists, so changing a size or persistence setting in values.override.yaml on an installed cluster does not resize the volume, and can make the upgrade fail. This applies to every component with storage, such as Kafka, VictoriaMetrics, PostgreSQL and Redis. Raising a retention setting also uses more disk, so check the volume has room first.

To find a setting, look it up in the preset on the ISO (/mnt/esb3027/values-production.yaml or /mnt/esb3027/values-lab.yaml), in the full reference /mnt/esb3027/values.yaml, or in the Configuration Guide.

Note: Some settings are derived and override anything in values.override.yaml: every manager hostname, Zitadel’s external domain, the TLS secret, and the list of CDN Directors whenever external_directors is not empty. Set those through manager_hostnames, external_directors and the certs/ folder instead.

The Generated values.yaml

/etc/acd/deploy/values.yaml holds the exact values the cluster received. Read it to see the result of the three layers.

Do not edit it: deploy rewrites it on every run, and stops with [ACD-VALUES-EDITED] if it was changed by hand. Move the change into values.override.yaml or group_vars/all.yaml, then delete values.yaml and run deploy again.

5.7 - Running deploy

What a run does, checking and restoring backups, and what to send support

Overview

deploy is on the installation ISO, at /mnt/esb3027/deploy. Run it as root on the first server in the inventory, with the ISO mounted at /mnt/esb3027. It reaches every other machine over SSH. For its commands, options, files and exit status, see the deploy reference.

To change what is installed, edit the files in /etc/acd/deploy and run deploy. Do not change the release with helm directly: the next deploy replaces it with what the configuration says.

What a Run Does

/mnt/esb3027/deploy

The first run installs; every later run re-applies your settings and changes only what differs. Running it again is safe, including after a failed run. deploy checks each machine after changing its network, and on a running cluster changes them one at a time, but a wrong network setting can still cut a machine off, and the run then stops with [ACD-NODE-UNREACHABLE].

Each run goes through fixed stages:

StageWhat it doesChanges anything?
Gather inputsReads every machine and works out the settingsNo
PreflightChecks the machines, the settings and, on a running cluster, the locked settingsNo
BackupArchives each machine’s configuration on the first serverWrites the backup only
MediaCopies what the other machines need from the ISOYes
ValuesWrites values.yamlYes
Host networkConfigures firewall zones, routing rules and kernel network settings, then verifies themYes
ClusterInstalls or upgrades k3s: the first server, then the other servers one at a time, then the agentsYes
PlatformInstalls or upgrades the cdn-platform release, loads the MaxMind databases, and runs the health checkYes

deploy backup runs only the first and third stages. --skip-tags preflight skips the second; use it only when you are sure a check is wrong for your site.

When deploy stops on a problem it recognizes, its message starts with a code such as [ACD-TLS-HOSTNAME]. See Installation Troubleshooting.

Backups

Important: These backups hold only the deployment’s configuration, each machine’s configuration and an etcd snapshot of the Kubernetes state. They do not include the data stored in the cluster: not the PostgreSQL databases, not Kafka’s messages, not the contents of any persistent volume, and not the metrics. Restoring one of these backups puts a machine’s configuration back; it cannot recover lost data. Back up the data separately: see Backup Procedures.

Every run of deploy takes a backup first, and deploy backup takes one without changing anything. Each backup is a folder under /var/lib/acd/backups on the first server: see the deploy reference for its files. deploy never deletes a backup: remove old folders yourself. Keep your own copies of the TLS certificate and key and the MaxMind databases, which the backup leaves out.

Important: Treat backups as secret material. A backup of a running cluster holds an etcd snapshot of the whole cluster, which contains every Kubernetes Secret: database passwords, the Zitadel master key and TLS keys among them. Every machine’s archive, servers’ and agents’, also holds the cluster token, and the servers’ hold the cluster-admin kubeconfig, /etc/rancher/k3s/k3s.yaml. Keep backups, and any copies you make, readable only by root, and delete the ones you no longer need.

The etcd snapshot is in two places, and deploy prunes neither:

  • the backup folder under /var/lib/acd/backups on the first server, inside one server’s <machine>.tar.gz. Remove an old backup folder with:

    rm -r /var/lib/acd/backups/<folder>
    
  • /var/lib/rancher/k3s/server/db/snapshots on that server, named acd-backup-<UTC time>-<machine>-<number>. List and delete old ones on that server with:

    k3s etcd-snapshot list
    k3s etcd-snapshot delete <name>
    

k3s also takes its own etcd snapshots, in the same folder on every server, named etcd-snapshot-<machine>-<time>: one every 12 hours, keeping the latest five, which k3s prunes itself. They hold every Kubernetes Secret too, so treat them, and any copy of them, as secret material.

The backup folder on the first server also holds a copy of /etc/acd/deploy, except the certs/ and maxmind/ folders and deploy.log, which it leaves out. Keep a copy of the latest backup folder off the first server, so that losing it does not lose the configuration deploy needs. Protect the copy as the warning above describes.

Before relying on a backup, check it:

cd /var/lib/acd/backups/<folder>
sha256sum -c SHA256SUMS

Restoring a Machine’s Files

Copy the machine’s archive and checksum file to it, then, as root:

tar --selinux --xattrs --acls -C / -xpzf <machine>.tar.gz
sha256sum -c <machine>.files.sha256

Keep the --selinux --xattrs --acls options: plain tar restores some k3s files with the wrong SELinux label. Then reboot the machine, or restart NetworkManager and k3s.

Rolling back the cluster itself also needs the etcd snapshot, restored on one server with the k3s cluster-reset procedure: stop k3s on every server, restore that server’s archive, run k3s server --cluster-reset --cluster-reset-restore-path=<snapshot path> on it, start k3s there, and rejoin the other servers. See the k3s etcd snapshot documentation for the full procedure.

What to Send Support

When the final health check fails, deploy writes /etc/acd/deploy/support-bundle-acd.tar.gz: send it to AgileTV support. When a run stops before the health check, send deploy.log, decision-record.json and the probe/ folder from /etc/acd/deploy instead.

5.8 - Changing the Cluster

Adding machines and changing a machine’s role

Overview

The machines in the cluster are whatever inventory.ini lists. To change them, edit the inventory and run deploy. This page covers the changes that need more than that.

Before you start, check that the cluster is healthy: every node yes under READY and every admin target ok under HEALTH in acd-admin status.

Adding an Agent

  1. Install the new machine as for the others: see Before You Begin. If you use a backplane, give it an address in backplane_network.

  2. From the first server, set up SSH to it:

    ssh-copy-id root@192.0.2.14
    ssh -o BatchMode=yes root@192.0.2.14 hostname
    
  3. Add it under [agent] in /etc/acd/deploy/inventory.ini:

    [agent]
    manager-4 ansible_host=192.0.2.14
    
  4. Run deploy:

    /mnt/esb3027/deploy
    

deploy configures the new machine’s network, joins it to the cluster, and updates every other machine’s firewall to trust it.

Adding Servers

The number of servers must stay odd, so add servers two at a time, for example to go from one to three. Prepare both machines as for an agent, list both under [server], and run deploy once. deploy joins them one at a time.

Changing a Machine’s Role

k3s cannot turn a server into an agent, or an agent into a server, in place: the machine leaves the cluster and joins again in its new role. Because the number of servers must stay odd, a role change always moves two machines: two agents promoted to go from one server to three, two servers demoted to go from three to one, or a server and an agent swapped.

  1. Take the first machine out of the cluster, whether it is a server or an agent. From any server, drain and remove it:

    acd-admin drain <machine>
    kubectl delete node <machine>
    

    The drain moves the machine’s pods elsewhere. It can take several minutes while storage moves, and reports what it is waiting for. If it does not finish, see When a Drain Does Not Finish. Removing the node needs kubectl: acd-admin has no command for it. Read Safety first.

    Then, on the machine itself, uninstall k3s: run /usr/local/bin/k3s-uninstall.sh if it was a server, or /usr/local/bin/k3s-agent-uninstall.sh if it was an agent.

  2. Only when the first machine is out, do the same for the second. Taking two out at once can leave too few machines for the workloads.

  3. List both machines under their new roles in inventory.ini, and run deploy.

With a single server, promoting two agents leaves the server alone to run every workload while both agents are out. Expect pods to stay Pending until the machines rejoin. The deploy that brings them back allows for this, and waits up to 10 minutes for the pods to start.

If a machine is listed under a role other than the one it runs, deploy stops with [ACD-INVENTORY-ROLE-CHANGED] before changing anything.

When a Drain Does Not Finish

acd-admin drain waits for every pod it moves, and has no timeout of its own. Its session ends after 12 hours; running drain again carries on where it stopped. Storage does not hold it up for long: each volume has replicas on other machines, and for the Kafka data volumes, which have a single replica, the storage layer (Longhorn) copies the replica to another machine before it lets the drain go on. While that happens, the drain names the pod whose eviction a disruption budget refuses for now, and keeps retrying.

If it still waits, read what it names:

  • A pod that is terminating but not gone, with the finalizer holding it. On a machine that is not Ready, such a pod stays until the machine returns. Once the drain waits only on such pods, press Ctrl-C: the machine stays drained.
  • A pod whose eviction is refused for a long time. Check acd-admin status for an admin target that is not ok, and kubectl -n longhorn-system get volumes.longhorn.io for a volume that is not healthy. A disruption budget refuses to evict a pod while its other pods are not Ready. Fix that, and the drain carries on.

See acd-admin drain.

Things You Cannot Change

The cluster is built around a few settings that cannot change once it is installed: backplane_network, cluster_pod_cidr, cluster_svc_cidr, the firewall zone and policy names, each machine’s node_ip and node_external_ip, and the machines’ hostnames. See Locked at First Install.

5.9 - Air-Gapped Installation

Installing without internet access

Overview

The installation ISO carries everything the CDN Manager needs, container images included, so an installation needs no internet access. What deploy does need is the operating system’s own packages: it installs a few with dnf on every machine: ansible-core on the first server, and on all of them rsync, tar, zstd, bash-completion, the storage tools (iscsi-initiator-utils and nfs-utils), and container-selinux, which the cluster’s SELinux policy needs.

Without internet access, give every machine a local package repository from the operating system’s installation media, then follow the Quickstart as written.

Prerequisites

MediaPurpose
esb3027-cdn-manager-1.8.0.isoThe CDN Manager, on the first server only
The installation ISO of your operating system, for the same release as the machinesOperating system packages, on every machine

Set Up a Local Repository on Every Machine

On each machine, mount the operating system ISO and add its two repositories. This example is for Red Hat Enterprise Linux 9; on a rebuild, use its ISO and the signing key file its own documentation names.

mkdir -p /mnt/os
mount -o loop,ro /root/rhel-9.4-x86_64-dvd.iso /mnt/os

cat > /etc/yum.repos.d/os-media.repo <<'REPO'
[os-media-baseos]
name=OS media - BaseOS
baseurl=file:///mnt/os/BaseOS
enabled=1
gpgcheck=1
gpgkey=file:///etc/pki/rpm-gpg/RPM-GPG-KEY-redhat-release

[os-media-appstream]
name=OS media - AppStream
baseurl=file:///mnt/os/AppStream
enabled=1
gpgcheck=1
gpgkey=file:///etc/pki/rpm-gpg/RPM-GPG-KEY-redhat-release
REPO

Disable any repository the machine cannot reach, or dnf fails while trying to read it:

dnf repolist
dnf config-manager --set-disabled <repository id>

Then check that dnf can find the packages:

dnf -q repoquery --latest-limit 1 ansible-core rsync tar zstd bash-completion iscsi-initiator-utils nfs-utils container-selinux

Each package should be listed once, whether or not it is already installed.

Important: Keep the local repository available on every machine whenever you run deploy, not only for the first installation. Every run installs or checks packages with dnf on each machine, and stops if it cannot reach them. Remount the operating system ISO after a reboot, as you do the CDN Manager ISO.

Install

Follow the Quickstart. Copy the MaxMind databases, your TLS certificate and its key, and both ISOs to the machines by whatever means your site allows.

If the first server cannot install ansible-core, deploy init stops with:

Error: could not install ansible-core. Ensure your OS repositories
or mounted install media are available to dnf (air-gap SOP).

Check the repository on that machine and run deploy init again.

5.10 - Upgrade Guide

Moving to a new release of the CDN Manager

Upgrading to 1.8 from an Earlier Release

Version 1.8 cannot be installed over 1.6 or any earlier release. The platform’s Helm charts were rearranged, and the cluster is now built and managed by deploy, so an earlier cluster cannot be upgraded in place.

To move to 1.8:

  1. Install 1.8 on a new cluster, on new or reinstalled machines, with the Quickstart.
  2. Set up its users and configuration. Contact AgileTV support to plan moving your existing configuration.
  3. Point your CDN Directors at the new manager, then retire the old cluster.

Upgrading from 1.8 to a Later Release

From 1.8 on, an upgrade is a deploy run with the new release’s ISO: deploy upgrades k3s on each machine, one server at a time, then upgrades the cdn-platform release.

  1. Read the Release Notes for the new release, for any step it needs before or after the upgrade.

  2. Check that the cluster is healthy:

    acd-admin status
    
  3. Replace the ISO at /mnt/esb3027 with the new one, on the first server:

    umount /mnt/esb3027
    mount -o loop,ro /root/esb3027-cdn-manager-<new version>.iso /mnt/esb3027
    

    The preset link in /etc/acd/deploy points into /mnt/esb3027, so mount the new ISO at the same path.

  4. Run deploy init from the new ISO. It is safe to run again: it replaces the installed Ansible collections with the new ISO’s versions, installs ansible-core only if it is missing, keeps your preset, and leaves the files you have edited alone.

    /mnt/esb3027/deploy init
    
  5. Run deploy:

    /mnt/esb3027/deploy
    

deploy takes a backup of the configuration before it changes anything, including an etcd snapshot of the cluster. That backup does not include the data stored in the cluster, such as the PostgreSQL databases: back that up first, as described in Backup Procedures. See Backups.

5.11 - Installation Troubleshooting

What deploy’s failure codes mean, and what to do about them

Overview

deploy checks the machines and your settings before it changes anything, and checks its own work as it goes. When it stops on a problem it recognizes, the message starts with a code in square brackets, such as [ACD-TLS-HOSTNAME], and names the machine, file or setting involved. The code stays the same when the wording changes, so search for it on this page, or quote it to support.

After fixing the cause, run deploy again. Re-running is safe: it picks up from the state the cluster is in. “Nothing was changed” in the table below means the run stopped before its first change.

Expected Messages

These appear in a successful run, and need no action:

MessageWhy
FAILED - RETRYING: [<machine>]: Wait for this server to become Ready (N retries left).deploy polls each joining machine until it is ready. Ten or more per machine is normal.
[WARNING]: Collection ansible.posix does not support Ansible version ...The bundled collection works with the ansible-core release of Enterprise Linux 9.
[WARNING]: Could not match supplied host pattern, ignoring: acd_host_network_verifyNo machine’s network needed checking at that point.

Problems Without a Code

SymptomCauseFix
UNREACHABLE for a machinedeploy cannot log in to it over SSH without a password.Set up the key: see SSH Access.
preset.yaml points at ..., which does not existThe installation ISO is not mounted at the path deploy init used, normally /mnt/esb3027.Mount it at that path, or run deploy init again from where it is mounted now.
ansible-core is not installeddeploy init has not been run on this machine, or ansible-core was removed.Run /mnt/esb3027/deploy init. It keeps the preset an existing configuration already uses; a new configuration gets prod unless you add lab.
deploy init cannot install ansible-corednf cannot reach the operating system’s repositories.Fix the repositories. Without internet access, see Air-Gapped Installation.
Signing in fails, though the pages loadThe browser uses a name other than the main manager hostname, such as an IP address.Browse to the first entry of manager_hostnames. Sign in to the GUI as <user>@zitadel.<manager hostname>.

Failure Codes

Each code below gives the cause, then what to do.

[ACD-ANSIBLE-VERSION]

Cause: The installed ansible-core is older than 2.14.

What to do: Run dnf update ansible-core.

[ACD-HOST-PROBE-IN-DEPLOY]

Cause: A machine has a host_probe setting. It is an input for tests and for replaying a run, and deploy reads every machine itself. Nothing was changed.

What to do: Remove the host_probe setting from inventory.ini and host_vars.

[ACD-HOST-PROBE-SCHEMA]

Cause: A machine’s host_probe setting, used by tests and replays, has a format version this deploy cannot read. Nothing was changed.

What to do: Replace it with that machine’s probe/<machine>.json from a run of this release, or remove the host_probe setting.

[ACD-NO-DEFAULT-ROUTE]

Cause: A machine has no IPv4 default route, so its address cannot be chosen.

What to do: Give the machine a default route.

[ACD-BACKPLANE-AMBIGUOUS]

Cause: backplane_network matches more than one interface on a machine.

What to do: Narrow backplane_network, or set node_ip to the address on the backplane interface, on that machine’s line in inventory.ini.

[ACD-BACKPLANE-NO-MATCH]

Cause: backplane_network matches no interface on a machine.

What to do: Fix backplane_network, or set node_ip to the address on the backplane interface, on that machine’s line in inventory.ini. Every machine needs an address in backplane_network.

[ACD-NODE-ADDRESS-NOT-FOUND]

Cause: No interface on a machine holds the address chosen for it.

What to do: Check node_ip for that machine, or backplane_network.

[ACD-DEFAULT-ADDRESS-NOT-FOUND]

Cause: No interface on a machine holds its default route’s source address.

What to do: Give the default route a source address that one of the machine’s interfaces holds.

[ACD-NM-NOT-RUNNING]

Cause: NetworkManager is not running on a machine, so the deployment cannot set its interfaces’ zones or routing rules.

What to do: Start it with systemctl enable --now NetworkManager and run deploy again.

[ACD-NM-CONNECTION-MISSING]

Cause: An interface the deployment configures has no active NetworkManager connection.

What to do: Let NetworkManager manage it (nmcli device set <interface> managed yes), give it a connection (nmcli device connect <interface>, or nmcli connection add) and run deploy again.

[ACD-PARTIAL-RUN]

Cause: The run was limited to some machines, so it could not plan every machine’s network. What each machine trusts depends on all of them.

What to do: Run deploy without --limit.

[ACD-DIRECTOR-ADDRESS]

Cause: An entry in external_directors has no address, or one that is not an IPv4 address.

What to do: Give each CDN Director’s IPv4 address, such as 192.0.2.201.

[ACD-MAXMIND-AMBIGUOUS]

Cause: More than one file in maxmind/ matches the same MaxMind database.

What to do: Leave one file per database in maxmind/.

[ACD-MAXMIND-INCOMPLETE]

Cause: maxmind/ holds some, but not all three, MaxMind databases.

What to do: Provide all three databases, or none.

[ACD-TLS-COMBINED-FILE]

Cause: One file in certs/ holds both the certificate and the private key.

What to do: Split them into two PEM files in certs/.

[ACD-TLS-TOO-MANY]

Cause: certs/ holds more than one certificate or more than one key.

What to do: Leave exactly one certificate and one key.

[ACD-TLS-UNPAIRED]

Cause: certs/ holds a certificate without a key, or a key without a certificate.

What to do: Provide both, or neither for a temporary self-signed certificate.

[ACD-TLS-NOT-PEM]

Cause: certs/ holds files, but none is a readable PEM certificate or key.

What to do: Convert DER or PKCS#12 files to PEM.

[ACD-TLS-CERT-UNREADABLE]

Cause: The certificate file cannot be read as a PEM certificate.

What to do: Provide a PEM certificate.

[ACD-TLS-KEY-UNREADABLE]

Cause: The private key cannot be read: it is not PEM, or it has a passphrase.

What to do: Remove the passphrase with openssl pkey -in <key> -out <key>.plain.

[ACD-TLS-KEY-MISMATCH]

Cause: The certificate and the private key are not a pair.

What to do: Provide the key the certificate was requested with.

[ACD-TLS-CERT-EXPIRED]

Cause: The certificate has expired.

What to do: Replace it with a current certificate.

[ACD-TLS-HOSTNAME]

Cause: The certificate does not cover the first entry of manager_hostnames.

What to do: Reissue it for that name, or for a wildcard covering it.

[ACD-INVENTORY-NO-SERVER]

Cause: The inventory lists no server machine.

What to do: List at least one; the first server is the control machine.

[ACD-INVENTORY-STRAY-DIRECTOR]

Cause: A machine is listed under [director].

What to do: Leave the [director] section empty. List CDN Directors in external_directors in Site Settings.

[ACD-INVENTORY-SERVER-AND-AGENT]

Cause: A machine is listed under both server and agent.

What to do: List it under only one.

[ACD-INVENTORY-EVEN-SERVERS]

Cause: The inventory lists an even number of servers.

What to do: List an odd number of servers; move the rest under agent.

[ACD-INVENTORY-DUPLICATE-HOSTNAME]

Cause: Two machines share a hostname, ignoring case.

What to do: Give each machine a unique hostname.

[ACD-INVENTORY-ROLE-CHANGED]

Cause: A machine runs k3s as a server but is listed as an agent, or the other way round.

What to do: List it under its current role, or follow Changing a Machine’s Role.

[ACD-NODE-OS]

Cause: A machine does not run Enterprise Linux 9.4 or a later 9 release.

What to do: Update or reinstall it, or remove it from the inventory.

[ACD-NODE-KERNEL-MODULES]

Cause: A machine lacks kernel modules k3s and its storage need.

What to do: Install the kernel modules package for the running kernel.

[ACD-NODE-RESOURCES]

Cause: A machine has fewer processor cores or less memory than its role needs.

What to do: Add cores or memory, or make it an agent.

[ACD-VALUES-EDITED]

Cause: values.yaml was edited by hand.

What to do: Move the changes to values.override.yaml or group_vars/all.yaml, then delete values.yaml.

[ACD-CLUSTER-API-DOWN]

Cause: k3s is installed, but no server’s k3s API answers.

What to do: Get k3s answering on one server. If you are sure the check is wrong, run deploy --skip-tags preflight.

[ACD-CLUSTER-NODE-RENAMED]

Cause: A machine’s hostname changed after it joined the cluster.

What to do: Set the hostname back to the name in the message.

[ACD-CLUSTER-NODE-ADDRESS]

Cause: A machine’s cluster address differs from the one the settings choose.

What to do: Set the address settings back to what the cluster runs with; see Locked at First Install.

[ACD-CLUSTER-POD-CIDR]

Cause: A machine’s pod range lies outside cluster_pod_cidr.

What to do: Set cluster_pod_cidr back to what the cluster runs with.

[ACD-CLUSTER-SERVICE-CIDR]

Cause: The cluster’s service range differs from cluster_svc_cidr.

What to do: Set cluster_svc_cidr back to what the cluster runs with.

[ACD-HEALTH-NODE-NOT-READY]

Cause: A cluster machine is not Ready.

What to do: Check systemctl status k3s or k3s-agent on it, or remove it from the cluster.

[ACD-HEALTH-LIST-FAILED]

Cause: The databases or volumes could not be listed.

What to do: Check the cluster API answers with k3s kubectl get nodes.

[ACD-HEALTH-DATABASE]

Cause: A PostgreSQL database cluster is not healthy: it is still being built, is switching its primary, or more than one of its instances is not ready. One instance being rebuilt is accepted, and so is one instance per machine the run is bringing back after a role change.

What to do: Inspect it with the kubectl describe command in the message.

[ACD-HEALTH-VOLUME-FAULTED]

Cause: A storage volume has no replica with usable data, and not every replica is on a machine the run joins back.

What to do: Recover it from the Longhorn UI or a backup.

[ACD-CIDR-OVERLAP]

Cause: cluster_pod_cidr and cluster_svc_cidr overlap.

What to do: Choose two separate ranges.

[ACD-BACKPLANE-SPLIT]

Cause: The machines’ cluster addresses are not all on one network, so they could not reach each other. Either a machine’s address is outside backplane_network, or, without one, a machine using a second interface for cluster traffic has an address on a different network from the others. Nothing was changed.

What to do: The message names the machine and the network. Put every machine’s cluster address inside backplane_network (correct node_ip in inventory.ini if needed), or set backplane_network to the network the machines actually share.

[ACD-CIDR-IN-USE]

Cause: A machine already uses an address or route in cluster_pod_cidr or cluster_svc_cidr.

What to do: Choose ranges no machine uses.

[ACD-ROUTING-CONFLICT]

Cause: A routing rule sends cluster traffic away from the main routing table.

What to do: Follow the message: give the conflicting rules a priority number above 50, or with manage_routes: false, set it to true or add your own lookup main rules ahead of them.

[ACD-FIREWALL-SOURCE-CONFLICT]

Cause: Another firewalld zone on a machine already holds an address range that overlaps one deploy needs to trust: the pod or service network, backplane_network, or another machine’s address. firewalld allows a range in only one zone. Nothing was changed.

What to do: Run the commands the message shows: they remove the overlapping range from the other zone. If the zone it names is deploy’s own zone under an earlier name, set firewall_cluster_zone back instead: it is locked at first install. Or set manage_firewall: false.

[ACD-FIREWALL-NAME-INVALID]

Cause: firewall_cluster_zone or firewall_public_policy is the name of one of firewalld’s own zones or policies (such as public, trusted or allow-host-ipv6), is empty or not a string, is longer than firewalld allows (17 characters for the zone, 18 for the policy), has characters other than letters, digits, - and _, or both are the same. Nothing was changed.

What to do: Choose other names in group_vars/all.yaml.

[ACD-FIREWALL-NAME-IN-USE]

Cause: A machine already has a firewalld zone or policy named like firewall_cluster_zone or firewall_public_policy, and it holds rules deploy did not create. deploy would delete those rules, so it stops instead. Nothing was changed.

What to do: Choose names in group_vars/all.yaml that nothing on the machines uses, or empty that zone or policy yourself.

[ACD-FIREWALL-HANDOVER-STOPPED]

Cause: With manage_firewall: false, deploy needs to empty its own zone and policy, but firewalld is stopped. Rules saved in a stopped firewalld would come back when it starts. If this appears after the network changes, firewalld stopped during the run, and nothing was installed on the cluster.

What to do: Start firewalld (systemctl enable --now firewalld) and run deploy again. Or run the commands the message shows, then run deploy again.

[ACD-FIREWALL-CLOSED]

Cause: With manage_firewall: false, a machine’s firewall blocks something the cluster needs: a required port is closed, or the pod or service network is not in a zone that accepts all traffic. Nothing was changed.

What to do: Run the firewall-cmd commands the message shows. See Managing the Firewall Yourself. Or set manage_firewall: true.

[ACD-BACKUP-FOLDER-CREATE]

Cause: The folder for this run’s backup could not be created.

What to do: Check that the backup folder’s parent exists and is writable, or set backup_dir in group_vars/all.yaml.

[ACD-BACKUP-FOLDER-EXISTS]

Cause: Another run started in the same second and owns the backup folder.

What to do: Run deploy again; nothing was changed.

[ACD-BACKUP-SNAPSHOT]

Cause: The etcd snapshot for the backup was not found, or more than one was.

What to do: Check the snapshot folder and the k3s log (journalctl -u k3s) on the machine the message names, then run deploy again.

[ACD-NM-PENDING-EDITS]

Cause: A network connection deploy needs to change has edits saved in NetworkManager that are not active yet. Applying deploy’s change would activate them too. Nothing was changed on that machine.

What to do: Check the pending edits with nmcli connection show <uuid>. Either undo them with nmcli connection modify, or activate them in a maintenance window with nmcli connection up <uuid>. Then run deploy again.

[ACD-CLUSTER-ZONE-MISSING]

Cause: A machine already running k3s has no acd-cluster zone or no acd-public policy (or the names you set). Creating them needs a firewalld reload, which would remove the rules k3s depends on, so deploy will not do it on a running machine. Nothing was changed.

What to do: If you changed firewall_cluster_zone or firewall_public_policy, set them back: they are locked at first install. Otherwise, in a maintenance window, one machine at a time, create them (firewall-cmd --permanent --new-zone=acd-cluster, --zone=acd-cluster --set-target=ACCEPT, --new-policy=acd-public, --policy=acd-public --add-ingress-zone=public --add-egress-zone=HOST), run firewall-cmd --reload, and restart k3s. Then run deploy again.

[ACD-NODE-UNREACHABLE]

Cause: A machine stopped answering over SSH while its host network changed, one machine at a time on a running cluster, so the machines after it were left as they were. The change itself may have cut it off.

What to do: Check that machine’s network from its console, then run deploy again.

[ACD-VERIFY-ZONE]

Cause: After changing a machine’s network, an interface is not in the firewalld zone deploy planned for it. Nothing was installed on the cluster.

What to do: Run deploy again. If the message comes back, send it to support.

[ACD-VERIFY-FIREWALL]

Cause: After changing a machine’s network, its firewall still blocks something the cluster needs, or another zone holds an address range deploy needs to trust. Nothing was installed on the cluster.

What to do: Run the firewall-cmd commands the message shows, then run deploy again. If the message comes back, send it to support.

[ACD-VERIFY-LEFTOVER]

Cause: After changing a machine’s network, deploy’s own zone or policy holds a rule it did not create, or is missing one it planned. Nothing was installed on the cluster.

What to do: Run deploy again, which removes the extra rule. Keep your own rules in other zones. If the message names a zone target or priority, run firewall-cmd --reload in a maintenance window and restart k3s (or k3s-agent) on that machine, then run deploy again. If it comes back otherwise, send it to support.

[ACD-VERIFY-ROUTING]

Cause: After changing a machine’s network, its routing rules are not what deploy planned: a cluster rule is missing, a removed rule is still active, a rule was added by hand with ip rule add, or cluster traffic uses a routing table other than main. Nothing was installed on the cluster.

What to do: Run the ip rule del commands the message shows, check with ip rule show, then run deploy again. If the message comes back, send it to support.

[ACD-VERIFY-SYSCTL]

Cause: After the network changes, a kernel network setting on a machine has another value than the deployment set.

What to do: Find what sets it (a file under /etc/sysctl.d, or a tuned profile), then run deploy again. No k3s step ran.

[ACD-JOIN-UNREACHABLE]

Cause: A joining machine cannot reach the server it joins through.

What to do: Check the firewall on both machines, that their node_ip addresses are on one network, and that reverse path filtering (net.ipv4.conf.all.rp_filter) does not drop the replies; see the message.

[ACD-GATE-DATABASE-UNHEALTHY]

Cause: After the install, a PostgreSQL database cluster did not become healthy in time. This message is followed by [ACD-GATE-FAILED], which writes the support bundle.

What to do: Wait until kubectl get clusters.postgresql.cnpg.io -A shows Cluster in healthy state, then run deploy again.

[ACD-GATE-UNHEALTHY-PODS]

Cause: After the install, pods in a critical namespace (default, kube-system or longhorn-system) were still not running after 10 minutes. The message lists each such pod with its namespace. It is followed by [ACD-GATE-FAILED], which writes the support bundle.

What to do: On a server, find the cause, fix it, then run deploy again. How depends on the pod’s namespace:

  • default: the platform’s own pods, which acd-admin covers. Most pods belong to an admin target, whose pods acd-admin status <target> lists with their last termination and recent warnings. Find the pod’s target, then read acd-admin logs <target>. For a pod that belongs to no target, such as the Kafka operator’s, use the kubectl commands in the next item.
  • kube-system and longhorn-system: the cluster’s own components and the storage layer, which acd-admin does not cover. Use the commands in the message: kubectl describe pod -n <namespace> <pod> and kubectl logs -n <namespace> <pod>.

[ACD-GATE-RELEASE-STATUS]

Cause: After the install, the Helm release is not deployed. This message is followed by [ACD-GATE-FAILED], which writes the support bundle.

What to do: Find the cause with the helm status command in the message and acd-admin status, fix it, then run deploy again.

[ACD-GATE-FAILED]

Cause: The final health check failed. It follows the message that names the failing check.

What to do: Read that earlier message, and send /etc/acd/deploy/support-bundle-acd.tar.gz to support.

Getting Help

When the final health check fails ([ACD-GATE-FAILED]), deploy writes /etc/acd/deploy/support-bundle-acd.tar.gz, with the run’s log, its decision record and what it read from each machine. Send it to AgileTV support.

When a run stops earlier, send support these files from /etc/acd/deploy instead:

  • deploy.log
  • decision-record.json
  • the probe/ folder

5.12 - Next Steps

Securing the administrator account, adding users, and other first tasks

Overview

With the cluster installed, finish these tasks before you let users on to the system. They are done in the web interfaces, not with deploy.

Every interface is reached under the main manager hostname, the first entry of manager_hostnames. Signing in under any other name, a machine’s IP address included, fails.

InterfaceAddress
GUIhttps://<manager hostname>/gui/
Zitadel Console (users and roles)https://<manager hostname>/ui/console
Grafana (dashboards)https://<manager hostname>/grafana/
APIhttps://<manager hostname>/api/v1/

Step 1: Sign In to the Zitadel Console

Zitadel is the manager’s identity service: every user account and role lives there.

  1. Open https://<manager hostname>/ui/console.
  2. Sign in with the default administrator: login name admin@zitadel.<manager hostname>, password Password1!. The console also accepts the account’s email address, admin@agiletv.dev.
  3. If you are asked to set up multi-factor authentication (MFA), skip it. MFA is not supported in this release, and setting it up can lock you out of the account.

The Zitadel Console home page

Step 2: Replace the Default Administrator

The default credentials are published, so replace them before the system is reachable by anyone else.

  1. Create your own administrator account, as in Step 4, and give it the administrator roles. See Zitadel’s administrator documentation for the roles.
  2. Sign out, and sign in with the new account to check that it works.
  3. Disable or delete the default admin account.

Zitadel sends email to verify addresses and reset passwords. Without an SMTP server, those features do not work.

  1. In the Zitadel Console, open Settings > Default Settings.
  2. Enter your mail server’s host, port (typically 587 or 465), user name and password, and the sender address, such as noreply@example.com.
  3. Save.

Step 4: Add Users

  1. In the Zitadel Console, open Users > Add User.

  2. Enter a username, first and last name, and email address.

    Known issue: The username must match the local part of the email address, the part before the @. For foo@example.com, the username must be foo. Otherwise Zitadel can block sign-in with the full email address.

  3. Without SMTP, tick Email Verified and set an initial password. Otherwise the account is not enabled.

  4. Select Create User, then give the user their username, temporary password, and the console address. They are asked to change the password at first sign-in.

  5. Assign the user’s roles. For Grafana’s roles, see Grafana Authentication.

Users sign in to the GUI with their login name, <username>@zitadel.<manager hostname>, not their email address. For more on managing users, see Zitadel’s user management documentation.

Step 5: Open Grafana

Open https://<manager hostname>/grafana/ and select Sign in with Zitadel. The dashboards are installed with it. See Metrics and Monitoring.

Step 6: Connect Your CDN Directors

Each CDN Director in external_directors is configured through the manager at https://<manager hostname>/confd/<name>/. Each one is known to Grafana as <name>.external, for example director-1.external. Connecting a Director to the manager is described in the CDN Director documentation.

Make sure your network’s firewall lets the Directors reach Kafka on every machine, and nothing else does: see Kafka Ports.

Step 7: Record Your Installation

Keep a record, off the cluster, of:

  • The Zitadel master key, from Step 11 of the Quickstart.
  • Your TLS certificate and key, and its expiry date.
  • Your MaxMind databases.
  • A copy of /etc/acd/deploy.
  • The machines, their addresses, and the manager hostnames.

Further Reading

6 - Configuration Guide

Helm chart configuration reference

Overview

The CDN Manager is the Helm release cdn-platform, configured by Helm values. The release is installed and updated only by deploy, which builds the values from three layers:

  1. The preset on the installation ISO: values-production.yaml or values-lab.yaml, chosen with deploy init.
  2. Your overrides in /etc/acd/deploy/values.override.yaml: only the settings you want to change.
  3. Derived settings worked out from your site settings in /etc/acd/deploy/group_vars/all.yaml and the certs/ folder. These always win.

deploy merges them into /etc/acd/deploy/values.yaml, which is what the cluster receives. See Application Settings.

This guide is the reference for the Helm values.

Applying Configuration

To change a value:

  1. Write the setting in /etc/acd/deploy/values.override.yaml, with the same nesting as below. For example:

    cdn-manager:
      manager:
        logLevel: debug
    
  2. Run deploy:

    /mnt/esb3027/deploy
    

Do not run helm install, helm upgrade or helm rollback yourself: the next deploy replaces the release with what the configuration says. To undo a change, revert it in values.override.yaml and run deploy again.

helm list -A shows six more releases, which the cluster itself runs on. They come from the installation ISO and k3s, not from your values, so leave them alone too:

ReleaseWhat it is
acd-clusterThe PostgreSQL database cluster
cloudnative-pgThe operator that runs the PostgreSQL cluster
longhornPersistent storage
deschedulerMoves pods to even out the load on the nodes
traefik, traefik-crdThe ingress controller, part of k3s

Set through site settings, not values: the manager hostnames (global.hosts.manager and Zitadel’s ExternalDomain), the CDN Directors (global.hosts.routers, when external_directors is not empty) and the TLS certificate secret are derived by deploy, and override anything in values.override.yaml. Set them with manager_hostnames, external_directors and the certs/ folder.

Reference Files

The installation ISO provides the presets and a complete reference at /mnt/esb3027/:

FilePurpose
values-production.yamlThe production preset, for clusters of three or more machines
values-lab.yamlThe lab preset, for a single machine
values.yamlEvery configurable option with its default and inline comments

Hardware requirements: For per-node hardware specifications, refer to the System Requirements Guide. The System Requirements Guide is the authoritative source — the hardware comments in the header of values-production.yaml may not reflect the current requirements.

Configuration Reference

Global Settings

The global section contains cluster-wide settings. The most critical configuration is global.hosts.

global:
  hosts:
    manager:
      - host: manager.local
    routers:
      - name: default
        address: 127.0.0.1
    edns_proxy: []
    geoip: []
KeyTypeDescription
global.hosts.managerArrayExternal IP addresses or DNS hostnames for all Manager cluster nodes
global.hosts.routersArrayESB3024 CDN Director instances
global.hosts.edns_proxyArrayEDNS Proxy addresses (currently unused)
global.hosts.geoipArrayGeoIP Proxy addresses for Frontend GUI

Important: deploy sets global.hosts.manager, Zitadel’s ExternalDomain and global.hosts.routers from manager_hostnames and external_directors in Site Settings. Do not set them in values.override.yaml.

Manager Configuration

Core Manager API server settings:

KeyTypeDefaultDescription
cdn-manager.manager.image.registryStringghcr.ioContainer image registry
cdn-manager.manager.image.repositoryStringedgeware/acd-managerContainer image repository
cdn-manager.manager.image.tagStringImage tag override (uses latest if empty)
cdn-manager.manager.logLevelStringinfoLog level (trace, debug, info, warn, error)
cdn-manager.manager.replicaCountNumber1Number of replicas (HPA manages this when enabled)
cdn-manager.manager.containerPorts.httpNumber80HTTP container port
cdn-manager.manager.maxmindDbVolumeStringName of PVC containing MaxMind GeoIP databases

Manager Resources

The chart supports both resource presets and explicit resource specifications:

KeyTypeDefaultDescription
cdn-manager.manager.resourcesPresetString`` (empty)Resource preset (see Resource Presets table). Ignored if cdn-manager.manager.resources is set.
cdn-manager.manager.resources.requests.cpuString300mCPU request
cdn-manager.manager.resources.requests.memoryString512MiMemory request
cdn-manager.manager.resources.limits.cpuString1CPU limit
cdn-manager.manager.resources.limits.memoryString4GiMemory limit

Note: For production workloads, explicitly set cdn-manager.manager.resources rather than using presets.

Manager Datastore

cdn-manager:
  manager:
    datastore:
      type: redis
      namespace: "cdn_manager_ds"
      default_ttl: ""
      compression: zstd
KeyTypeDefaultDescription
cdn-manager.manager.datastore.typeStringredisDatastore backend type
cdn-manager.manager.datastore.namespaceStringcdn_manager_dsRedis namespace for manager data
cdn-manager.manager.datastore.default_ttlString`` (empty)Default TTL for entries
cdn-manager.manager.datastore.compressionStringzstdCompression algorithm (none, zstd, etc.)

Manager Database

The manager keeps its own data, including the subnet map, in a manager database in the PostgreSQL cluster. The manager creates the database if it is absent.

cdn-manager:
  manager:
    database:
      name: manager
      host: acd-cluster-postgresql-rw
      port: 5432
      user: app
      provision:
        enabled: true
KeyTypeDefaultDescription
cdn-manager.manager.database.nameStringmanagerDatabase name
cdn-manager.manager.database.hostStringacd-cluster-postgresql-rwPostgreSQL read-write service
cdn-manager.manager.database.portNumber5432PostgreSQL port
cdn-manager.manager.database.userStringappRole the manager connects as
cdn-manager.manager.database.provision.enabledBooleantrueCreate the database when it is absent. Set to false when the manager uses an external database that you create yourself

Manager Discovery

cdn-manager:
  manager:
    discovery: []
    # Example:
    # - namespace: "other"
    #   hosts:
    #     - other-host1
    #     - other-host2
    #   pattern: "other-.*"
KeyTypeDescription
cdn-manager.manager.discoveryArrayArray of discovery host configurations. Each entry can specify hosts (list of hostnames), pattern (regex pattern), or both

Manager Tuning

cdn-manager:
  manager:
    tuning:
      enable_cache_control: true
      cache_control_max_age: "5m"
      cache_control_miss_max_age: ""
      subnet:
        mutation_lock:
          wait: ""
      postgres:
        retry_min: ""
        retry_max: ""
        acquire_timeout: ""
        pool_size: ""
KeyTypeDefaultDescription
cdn-manager.manager.tuning.enable_cache_controlBooleantrueEnable cache control headers in responses
cdn-manager.manager.tuning.cache_control_max_ageString5mMaximum age for cache control headers
cdn-manager.manager.tuning.cache_control_miss_max_ageString`` (empty)Maximum age for cache control headers on cache misses
cdn-manager.manager.tuning.subnet.mutation_lock.waitString`` (empty)How long a subnet change waits for another to finish before reporting 503. Empty uses the default of 2s
cdn-manager.manager.tuning.postgres.retry_minString`` (empty)First pause before retrying a failed connection to the manager’s database. Each pause doubles, up to retry_max. Empty uses the default of 100ms
cdn-manager.manager.tuning.postgres.retry_maxString`` (empty)Longest pause between those retries. Empty uses the default of 5s
cdn-manager.manager.tuning.postgres.acquire_timeoutString`` (empty)How long a request waits for a database connection before reporting 503. Empty uses the default of 2s
cdn-manager.manager.tuning.postgres.pool_sizeString`` (empty)Most database connections each manager pod opens. Raise it if requests report 503 while waiting for a database connection. Empty uses the default of 5

Manager Container Arguments

cdn-manager:
  manager:
    args:
      - --config-file=/etc/manager/config.toml
      - http-server

Gateway Configuration

NGinx Gateway settings:

KeyTypeDefaultDescription
cdn-manager.gateway.replicaCountNumber1Number of gateway replicas
cdn-manager.gateway.resources.requests.cpuString100mCPU request
cdn-manager.gateway.resources.requests.memoryString128MiMemory request
cdn-manager.gateway.resources.limits.cpuString150mCPU limit
cdn-manager.gateway.resources.limits.memoryString192MiMemory limit
cdn-manager.gateway.service.typeStringClusterIPService type

MIB Frontend Configuration

Web-based configuration GUI settings:

KeyTypeDefaultDescription
cdn-manager.mibFrontend.enabledBooleantrueEnable the frontend GUI
cdn-manager.mibFrontend.resourcesPresetStringnanoResource preset
cdn-manager.mibFrontend.autoscaling.hpa.enabledBooleantrueEnable HPA
cdn-manager.mibFrontend.autoscaling.hpa.minReplicasNumber2Minimum replicas
cdn-manager.mibFrontend.autoscaling.hpa.maxReplicasNumber4Maximum replicas

Confd Configuration

Confd settings for configuration management:

KeyTypeDefaultDescription
confd.enabledBooleantrueEnable Confd
confd.service.ports.internalNumber15000Internal service port

VictoriaMetrics Configuration

Time-series database for metrics:

KeyTypeDefaultDescription
acd-metrics.enabledBooleantrueEnable metrics components
acd-metrics.victoria-metrics-single.enabledBooleantrueEnable VictoriaMetrics
acd-metrics.grafana.enabledBooleantrueEnable Grafana
acd-metrics.telegraf.enabledBooleantrueEnable Telegraf
acd-metrics.prometheus.enabledBooleantrueEnable Prometheus metrics

Ingress Configuration

Traffic exposure settings:

KeyTypeDefaultDescription
cdn-manager.ingress.enabledBooleantrueEnable ingress record generation
cdn-manager.ingress.pathTypeStringPrefixIngress path type
cdn-manager.ingress.hostnameString`` (empty)Primary hostname (defaults to manager.local via global.hosts)
cdn-manager.ingress.pathString/apiDefault path for ingress
cdn-manager.ingress.tlsBooleanfalseEnable TLS configuration
cdn-manager.ingress.selfSignedBooleanfalseGenerate self-signed certificate via Helm
cdn-manager.ingress.secretsArrayCustom TLS certificate secrets

Ingress Extra Paths

The chart includes default extra paths for Confd and GeoIP:

cdn-manager:
  ingress:
    extraPaths:
      - path: /confd
        pathType: Prefix
        backend:
          service:
            name: cdn-platform-cdn-manager-gateway
            port:
              name: http
      - path: /geoip
        pathType: Prefix
        backend:
          service:
            name: cdn-platform-cdn-manager-gateway
            port:
              name: http

TLS Certificate Secrets

Note: With deploy, put the certificate and key in /etc/acd/deploy/certs/ instead: deploy creates the secret and wires it to every ingress. See TLS Certificates. The settings below are for reference.

For production TLS certificates:

cdn-manager:
  ingress:
    secrets:
      - name: manager.local-tls
        key: |-
          -----BEGIN RSA PRIVATE KEY-----
          ...
          -----END RSA PRIVATE KEY-----
        certificate: |-
          -----BEGIN CERTIFICATE-----
          ...
          -----END CERTIFICATE-----
    tls: true

Resource Configuration

Resource Presets

Predefined resource configurations for common deployment sizes:

PresetRequest CPURequest MemoryLimit CPULimit MemoryEphemeral Storage Limit
nano100m128Mi150m192Mi2Gi
micro250m256Mi375m384Mi2Gi
small500m512Mi750m768Mi2Gi
medium500m1024Mi750m1536Mi2Gi
large1000m2048Mi1500m3072Mi2Gi
xlarge1000m3072Mi3000m6144Mi2Gi
2xlarge1000m3072Mi6000m12288Mi2Gi

Note: Limits are calculated as requests plus 50% (except for xlarge/2xlarge and ephemeral-storage).

Custom Resources

Override preset with custom values:

cdn-manager:
  manager:
    resources:
      requests:
        cpu: "300m"
        memory: "512Mi"
      limits:
        cpu: "1"
        memory: "1Gi"

Note:

  • CPU values use millicores (1000m = 1 core)
  • Memory values use binary SI units (1024Mi = 1GiB)
  • Requests represent minimum guaranteed resources
  • Limits represent maximum consumable resources

Capacity Planning

When sizing resources:

  • Requests determine scheduling (node must have available capacity)
  • Limits prevent resource starvation
  • Maintain 20-30% cluster headroom for scaling
  • Total capacity = sum of all requests × replica count + headroom

Security Contexts

Pod Security Context

cdn-manager:
  manager:
    podSecurityContext:
      enabled: true
      fsGroup: 1001
      fsGroupChangePolicy: Always
      sysctls: []
      supplementalGroups: []

Container Security Context

cdn-manager:
  manager:
    containerSecurityContext:
      enabled: true
      runAsUser: 1001
      runAsGroup: 1001
      runAsNonRoot: true
      readOnlyRootFilesystem: true
      privileged: false
      allowPrivilegeEscalation: false
      capabilities:
        drop: ["ALL"]
      seccompProfile:
        type: "RuntimeDefault"

Health Probes

Probe Types

ProbePurposeFailure Action
startupProbeInitial startup verificationContainer restart
readinessProbeTraffic readiness checkRemove from load balancer
livenessProbeHealth monitoringContainer restart

Default Probe Configuration

Liveness Probe

cdn-manager:
  manager:
    livenessProbe:
      enabled: true
      initialDelaySeconds: 5
      periodSeconds: 30
      timeoutSeconds: 10
      failureThreshold: 5
      successThreshold: 1
      httpGet:
        path: /api/v1/health/alive
        port: http

Readiness Probe

cdn-manager:
  manager:
    readinessProbe:
      enabled: true
      initialDelaySeconds: 5
      periodSeconds: 10
      timeoutSeconds: 7
      failureThreshold: 3
      successThreshold: 1
      httpGet:
        path: /api/v1/health/ready
        port: http

Startup Probe

cdn-manager:
  manager:
    startupProbe:
      enabled: true
      initialDelaySeconds: 0
      periodSeconds: 5
      timeoutSeconds: 3
      failureThreshold: 10
      successThreshold: 1
      httpGet:
        path: /api/v1/health/alive
        port: http

Autoscaling Configuration

Horizontal Pod Autoscaler (HPA)

cdn-manager:
  manager:
    autoscaling:
      hpa:
        enabled: true
        minReplicas: 3
        maxReplicas: 8
        targetCPU: 50
        targetMemory: 80
KeyTypeDefaultDescription
cdn-manager.manager.autoscaling.hpa.enabledBooleantrueEnable HPA
cdn-manager.manager.autoscaling.hpa.minReplicasNumber3Minimum number of replicas
cdn-manager.manager.autoscaling.hpa.maxReplicasNumber8Maximum number of replicas
cdn-manager.manager.autoscaling.hpa.targetCPUNumber50Target CPU utilization percentage
cdn-manager.manager.autoscaling.hpa.targetMemoryNumber80Target Memory utilization percentage

Network Policy

cdn-manager:
  networkPolicy:
    enabled: true
    allowExternal: true
    allowExternalEgress: true
    addExternalClientAccess: true
KeyTypeDefaultDescription
cdn-manager.networkPolicy.enabledBooleantrueEnable NetworkPolicy
cdn-manager.networkPolicy.allowExternalBooleantrueAllow connections from any source (don’t require pod label)
cdn-manager.networkPolicy.allowExternalEgressBooleantrueAllow pod to access any range of port and destinations
cdn-manager.networkPolicy.addExternalClientAccessBooleantrueAllow access from pods with client label set to “true”

Pod Affinity and Anti-Affinity

cdn-manager:
  manager:
    podAffinityPreset: ""
    podAntiAffinityPreset: soft
    nodeAffinityPreset:
      type: ""
      key: ""
      values: []
    affinity: {}
KeyTypeDefaultDescription
cdn-manager.manager.podAffinityPresetString`` (empty)Pod affinity preset (soft or hard). Ignored if affinity is set
cdn-manager.manager.podAntiAffinityPresetStringsoftPod anti-affinity preset (soft or hard). Ignored if affinity is set
cdn-manager.manager.nodeAffinityPreset.typeString`` (empty)Node affinity preset type (soft or hard)
cdn-manager.manager.affinityObject{}Custom affinity rules (overrides presets)

Service Configuration

cdn-manager:
  service:
    type: ClusterIP
    ports:
      http: 80
    annotations:
      service.kubernetes.io/topology-mode: Auto
    externalTrafficPolicy: Cluster
    sessionAffinity: None
KeyTypeDefaultDescription
service.typeStringClusterIPService type
service.ports.httpNumber80HTTP service port
service.annotationsObjectservice.kubernetes.io/topology-mode: AutoService annotations
service.externalTrafficPolicyStringClusterExternal traffic policy

Persistence Configuration

Warning: Set volume sizes before the first deploy. Helm cannot change the size of a persistent volume claim that already exists, so changing a size or persistence setting in values.override.yaml on an installed cluster does not resize the volume, and can make the upgrade fail. This applies to every component with storage, such as Kafka, VictoriaMetrics, PostgreSQL and Redis. Raising a retention setting also uses more disk, so check the volume has room first.

cdn-manager:
  persistence:
    enabled: false
    mountPath: /agiletv/manager/data
    storageClass: ""
    accessModes:
      - ReadWriteOnce
    size: 8Gi
KeyTypeDefaultDescription
persistence.enabledBooleanfalseEnable persistence using PVC
persistence.mountPathString/agiletv/manager/dataMount path
persistence.storageClassString`` (empty)Storage class (uses cluster default if empty)
persistence.sizeString8GiSize of data volume

RBAC and Service Account

cdn-manager:
  rbac:
    create: false
    rules: []

  serviceAccount:
    create: true
    name: ""
    automountServiceAccountToken: true
    annotations: {}

Metrics

cdn-manager:
  metrics:
    enabled: false
    serviceMonitor:
      enabled: false
      namespace: ""
      annotations: {}
      labels: {}
      interval: ""
      scrapeTimeout: ""
KeyTypeDefaultDescription
metrics.enabledBooleanfalseEnable Prometheus metrics export
metrics.serviceMonitor.enabledBooleanfalseCreate Prometheus Operator ServiceMonitor

Next Steps

After configuration:

  1. Installation Guide - Deploy with your configuration
  2. Operations Guide - Day-to-day management
  3. Performance Tuning Guide - Optimize system performance
  4. Architecture Guide - Understand component relationships

7 - Performance Tuning Guide

Optimization tips for improving CDN Manager performance

Overview

This guide provides performance tuning recommendations for the AgileTV CDN Manager (ESB3027). While the default configuration is suitable for most deployments, certain environments may benefit from additional optimizations.

Network Topology Optimization

Topology Aware Hints

The CDN Manager uses Kubernetes Topology Aware Hints to prefer routing pods in the same zone as the source of network traffic. This reduces cross-zone latency and improves overall system responsiveness.

How It Works

When nodes are labeled with topology zones, Kubernetes automatically routes traffic to pods in the same zone when possible. This is particularly beneficial for:

  • Low-latency requirements: Keeps traffic local to reduce round-trip time
  • Cost optimization: Reduces cross-zone data transfer costs in cloud environments
  • Load distribution: Prevents hotspots by distributing load across zones

Configuring Availability Zones

Each node must have zone and region labels applied for Topology Aware Hints to function. Set them with node_labels on each machine’s line in /etc/acd/deploy/inventory.ini, then run deploy, which applies them:

[server]
manager-1 ansible_host=192.0.2.11 node_labels='{"topology.kubernetes.io/zone": "us-east-1a", "topology.kubernetes.io/region": "us-east-1"}'

Adjust the zone and region values to match your deployment geography. Labels set this way are reapplied on every deploy, so they are kept with the rest of your configuration. See Inventory.

Verify Topology Configuration

Verify labels are applied:

kubectl get nodes --show-labels | grep topology.kubernetes.io

Verify EndpointSlices are being generated with hints:

kubectl get endpointslices

Requirements for Topology Aware Hints

For Topology Aware Hints to activate:

  • Minimum Nodes: At least one node must be labeled with each zone referenced by endpoints
  • Symmetry: The control plane checks for sufficient CPU capacity across zones to balance traffic
  • Zone Coverage: All zones with endpoints should have at least one ready node

Integration with Pod Anti-Affinity

Topology labels complement the pod anti-affinity rules already configured in the Helm chart:

  • Pod Anti-Affinity: Handles pod-to-node placement to ensure high availability
  • Topology Aware Hints: Handles service-to-pod traffic routing to keep requests within the same zone

Together, these features optimize both placement and routing for improved performance.

Fallback Behavior

If zone labels are not configured, the system falls back to random load-balancing across all available pods. This is functionally correct but may result in:

  • Increased cross-zone traffic
  • Higher latency for some requests
  • Less predictable performance characteristics

Kernel Network Tuning (sysctl)

deploy tunes the kernel’s network settings on every machine for you, for high connection rates and large numbers of concurrent connections. There is nothing to configure: it applies these values on every run, whatever the firewall settings:

ParameterValuePurpose
net.core.somaxconn1024Maximum socket listen backlog. Increases pending connection queue size.
net.core.netdev_max_backlog2048Maximum packets queued at network device level. Helps handle burst traffic.
net.ipv4.tcp_max_syn_backlog2048Maximum SYN requests queued. Improves handling of connection floods.
net.ipv4.ip_local_port_range10240 65535Range of local ports for outbound connections. Expands available ephemeral ports.
net.ipv4.tcp_tw_reuse1Allows reusing TIME_WAIT sockets. Reduces port exhaustion under high load.
net.core.rmem_max8388608Maximum receive socket buffer size (8MB). Improves high-bandwidth transfers.
net.core.wmem_max8388608Maximum send socket buffer size (8MB). Improves high-bandwidth transfers.

Connection tracking (net.netfilter.nf_conntrack_*) is not in the list: k3s sets those values itself every time it starts, so values set elsewhere do not last.

Important: Do not set these parameters yourself, in /etc/sysctl.d/, a tuned profile or anywhere else. After changing a machine’s network, deploy checks that each one holds the value above, and stops with [ACD-VERIFY-SYSCTL] before installing anything if another value is in place. If an earlier installation left a file such as /etc/sysctl.d/99-cdn-manager.conf, remove these parameters from it.

To check the values on a machine:

sysctl net.core.somaxconn net.core.netdev_max_backlog net.ipv4.tcp_max_syn_backlog \
    net.ipv4.ip_local_port_range net.ipv4.tcp_tw_reuse net.core.rmem_max net.core.wmem_max

Monitoring Impact

After applying these settings, monitor:

  • Connection establishment rates
  • TIME_WAIT socket count: netstat -n | grep TIME_WAIT | wc -l
  • Connection tracking table usage: cat /proc/sys/net/netfilter/nf_conntrack_count
  • Network buffer utilization via Grafana dashboards

Resource Configuration

Horizontal Pod Autoscaler (HPA)

The default HPA configuration is tuned for production workloads. For environments with variable load, consider adjusting the scale metrics:

ComponentDefault Scale MetricsTuning Consideration
Core ManagerCPU 50%, Memory 80%Lower CPU threshold for faster scale-out
NGinx GatewayCPU 75%, Memory 80%Increase for cost optimization
MIB FrontendCPU 75%, Memory 90%Adjust based on operator concurrency

For detailed HPA configuration, see the Architecture Guide.

Resource Requests and Limits

Ensure resource requests and limits are appropriately sized for your workload. Under-provisioned resources can cause:

  • Pod evictions during high load
  • Increased latency due to CPU throttling
  • Slow scaling responses

Refer to the Configuration Guide for preset configurations and planning guidance.

Database Optimization

PostgreSQL

The PostgreSQL cluster is managed by the Cloudnative PG operator. For improved performance:

  • Connection Pooling: The application uses connection pooling by default
  • Replica Usage: Read queries can be offloaded to replicas for read-heavy workloads
  • Backup Scheduling: Schedule backups during low-traffic periods to minimize I/O impact

Redis

Redis provides in-memory caching for sessions and ephemeral state:

  • Memory Allocation: Ensure sufficient memory for cache hit rates
  • Persistence: RDB snapshots are enabled; adjust frequency based on durability needs

Kafka

Kafka handles event streaming for selection input and metrics:

  • Partition Count: Default partitions are sized for typical workloads
  • Replication Factor: Production deployments use 3 replicas for fault tolerance
  • Consumer Groups: The Selection Input Worker is limited to one consumer per partition

Monitoring Performance

Key Metrics to Watch

Monitor the following metrics for performance insights:

  • API Response Time: Track via Grafana dashboards
  • Pod CPU/Memory Usage: Identify resource bottlenecks
  • Kafka Lag: Monitor consumer lag for selection input processing
  • Database Connections: Watch for connection pool exhaustion

Grafana Dashboards

Pre-built dashboards are available at https://<manager-host>/grafana:

  • System Health: Overall cluster and application health
  • CDN Metrics: Routing and usage statistics
  • Resource Utilization: CPU, memory, and network usage per component

Troubleshooting Performance Issues

High Latency

  1. Check pod distribution across nodes: kubectl get pods -o wide
  2. Verify topology labels are applied: kubectl get nodes --show-labels
  3. Review network latency between nodes
  4. Check for resource contention: kubectl top pods

Slow Scaling

  1. Verify HPA is enabled: kubectl get hpa
  2. Check cluster capacity for scheduling new pods
  3. Review HPA metrics: kubectl describe hpa cdn-platform-cdn-manager

Database Performance

  1. Check PostgreSQL cluster status: kubectl get pods -l app=postgresql
  2. Review slow query logs (if enabled)
  3. Monitor connection pool usage

Next Steps

After reviewing performance tuning:

  1. Architecture Guide - Understand component interactions
  2. Configuration Guide - Detailed configuration options
  3. Metrics & Monitoring Guide - Comprehensive monitoring setup
  4. Troubleshooting Guide - Resolve performance issues

8 - Operations Guide

Day-to-day operational procedures and maintenance tasks

Overview

This guide covers day-to-day operational procedures for managing the AgileTV CDN Manager (ESB3027). Topics include routine maintenance, backup procedures, log management, and common operational tasks.

Prerequisites

Before performing operations, ensure you have:

  • kubectl access to the cluster (available as root on every server)
  • Access to the first server, where deploy and its configuration in /etc/acd/deploy live
  • Appropriate RBAC permissions for administrative tasks

Cluster Access

There are two supported methods for accessing the Kubernetes cluster:

  1. SSH to a Server Node (Recommended for operations staff) - SSH into any Server node and run kubectl commands directly
  2. Remote kubectl - Install kubectl on your local machine and configure it to connect to the cluster remotely

The kubectl command-line tool is pre-configured on all Server nodes and can be used directly without additional setup:

# SSH to any Server node
ssh root@<server-ip>

# Run kubectl commands directly
kubectl get nodes
kubectl get pods

This method is recommended for day-to-day operations as it requires no local configuration and provides direct access to the cluster.

For routine status checks, logs, restarts and node maintenance, acd-admin on a server does the same by admin target name, without kubectl.

Method 2: Remote kubectl from Local Machine

To use kubectl from your local workstation or laptop:

Step 1: Install kubectl

Download and install kubectl for your operating system:

  • Official Documentation: Install kubectl
  • macOS (Homebrew): brew install kubectl
  • Linux: Download from the official Kubernetes release page
  • Windows: Download from the official Kubernetes release page

Step 2: Copy kubeconfig from Server Node

# Copy kubeconfig from any Server node
scp root@<server-ip>:/etc/rancher/k3s/k3s.yaml ~/.kube/config

Step 3: Update kubeconfig

Edit the kubeconfig file to point to the correct server address:

# Replace localhost with the actual server IP
# macOS/Linux:
sed -i '' 's/127.0.0.1/<server-ip>/g' ~/.kube/config  # macOS
sed -i 's/127.0.0.1/<server-ip>/g' ~/.kube/config    # Linux

# Or manually edit ~/.kube/config and change:
# server: https://127.0.0.1:6443
# to:
# server: https://<server-ip>:6443

Step 4: Verify connectivity

kubectl get nodes

Managing Multiple Clusters

If you manage multiple Kubernetes clusters from the same machine, you can maintain multiple kubeconfig files:

# Set KUBECONFIG environment variable to include multiple config files
export KUBECONFIG=~/.kube/config-prod:~/.kube/config-lab

# View all contexts
kubectl config get-contexts

# Switch between clusters
kubectl config use-context <context-name>

# View current context
kubectl config current-context

For more information, see the official Kubernetes documentation: Organizing Cluster Access

Helm Commands

Helm releases are managed cluster-wide. Besides cdn-platform, the cluster runs six releases of its own: see Configuration.

# List all releases
helm list -A

# View release history
helm history cdn-platform

# Get deployed values
helm get values cdn-platform -o yaml

# Get deployed manifest
helm get manifest cdn-platform

These commands are read-only. Change the release only through deploy: see Changing Application Settings.

Backup Procedures

PostgreSQL Backup

PostgreSQL is managed by the Cloudnative PG operator, which provides continuous backup capabilities.

A backup of the cluster covers all of its databases: manager (the manager’s own data, including the subnet map), app (Grafana), zitadel and confsej. The subnet map is not in Redis, so back up PostgreSQL to keep it.

# Check backup status
kubectl get backup

# Create manual backup
kubectl apply -f - <<EOF
apiVersion: postgresql.cnpg.io/v1
kind: Backup
metadata:
  name: manual-backup-$(date +%Y%m%d-%H%M%S)
spec:
  cluster:
    name: acd-cluster-postgresql
EOF

# List available backups
kubectl get backup -o wide

# Restore from backup (requires downtime)
# See Upgrade Guide for restore procedures

Longhorn Volume Backups

Longhorn provides snapshot and backup capabilities for persistent volumes:

# List all volumes
kubectl get volumes -n longhorn-system

# Create snapshot via Longhorn UI
# Port-forward to Longhorn UI (do not expose via ingress)
kubectl port-forward -n longhorn-system svc/longhorn-frontend 8080:80

# Access: http://localhost:8080
# WARNING: Longhorn UI grants access to sensitive storage information
# and should never be exposed through the ingress controller

Accessing Internal Services

For debugging and troubleshooting, you may need direct access to internal services.

PostgreSQL

PostgreSQL is managed by the Cloudnative PG operator. Connection details are stored in the acd-cluster-postgresql-app Secret:

# View connection details
kubectl describe secret acd-cluster-postgresql-app

# Extract individual fields
PG_HOST=$(kubectl get secret acd-cluster-postgresql-app -o jsonpath='{.data.host}' | base64 -d)
PG_USER=$(kubectl get secret acd-cluster-postgresql-app -o jsonpath='{.data.username}' | base64 -d)
PG_PASS=$(kubectl get secret acd-cluster-postgresql-app -o jsonpath='{.data.password}' | base64 -d)
PG_DB=$(kubectl get secret acd-cluster-postgresql-app -o jsonpath='{.data.dbname}' | base64 -d)

# Connect via psql
kubectl exec -it acd-cluster-postgresql-1 -- psql -U $PG_USER -d $PG_DB

Secret fields: The CNPG operator populates the following fields: username, password, host, port, dbname, uri, jdbc-uri, fqdn-uri, fqdn-jdbc-uri, pgpass.

Redis

Redis runs on port 6379 with no authentication:

# Connect via redis-cli
kubectl exec -it cdn-platform-redis-master-0 -- redis-cli

# Or connect from another pod
kubectl run redis-test --rm -it --image=redis -- redis-cli -h cdn-platform-redis-master

Kafka

Kafka’s bootstrap listens on the default port, 9092, on every machine. See Kafka Ports. To list the topics from a host with the Kafka command-line tools:

kafka-topics.sh --bootstrap-server <node-ip>:9092 --list

The selection_input topic is pre-configured for selection input events, with a single replica by design: see Data Replication.

Kubernetes Port Forwarding

For accessing internal Kubernetes services that are not exposed via ingress or services, use kubectl port-forward to create a secure tunnel from your local machine to the service.

Basic Port Forwarding

# Forward local port to a service
kubectl port-forward -n <namespace> svc/<service-name> <local-port>:<service-port>

# Example: Forward local port 8080 to Grafana (port 3000)
kubectl port-forward -n default svc/grafana 8080:3000

Note: “Local” refers to the machine where you run kubectl. This can be:

  • A Server node in the cluster (common for administrative tasks)
  • A remote machine with kubectl configured to access the cluster

Accessing the Forwarded Service

Once the port-forward is established, access the service at http://localhost:<local-port> from the machine where you ran kubectl port-forward.

If running on a Server node: kubectl port-forward listens only on the node’s own loopback address, 127.0.0.1, so the node’s IP address does not reach it from your workstation. Carry the port over SSH instead, from your workstation:

# From your workstation: forward <local-port> to the same port on the Server node
ssh -L <local-port>:127.0.0.1:<local-port> root@<server-node>

Keep that SSH session open, and open http://localhost:<local-port> on your workstation.

For simplicity, consider running port-forward from your local machine (if kubectl is configured for remote cluster access) rather than from a Server node.

Background Port Forwarding

To run port-forward in the background:

kubectl port-forward -n <namespace> svc/<service-name> <local-port>:<service-port> &

Security Considerations

Port forwarding is recommended for:

  • Administrative interfaces (e.g., Longhorn UI) that should not be publicly exposed
  • Debugging and troubleshooting internal services
  • Temporary access to services without modifying ingress configuration

The port-forward tunnel remains active only while the kubectl port-forward command is running. Press Ctrl+C to terminate the tunnel.

Example: The Longhorn storage UI is intentionally not exposed via ingress due to security risks. Access it via port-forward:

kubectl port-forward -n longhorn-system svc/longhorn-frontend 8080:80

Then navigate to http://localhost:8080 in your browser.

Longhorn Storage

Longhorn is a distributed block storage system for Kubernetes that provides persistent volumes for stateful applications such as PostgreSQL and Kafka.

Architecture

Longhorn deploys controller and replica engines on each node, forming a distributed storage system. When a volume is created, Longhorn replicates data across multiple nodes to ensure durability even in the event of node failures.

Storage Protocols:

  • iSCSI: Used for standard Read-Write-Once (RWO) volumes
  • NFS: Used for Read-Write-Many (RWX) volumes that can be mounted by multiple pods simultaneously

Configuration

Each volume keeps three replicas on different nodes, the Longhorn default. The three Kafka data volumes are the exception, with one replica each, because Kafka copies its topics across its brokers itself. Longhorn tries to keep one replica on the node where the volume’s pod runs, but a pod can run on a node with no replica of its volume.

When a node is drained, its replicas stop. Volumes with three replicas stay degraded while the node is away; on a cluster with a spare node, Longhorn rebuilds the missing replica there after about 10 minutes. After a short drain, the replicas catch up within about a minute of the node’s return. Before the drain of a node that holds a Kafka volume’s only replica finishes, Longhorn copies that replica to another node.

Capacity Planning

Longhorn storage requires an additional 30% capacity headroom for internal operations and scaling. If less than 30% of the total partition capacity is available, Longhorn may mark volumes as “full” and prevent further writes.

For detailed storage requirements and disk partitioning guidance, see the System Requirements Guide.

Configuration Backup

Every run of deploy backs up each machine’s configuration, and the configuration folder /etc/acd/deploy, before it changes anything. It does not back up the data stored in the cluster: use the PostgreSQL procedure above for the databases. To take a backup without changing anything:

/mnt/esb3027/deploy backup

The backup leaves out your TLS certificate and key and the MaxMind databases: keep your own copies. A backup of a running cluster holds an etcd snapshot with every Kubernetes Secret, and the cluster token: protect and prune backups as secret material. See Backups.

Backup Schedule Recommendations

ComponentFrequencyRetention
PostgreSQLDaily30 days
Longhorn SnapshotsBefore changes7 days
ConfigurationBefore each changeIndefinite

Updating MaxMind GeoIP Databases

The MaxMind GeoIP databases (GeoIP2-City, GeoLite2-ASN, GeoIP2-Anonymous-IP) are used for GeoIP-based routing and validation features. Update them periodically to keep IP geolocation accurate.

To update them, copy the new .mmdb files into /etc/acd/deploy/maxmind/, replacing the old ones, and run deploy. It loads the databases only when they have changed. See MaxMind GeoIP Databases.

Update Frequency Recommendations

DatabaseRecommended Update Frequency
GeoIP2-CityWeekly or monthly
GeoLite2-ASNMonthly
GeoIP2-Anonymous-IPWeekly or monthly

MaxMind releases database updates on a regular schedule. Subscribe to MaxMind notifications to stay informed of new releases.

Log Management

acd-admin logs <target> shows the logs of every pod of a component at once: see acd-admin logs.

Application Logs

# View manager logs
kubectl logs -l app.kubernetes.io/component=manager

# Follow logs in real-time
kubectl logs -l app.kubernetes.io/component=manager -f

# View logs from specific pod
kubectl logs <pod-name>

# View previous instance logs (after crash)
kubectl logs <pod-name> -p

# View logs with timestamps
kubectl logs <pod-name> --timestamps

# View logs from all containers in pod
kubectl logs <pod-name> --all-containers

Component-Specific Logs

# Zitadel logs
kubectl logs -l app.kubernetes.io/name=zitadel

# Gateway logs
kubectl logs -l app.kubernetes.io/component=gateway

# Confd logs
kubectl logs -l app.kubernetes.io/component=confd

# MIB Frontend logs
kubectl logs -l app.kubernetes.io/component=mib-frontend

# PostgreSQL logs
kubectl logs -l app.kubernetes.io/name=postgresql

# Kafka logs
kubectl logs -l app.kubernetes.io/name=kafka

# Redis logs
kubectl logs -l app.kubernetes.io/name=redis

Log Aggregation

Logs are collected by Telegraf and sent to VictoriaMetrics:

# Access Grafana for log visualization
# https://<manager-host>/grafana

# Query logs via Grafana Explore
# Select VictoriaMetrics datasource and use log queries

Log Rotation

Container logs are automatically rotated by Kubernetes:

  • Default max size: 10MB per container
  • Default max files: 5 rotated files
  • Total per pod: ~50MB maximum

Scaling Operations

Manual Scaling

Note: If HPA (Horizontal Pod Autoscaler) is enabled for a deployment, manual scaling changes will be overridden by the HPA. To manually scale, you must first disable the HPA.

# Check if HPA is enabled
kubectl get hpa

# Disable HPA before manual scaling
kubectl patch hpa cdn-platform-cdn-manager -p '{"spec": {"minReplicas": null, "maxReplicas": null}}'

# Or delete the HPA entirely
kubectl delete hpa cdn-platform-cdn-manager

# Scale manager replicas
kubectl scale deployment cdn-platform-cdn-manager --replicas=3

# Scale gateway replicas
kubectl scale deployment cdn-platform-cdn-manager-gateway --replicas=2

# Scale MIB frontend replicas
kubectl scale deployment cdn-platform-cdn-manager-mib-frontend --replicas=2

HPA Configuration

# View HPA status
kubectl get hpa

# Describe HPA details
kubectl describe hpa cdn-platform-cdn-manager

# Edit HPA configuration
kubectl edit hpa cdn-platform-cdn-manager

Configuration Updates

Changing Application Settings

Application settings are changed through deploy, never with helm directly: the next deploy would undo the change.

# Write only the settings you want to change
vi /etc/acd/deploy/values.override.yaml

# Apply them
/mnt/esb3027/deploy

# Check every component
acd-admin status

See Application Settings.

Rolling Back Changes

To undo a change, revert it in values.override.yaml or group_vars/all.yaml and run deploy again. To see the release’s revisions:

helm history cdn-platform

Certificate Management

Checking Certificate Expiration

# Check TLS secret expiration
kubectl get secret <tls-secret> -o jsonpath='{.data.tls\.crt}' | base64 -d | openssl x509 -noout -dates

# Check via Grafana dashboard
# Certificate expiration metrics are available in Grafana

Renewing Certificates

Replace the certificate and key in /etc/acd/deploy/certs/ and run deploy. See TLS Certificates.

Health Checks

acd-admin status shows the health of every component and node in one view: see acd-admin status.

Component Health

# Check all pods
kubectl get pods

# Check specific component
kubectl get pods -l app.kubernetes.io/component=manager

# Check persistent volumes
kubectl get pvc

# Check cluster status
kubectl get nodes

# Check ingress
kubectl get ingress

API Health Endpoints

# Liveness check
curl -k https://<manager-host>/api/v1/health/alive

# Readiness check
curl -k https://<manager-host>/api/v1/health/ready

Database Health

# PostgreSQL cluster status
kubectl get clusters -n default

# Check PostgreSQL pods
kubectl get pods -l app.kubernetes.io/name=postgresql

# Kafka cluster status
kubectl get pods -l app.kubernetes.io/name=kafka

# Redis status
kubectl get pods -l app.kubernetes.io/name=redis

Maintenance Windows

Planned Maintenance

Before performing maintenance:

  1. Notify users of potential service impact
  2. Verify backups are current
  3. Document the maintenance procedure
  4. Prepare rollback plan

Node Maintenance

# Take the node out of service: cordon it and evict its pods
acd-admin drain <node-name>

# Perform maintenance

# Return the node to service
acd-admin uncordon <node-name>

Before you drain or restart anything, read Safety: drain one server at a time, check which broker holds selection_input, and wait for every admin target ok and every storage volume healthy before the next drain. Cordoning or draining the node of the PostgreSQL primary switches the primary to another instance.

A drained node keeps running its DaemonSet pods (host metrics, ingress and storage) and any acd-admin session. Everything else moves to other nodes. Longhorn’s node components keep running, but its storage replicas on the node stop: see Longhorn Storage for what that means for volumes.

acd-admin drain refuses a node that runs a pod that no controller recreates until you add --force. Ctrl-C stops only the waiting, and pods still waiting to move are not retried until drain runs again, which carries on. The node stays drained through reboots and deploy runs until acd-admin uncordon; kubectl uncordon returns the node to service but leaves its drained label. See acd-admin drain and Drained Nodes.

Uncordoning a node does not move pods back to it: it takes only new pods. After a round of drains, the last node returned can sit almost empty while the others carry its load. The descheduler gradually moves pods without persistent storage from busy nodes to underused ones. To spread a target’s pods sooner, restart it with acd-admin restart <target>, starting with the targets that have the most pods.

Cluster Upgrades

See the Upgrade Guide for cluster upgrade procedures.

Troubleshooting Quick Reference

Common Commands

# Describe problematic pod
kubectl describe pod <pod-name>

# View pod events
kubectl get events --sort-by='.lastTimestamp'

# Check resource usage
kubectl top pods
kubectl top nodes

# Exec into container
kubectl exec -it <pod-name> -- /bin/sh

# Check network policies
kubectl get networkpolicies

# Check service endpoints
kubectl get endpoints

Restarting Components

acd-admin restart <target> restarts an admin target the safe way for its kind and waits for it, including kafka and postgresql through their operators: see acd-admin restart.

Important: Never restart Kafka or PostgreSQL with the kubectl commands below. Their operators, Strimzi and CloudNativePG, own their pods: a kubectl restart or pod deletion bypasses the broker-by-broker roll and the PostgreSQL switchover. Use acd-admin restart kafka and acd-admin restart postgresql.

For other workloads:

# Restart deployment
kubectl rollout restart deployment/<deployment-name>

# Restart statefulset
kubectl rollout restart statefulset/<statefulset-name>

# Delete pod (auto-recreated)
kubectl delete pod <pod-name>

Security Operations

Rotating Service Account Tokens

# Delete service account secret (auto-regenerated)
kubectl delete secret <service-account-token-secret>

# Tokens are automatically regenerated

Updating RBAC Permissions

# View current roles
kubectl get roles
kubectl get clusterroles

# View role bindings
kubectl get rolebindings
kubectl get clusterrolebindings

# Edit role
kubectl edit role <role-name>

Disaster Recovery

Pod Recovery

Pods are automatically recreated if they fail:

# Check pod status
kubectl get pods

# If pod is stuck in Terminating
kubectl delete pod <pod-name> --force --grace-period=0

# If pod is stuck in Pending, check resources
kubectl describe pod <pod-name>
kubectl get events --sort-by='.lastTimestamp'

Node Failure Recovery

When a node fails:

  1. Automatic: Pods are rescheduled on healthy nodes (after timeout)
  2. Manual: Force delete stuck pods
# Force delete pods on failed node
kubectl delete pod --all --force --grace-period=0 \
  --field-selector spec.nodeName=<failed-node>

Data Recovery

For data recovery scenarios, refer to:

  • PostgreSQL: Cloudnative PG backup/restore procedures
  • Longhorn: Volume snapshot restoration
  • Kafka: Topics other than selection_input survive the loss of one broker. selection_input has a single replica: see Data Replication

Routine Maintenance Checklist

Daily

  • Review Grafana dashboards for anomalies
  • Check alert notifications
  • Verify backup completion

Weekly

  • Review pod restart counts
  • Check certificate expiration dates
  • Review log storage usage
  • Verify HPA is functioning correctly

Monthly

  • Test backup restoration procedure
  • Review and rotate credentials if needed
  • Update documentation if configuration changed
  • Review resource utilization trends

Next Steps

After mastering operations:

  1. Troubleshooting Guide - Deep dive into problem resolution
  2. Performance Tuning Guide - Optimize system performance
  3. Metrics & Monitoring Guide - Comprehensive monitoring setup
  4. API Guide - REST API reference and automation

9 - Metrics & Monitoring Guide

Monitoring architecture and metrics collection

Overview

The CDN Manager includes a comprehensive monitoring stack based on VictoriaMetrics for time-series data storage, Telegraf for metrics collection, and Grafana for visualization. This guide describes the monitoring architecture and how to access and use the monitoring capabilities.

GuideDescription
Grafana DashboardsUsing and customising the built-in and advanced Grafana dashboards
Grafana Authentication & RolesConfiguring Grafana authentication, roles, and permissions
Alerts & AlarmsConfiguring and managing alerts and alarms

Architecture

Components

ComponentPurpose
TelegrafMetrics collector running on each node, gathering system and application metrics
VictoriaMetrics AgentMetrics scraper and forwarder; scrapes Prometheus endpoints and forwards to VictoriaMetrics
VictoriaMetrics (Short-term)Time-series database for operational dashboards (30-90 day retention)
VictoriaMetrics (Long-term)Time-series database for billing and compliance (1+ year retention)
GrafanaVisualization and dashboard platform; deployed as two replicas for high availability
AlertmanagerAlert routing and notification management

Metrics Flow

The following diagram illustrates how metrics flow through the monitoring stack:

flowchart TB
    subgraph External["External Sources"]
        Streamers[Streamers/External Clients]
    end

    subgraph Cluster["Kubernetes Cluster"]
        Telegraf[Telegraf DaemonSet]

        subgraph Applications["Application Components"]
            Director[CDN Director]
            Kafka[Kafka]
            Redis[Redis]
            Manager[ACD Manager]
            Alertmanager[Alertmanager]
        end

        VMAgent[VictoriaMetrics Agent]

        subgraph Storage["Storage"]
            VMShort[VictoriaMetrics<br/>Short-term]
            VMLong[VictoriaMetrics<br/>Long-term]
        end

        Grafana[Grafana<br/>2 replicas, HA]
        PostgreSQL[(PostgreSQL)]
        Zitadel[Zitadel]
    end

    Streamers -->|Push metrics| Telegraf
    Telegraf -->|remote_write| VMShort
    Telegraf -->|remote_write| VMLong

    Director -->|Scrape| VMAgent
    Kafka -->|Scrape| VMAgent
    Redis -->|Scrape| VMAgent
    Manager -->|Scrape| VMAgent
    Alertmanager -->|Scrape| VMAgent

    VMAgent -->|remote_write| VMShort
    VMAgent -->|remote_write| VMLong

    VMShort -->|Query| Grafana
    VMLong -->|Query| Grafana

    Grafana <-->|Shared state| PostgreSQL
    Grafana -->|OAuth2 / OIDC| Zitadel

Metrics Flow Summary:

  1. External metrics ingestion:

    • External clients (streamers) push metrics to Telegraf
    • Telegraf forwards metrics via remote_write to both VictoriaMetrics instances
  2. Internal metrics scraping:

    • VictoriaMetrics Agent scrapes Prometheus endpoints from:
      • CDN Director instances
      • Kafka cluster
      • Redis
      • ACD Manager components
      • Alertmanager
    • VMAgent forwards scraped metrics via remote_write to both VictoriaMetrics instances
  3. Data visualization:

    • Grafana queries both VictoriaMetrics databases depending on the dashboard requirements
    • Operational dashboards use short-term storage
    • Billing and compliance dashboards use long-term storage

Metrics Collection

Application Metrics

Applications expose metrics on Prometheus-compatible endpoints. VictoriaMetrics Agent (VMAgent) scrapes these endpoints and forwards metrics to VictoriaMetrics via remote_write.

System Metrics

Telegraf collects system-level metrics including:

  • CPU usage
  • Memory utilization
  • Disk I/O
  • Network statistics
  • Process metrics

Kubernetes Metrics

Cluster metrics are collected including:

  • Pod resource usage
  • Node status
  • Deployment status
  • Persistent volume usage

Metrics Retention

VictoriaMetrics is configured with default retention policies. For custom retention settings, modify the VictoriaMetrics configuration in your values.yaml:

acd-metrics:
  victoria-metrics-single:
    retentionPeriod: "3"  # Retention period in months

Troubleshooting

Metrics Not Appearing

If metrics are not appearing in Grafana:

  1. Check Telegraf pods:

    kubectl get pods -l app.kubernetes.io/component=telegraf
    
  2. Check Telegraf logs:

    kubectl logs -l app.kubernetes.io/component=telegraf
    
  3. Verify VictoriaMetrics is running:

    kubectl get pods -l app.kubernetes.io/component=victoria-metrics
    
  4. Check application metrics endpoints:

    kubectl exec <pod-name> -- curl localhost:8080/metrics
    

For dashboard and authentication issues, see the Grafana Dashboards and Grafana Authentication & Roles guides.

Next Steps

After setting up monitoring:

  1. Grafana Authentication & Roles - Configure SSO and permissions before accessing Grafana
  2. Grafana Dashboards - Explore and customise dashboards
  3. Alerts & Alarms - Set up alerting and notifications
  4. Operations Guide - Day-to-day operational procedures
  5. Troubleshooting Guide - Resolve monitoring issues
  6. API Guide - Access metrics via API

9.1 - Grafana Authentication & Roles

Configuring Grafana authentication, roles, and permissions via Zitadel

Overview

Grafana authentication is delegated entirely to Zitadel via OAuth2/OIDC. Local username/password login is not available to end users. When a user logs into Grafana, they are redirected to Zitadel to authenticate, and their Grafana role is automatically determined by the Zitadel project roles assigned to their account.

The OIDC integration between Grafana and Zitadel is configured automatically at install time — no manual Zitadel application registration is required.

How It Works

During installation, an init container runs before Grafana starts and:

  1. Authenticates with Zitadel using a machine-account service key.
  2. Registers a Grafana OIDC application in the Zitadel project (or re-uses an existing one if already registered).
  3. Writes the resulting client_id and client_secret into a Kubernetes Secret, which Grafana picks up on startup.

This means the Grafana OIDC application in Zitadel is managed automatically and does not need to be created or modified manually.

Role Mapping

Grafana roles are mapped from Zitadel project roles using the following rule:

Zitadel Project RoleGrafana Role
grafana_adminAdmin — full access, can manage users, datasources, and dashboards
(any other role, or no role)Viewer — read-only access to dashboards

Note: There is no Grafana Editor role mapped by default. All authenticated users who are not explicitly granted grafana_admin receive Viewer access. If you need an Editor tier, see Customising the Role Mapping.

The mapping is enforced on every login. If a user’s Zitadel role changes, the change takes effect the next time they log into Grafana.

Prerequisites

  • Zitadel is configured and accessible at https://<manager-host>
  • At least one Zitadel user account exists (see Next Steps — Add Users)
  • Grafana is accessed via the correct DNS hostname (see Accessing Grafana)

Accessing Grafana

Grafana is accessible at:

https://<manager-host>/grafana

Important: Grafana must be accessed using the main manager hostname, the first entry of manager_hostnames in the site settings. Accessing Grafana via an IP address or an alternative hostname will cause OAuth2 redirect URI mismatches and CORS errors, preventing login from completing successfully.

To log in:

  1. Navigate to https://<manager-host>/grafana
  2. Click “Login with Zitadel”
  3. Authenticate with your Zitadel account credentials

Granting Admin Access

By default, all Zitadel users who log into Grafana receive Viewer access. To grant a user Admin access, assign them the grafana_admin project role in Zitadel.

Step 1: Ensure the grafana_admin Role Exists

  1. Log into the Zitadel Console at https://<manager-host>/ui/console
  2. Navigate to Projects and open the ZITADEL project
  3. Click the Roles tab
  4. Check whether a role named grafana_admin already exists
  5. If it does not exist, click New Role and create it:
    • Key: grafana_admin
    • Display Name: Grafana Admin (or any label you prefer)
    • Click Save

Step 2: Assign the Role to a User

  1. In the Zitadel Console, navigate to Users and open the user you want to grant admin access to
  2. Click the Authorizations tab
  3. Click New Authorization
  4. Select the ZITADEL project
  5. Select the grafana_admin role
  6. Click Save

The user will have Grafana Admin access the next time they log in.

Revoking Admin Access

To demote a user back to Viewer, remove the grafana_admin authorization from their account:

  1. In the Zitadel Console, open the user’s Authorizations tab
  2. Find the grafana_admin authorization on the ZITADEL project
  3. Click the delete icon to remove it

The change takes effect on their next Grafana login.

Customising the Role Mapping

The role mapping expression is configured in values.yaml under grafana."grafana.ini".auth.generic_oauth.role_attribute_path. It uses JMESPath syntax evaluated against the OIDC token’s role claims.

The default expression is:

grafana:
  "grafana.ini":
    auth.generic_oauth:
      role_attribute_path: >-
        contains(keys("urn:zitadel:iam:org:project:roles"), 'grafana_admin') && 'Admin' || 'Viewer'

Example: Adding an Editor Tier

To map a grafana_editor Zitadel role to Grafana’s Editor role, create the grafana_editor role in Zitadel (following the same steps as above) and extend the expression:

grafana:
  "grafana.ini":
    auth.generic_oauth:
      role_attribute_path: >-
        contains(keys("urn:zitadel:iam:org:project:roles"), 'grafana_admin') && 'Admin'
        || contains(keys("urn:zitadel:iam:org:project:roles"), 'grafana_editor') && 'Editor'
        || 'Viewer'

Apply the change using the standard upgrade procedure in the Configuration Guide.

Blocking Unauthenticated Access

By default, role_attribute_strict is set to false, which means any authenticated Zitadel user can log into Grafana as a Viewer even if they have no explicit Grafana role assigned. To restrict Grafana access to only users who have been explicitly granted a role, set this to true:

grafana:
  "grafana.ini":
    auth.generic_oauth:
      role_attribute_strict: true

With role_attribute_strict: true, users who do not match any role in the role_attribute_path expression will be denied access entirely.

Managing Users in Grafana

User accounts in Grafana are created automatically on first login via Zitadel. There is no need to pre-create users in the Grafana UI.

To view and manage users who have logged in:

  1. Log into Grafana as an Admin
  2. Navigate to Administration > Users and access > Users

From here you can see each user’s current role, last login time, and authentication provider. Role changes should always be made via Zitadel (as described above) rather than directly in Grafana, as they will be overwritten on the user’s next login.

Break-Glass Admin Access

A local Grafana admin account is available as a break-glass fallback for situations where Zitadel is unavailable. This account is not accessible via the standard login page (which only shows the Zitadel SSO button).

To use the local admin account, navigate directly to:

https://<manager-host>/grafana/login

The default credentials are listed in the Glossary. Change the default password immediately after installation.

Security recommendation: The break-glass account should be used only for emergency access. Do not use it for routine administration.

Troubleshooting

OAuth2 Redirect URI Mismatch / CORS Errors

Grafana is registered in Zitadel with the redirect URI https://<manager-host>/grafana/login/generic_oauth, derived from the first entry of manager_hostnames. Accessing Grafana via a different hostname or IP address will not match this URI and will cause the login to fail.

Resolution: Always access Grafana via the configured hostname. If the hostname has changed, run deploy again to re-register the application with the updated URI.

User Receives Viewer Instead of Admin

The grafana_admin role is not included in the user’s Zitadel token.

Resolution:

  1. Confirm the grafana_admin role exists on the ZITADEL project in the Zitadel Console
  2. Confirm the role is assigned to the user under their Authorizations tab
  3. Ask the user to log out of Grafana and log back in — role changes are applied on the next login, not the current session

Login Fails with “Role not found” or Access Denied

role_attribute_strict may be set to true and the user has no matching Zitadel role.

Resolution: Either assign the user an appropriate Zitadel project role, or set role_attribute_strict: false in values.yaml to allow all authenticated users Viewer access.

Admin Role Assigned in Zitadel but User Still Gets Viewer

The grafana_admin role is correctly assigned to the user in Zitadel, but Grafana still grants them Viewer access. This indicates that role claims are not being included in the Zitadel userinfo response.

Grafana determines roles by calling the Zitadel userinfo endpoint (/oidc/v1/userinfo) and evaluating the urn:zitadel:iam:org:project:roles claim. Zitadel only includes this claim when the Grafana OIDC application has Access Token Role Assertions enabled. If the claim is absent, the role_attribute_path expression always falls through to 'Viewer'.

To verify and fix:

  1. Log into the Zitadel Console at https://<manager-host>/ui/console
  2. Navigate to Projects > ZITADEL > Applications > Grafana
  3. Open the Token Settings tab
  4. Ensure Access Token Role Assertions is enabled
  5. Save the change

The fix takes effect on the user’s next login — no Grafana or Helm changes are required.

Grafana OIDC App Not Registered in Zitadel

If the init container failed during installation, the Grafana OIDC application may not have been created in Zitadel.

Resolution: Check the init container logs for errors:

kubectl logs -l app.kubernetes.io/component=grafana --previous -c zitadel-oauth-setup

Common causes are Zitadel not being ready when the init container ran, or a machine-key permission issue. Running deploy again re-triggers the init container and attempts registration again.

Next Steps

  1. Grafana Dashboards - Using and customising dashboards
  2. Alerts & Alarms - Configure alerting and notifications
  3. Metrics & Monitoring Overview - Return to the monitoring overview

9.2 - Grafana Dashboards

Using and customising Grafana dashboards

Overview

Grafana is the primary visualization platform for the CDN Manager monitoring stack. It provides pre-built dashboards for cluster health, application performance, and billing analytics, and is accessible via the manager ingress.

Prerequisites

  • Grafana is deployed and running (verify with kubectl get pods -l app.kubernetes.io/component=grafana)
  • A Zitadel user account is available for login
  • Grafana is accessed via the correct DNS hostname (see Grafana Authentication & Roles)

Accessing Grafana

Grafana is accessible via the manager ingress:

URL: https://<manager-host>/grafana

To log in:

  1. Navigate to https://<manager-host>/grafana
  2. Click the “Login with Zitadel” button
  3. Authenticate with your Zitadel account credentials

Important: Grafana must be accessed using the main manager hostname, the first entry of manager_hostnames in the site settings. Accessing Grafana via an IP address or an alternative hostname will cause OAuth2 redirect URI mismatches and CORS errors, preventing login from completing successfully.

For details on authentication and role configuration, see Grafana Authentication & Roles.

Standard Dashboards

Accessing Dashboards

After logging into Grafana:

  1. Navigate to Dashboards in the left menu
  2. Browse the folder structure to find the dashboard you need
  3. Click on a dashboard to open it

Dashboards are organised into the following folders:

  • Alerting — alert state history and alerting system health
  • Billing — redirect counts for billing analytics
  • CDN Manager — ACD Manager API performance
  • Hardware — host-level CPU, memory, disk, and network telemetry
  • Infrastructure — Kubernetes cluster, Kafka, Longhorn, and Redis health
  • Streaming — CDN routing, streamer performance, and QoE
  • Internal Debugging — low-level CDN Director diagnostics

Alerting

Active Alarms

A live view of all currently firing alerts. Shows the alert name, severity, affected host, and description. Use this as the first stop when investigating an active incident.

Alert Statistics

Historical view of alert firing activity over time. Shows which alert groups and individual rules have been firing, with timelines and trend charts. Useful for identifying recurring or flapping alerts.

vmalert

Operational health dashboard for the vmalert component itself. Covers evaluation rate, evaluation errors, alerting and recording rule counts, remote write throughput, and resource usage. Use this to verify the alerting pipeline is functioning correctly.


Billing

Billing Dashboard

Tracks redirect volumes for billing and usage analytics. Shows initial managed and unmanaged redirects, segment redirects, and endpoint redirects — both as totals and as ratios over time. Data is sourced from long-term VictoriaMetrics storage to support historical reporting.


CDN Manager

CDN Manager API

Health and performance dashboard for the ACD Manager REST API. Covers:

  • Overview: API health status, active pod count, total request volume, 5xx error rate, and average latency
  • Traffic: Request rate by pod, distribution across API endpoints
  • Errors: 5xx errors per endpoint, response code breakdown per endpoint, error rate by pod
  • Latency: P99 and average latency by endpoint, overall API response latency
  • Resources & Auth: Route validation API activity

Hardware

HW Metrics

Condensed host hardware overview covering CPU usage and load averages, memory utilisation, network interface throughput, swap usage, and root filesystem disk space. Suitable for day-to-day health checks across all cluster nodes.

An expanded HW Metrics (Advanced) dashboard is available as part of the Advanced Dashboards licence.


Infrastructure

k3s Cluster Infrastructure

Kubernetes cluster health overview using node-exporter and kube-state-metrics. Covers:

  • Cluster Overview: Node count, running pod count, OOMKilled containers, and overall cluster health status
  • Compute: CPU usage, memory usage, and load average per node
  • Network: Inbound and outbound bytes per node
  • Disk: Read/write throughput and I/O pressure per node
  • Longhorn PVC Disk Usage: Usage percentage per persistent volume
  • Workload Health: Pod restart counts and OOMKill occurrences

Kafka

Kafka broker health using JMX exporter metrics. Covers:

  • Cluster Health: Active controller, broker state, topic and partition counts, offline and under-replicated partitions, active and fenced broker counts, metadata log lag
  • Throughput: Bytes in/out and messages in by topic, replication bytes in/out
  • Internals: Request handler idle percentage, network processor idle percentage

Longhorn Storage

Persistent storage health for the Longhorn distributed block storage layer. Covers:

  • Overview: Total, healthy, degraded, and faulted volume counts; nodes down
  • Capacity: Total cluster capacity, used, and available storage
  • Volume Detail: Usage percentage per volume, actual size per volume, volume robustness state, volumes approaching capacity (>85%)
  • Node & Disk: Disk usage percentage and available bytes per node, node condition checks

Redis

Redis instance health using redis-exporter metrics. Covers:

  • Instance Health: Status, uptime, connected and blocked clients, slow log length, rejected connections
  • Memory: Usage and fragmentation ratio
  • Throughput & Keyspace: Commands processed, network I/O, keyspace hit rate, keys per database
  • Evictions & Persistence: Evictions, expirations, RDB unsaved changes
  • CPU & Connections: CPU usage and connection metrics
  • Command Analysis: Per-command breakdown

Streaming

Extended Monitoring

The primary operational dashboard for CDN routing activity. This is the home dashboard displayed on Grafana login. Covers:

  • Latency Statistics: CDN Director latency and CDN latency over time
  • Redirects: Total redirect volume, status code breakdown, managed vs unmanaged ratio
  • Content Popularity: Top 10 requested content and top 10 most rapidly increasing popularity scores
  • CDN Selection: Redirect distribution across CDN endpoints, current and historical ratios
  • CDN Failovers and Retries: Failover events and retry rates by CDN
  • Host Selection: Endpoint request distribution
  • Session Statistics: Active session counts and session type breakdown
  • Client Responses: Client-facing HTTP status code distribution
  • Incoming Requests: Raw request volume
  • HTTPS Certificate Statistics: Certificate validity and expiry indicators
  • Warnings & Errors: Application-level warnings and errors over time
  • LUA Statistics: Lua exception counts and execution time
  • Configuration Change History: Timeline of routing configuration changes

Router Monitoring

External-facing view of CDN Director routing activity. Shows the number of initial routing decisions made, HTTP status code distribution, incoming HTTP/HTTPS request volumes, and selection input metrics. Useful for a high-level view of traffic hitting the directors.

QoE Monitoring

Quality of Experience scoring dashboard. Shows average QoE scores per host, per session group, per CDN, and per agent, as well as the initial CDN selection rate. Use this to identify CDN providers or content hosts that are delivering a degraded experience.

Streamer Statistics

Condensed view of streamer node performance, covering network ingress/egress throughput, TCP and HTTP connection counts, active session counts, HTTP request rates and response codes, response times (ingress and egress), and storage/memory/CPU. Suitable for routine streamer health monitoring.

An expanded Streamer Statistics (Advanced) dashboard is available as part of the Advanced Dashboards licence.


Internal Debugging

These dashboards expose low-level CDN Director internals and are primarily intended for advanced diagnostics and support investigations.

Debugging Information

Lua runtime statistics from the CDN Director: exception counts, active Lua context count, time spent in Lua execution, and CDN Director latency. Use when investigating unexpected CDN Director behaviour or Lua errors.

ACD: Incoming Internet Connections

SSL-level connection statistics at the CDN Director: SSL warnings and errors, valid and invalid HTTP and HTTPS request counts from external clients. Use when investigating TLS handshake failures or unexpected rejection rates.

Performance Metrics

CDN Director process-level resource usage: CDN Director CPU utilisation, CDN Director memory usage, and Lua memory consumption. Useful for identifying resource pressure on the CDN Director process itself rather than the host.

Prometheus: ACD

ACD application metrics exposed via Prometheus: active and total session counts, session type breakdown, managed and unmanaged redirect counts, QoE corrections, manifest parse failures, initial endpoint request counts, HTTP request rates, and logged warnings and errors.

CDN Failures

CDN-level failure tracking: response code distribution from CDN backends, CDN-level failover events, host-level failovers, and host retry counts. Use when investigating CDN reliability issues or failover behaviour.

ACD: CDN Latencies Detail

Detailed CDN latency analysis with configurable percentile plots and a full latency histogram. Use when investigating tail latency issues on specific CDN backends.

ACD: Router Latencies

CDN Director routing latency distributions for both 2xx (successful) and 3xx (redirect) responses, visualised as heatmap buckets over time. Use alongside CDN Latencies Detail to separate CDN Director processing time from CDN response time.

Prometheus/ACD: SubRunners

Internal async processing queue depth and throughput metrics for the CDN Director subrunner system: client connection counts, low/medium/high priority queue depths (current and max), send/receive data block usage, wakeup counts, overload events, and autopause activations. Use when investigating CDN Director throughput bottlenecks or queue backpressure.

Advanced Dashboards

Advanced dashboards are a paid add-on that unlocks more detailed variants of two standard dashboards, providing deeper visibility for performance investigation and capacity planning.

Licensing: Advanced dashboards require a separate licence key. To obtain a key for your deployment, contact your AgileTV account representative.

Enabling Advanced Dashboards

Once you have your licence key, add the following to your values.yaml:

dashboards:
  advanced:
    licenceKey: "<your-licence-key>"

Then apply the change by following the upgrade procedure in the Configuration Guide. The advanced dashboards will become available in Grafana automatically once the upgrade completes.

HW Metrics (Advanced)

Expanded hardware telemetry that supplements the standard HW Metrics dashboard with additional depth and additional sections: kernel metrics, TCP and UDP network stack statistics, per-interface error counters, disk IOPS, and metrics collection velocity. Use this when investigating performance anomalies surfaced by the standard dashboard or by alerts.

Streamer Statistics (Advanced)

Full streamer telemetry that supplements the standard Streamer Statistics dashboard. Includes all standard metrics plus:

  • OTT JCQ: Server group and node request rates and ratios, circuit breaker state (closed/open backends), global backend stats, pending requests per backend, and pop-out per backend
  • Account Records: Session counts, total traffic in/out, HTTP request rates (ingress/egress), cache hit ratio, backend request rates, and response times
  • Detailed network error and drop counters from /proc/net/dev

CDN Director Metrics

CDN Director DNS Names in Grafana

CDN Director instances are identified in Grafana by their DNS name, which is derived from the name field in global.hosts.routers:

global:
  hosts:
    routers:
      - name: my-router-1
        address: 192.0.2.1

The DNS name used in Grafana dashboards will be: my-router-1.external

This naming convention is automatically applied for all configured directors.

Customising Dashboards

Permissions: Creating and importing dashboards requires Grafana Admin access. See Grafana Authentication & Roles for details on granting admin rights.

The pre-provisioned dashboards are read-only and managed by the Helm chart — changes made to them in the Grafana UI will not persist across upgrades. To create persistent custom dashboards:

  1. In Grafana, navigate to Dashboards > New > New Dashboard
  2. Add panels using the VictoriaMetrics or Prometheus datasource
  3. Save the dashboard to a folder of your choice

Custom dashboards saved this way are stored in the Grafana PostgreSQL database and are unaffected by Helm upgrades.

Note: Do not save custom dashboards into the provisioned folders (Alerting, Billing, CDN Manager, etc.). Grafana marks these folders as provisioned and may behave unexpectedly if user dashboards are mixed in.

Customising a Pre-provisioned Dashboard

If you want a modified version of one of the built-in dashboards as a starting point:

  1. Open the dashboard you want to customise
  2. Click the Share icon (top toolbar) > Export > Save to file to download the dashboard JSON
  3. In Grafana, navigate to Dashboards > New > Import
  4. Upload the downloaded JSON file
  5. On the import screen, give the dashboard a new name to distinguish it from the original, and choose a destination folder outside the provisioned set
  6. Click Import

You now have an independently editable copy. The original provisioned dashboard remains unchanged and will continue to be updated by future Helm upgrades. Your copy is stored in PostgreSQL and persists across upgrades independently.

Troubleshooting

Dashboard Loading Issues

If dashboards fail to load:

  1. Check Grafana pods:

    kubectl get pods -l app.kubernetes.io/component=grafana
    
  2. Review Grafana logs:

    kubectl logs -l app.kubernetes.io/component=grafana
    
  3. Verify datasource configuration in Grafana UI

For login and authentication issues, see Grafana Authentication & Roles.

Next Steps

  1. Alerts & Alarms - Set up alerting and notifications
  2. Operations Guide - Day-to-day operational procedures
  3. Metrics & Monitoring Overview - Return to the monitoring overview

9.3 - Alerts & Alarms

Configuring and managing alerts and alarms

Overview

The CDN Manager ships a set of pre-configured alerting rules evaluated by vmalert against VictoriaMetrics. When a rule fires, the alert is routed to Alertmanager, which handles deduplication, grouping, silencing, and delivery to configured notification channels.

This page documents every built-in alert rule, what it means, its severity, and the recommended operator action.

Alert Severity Levels

SeverityMeaning
criticalImmediate action required. The condition poses a risk to data integrity, service availability, or active traffic.
warningInvestigate soon. The condition is not immediately harmful but will degrade into a critical state if left unattended.

Alert Groups

Alerts are organised into the following groups, each evaluated on a 15-second interval.


infra-disk

Monitors disk space utilisation and I/O latency on cluster nodes.

StorageFillingUp

PropertyValue
Severitywarning
ConditionRoot filesystem usage exceeds 85%
Must persist for2 minutes

What it means: A node’s root filesystem is running low on space. If left unchecked this will progress to a full disk, which can cause pod evictions, write failures, and potential data loss.

Recommended actions:

  1. Identify the node from the host label in the alert.
  2. Log into the node and check disk usage:
    df -h /
    du -sh /var/log/* | sort -rh | head -20
    
  3. Clear old log files, unused container images, or temporary files:
    # On the node
    journalctl --vacuum-size=500M
    crictl rmi --prune
    
  4. If disk usage is due to application data growth, consider expanding the volume or adjusting retention settings. See Metrics Retention.

HighDiskLatency

PropertyValue
Severitywarning
ConditionAverage disk write latency exceeds 100 ms
Must persist for2 minutes

What it means: Disk write operations are taking longer than 100 ms on average. High disk latency can degrade database performance (PostgreSQL, VictoriaMetrics) and cause timeouts in write-heavy components.

Recommended actions:

  1. Identify the affected disk from the name label in the alert.
  2. Check for I/O-intensive processes on the node:
    iostat -x 2 5
    iotop -o
    
  3. Check for Longhorn replica rebuilds or rebalancing activity, which can saturate disk I/O.
  4. If the issue persists on a production node, review whether the storage hardware meets the System Requirements.

infra-compute

Monitors CPU and memory utilisation on cluster nodes.

CpuSaturation

PropertyValue
Severitywarning
ConditionTotal CPU usage exceeds 90%
Must persist for5 minutes

What it means: A node is running at near-full CPU capacity. Sustained CPU saturation causes request latency increases across all workloads on that node and may result in pod throttling.

Recommended actions:

  1. Identify the saturated node from the host label in the alert.
  2. Check which pods are consuming CPU:
    kubectl top pods --sort-by=cpu -A
    
  3. Check for runaway processes on the node:
    top -b -n 1 | head -20
    
  4. If saturation is caused by a legitimate workload spike (e.g. CDN traffic burst), consider scaling the deployment or redistributing load across nodes.

MemoryCriticallyLow

PropertyValue
Severitycritical
ConditionAvailable RAM falls below 10%
Must persist for2 minutes

What it means: The node has very little free memory remaining. The Linux OOM killer may begin terminating processes, which can cause abrupt pod restarts, data corruption in in-memory caches, and service unavailability.

Recommended actions:

  1. Identify the affected node from the host label in the alert.
  2. Immediately check for memory-leaking or oversized pods:
    kubectl top pods --sort-by=memory -A
    
  3. Identify and restart any pods showing abnormal memory consumption:
    kubectl rollout restart deployment/<name>
    
  4. Check kernel OOM kill log for any processes already killed:
    dmesg | grep -i "oom\|killed"
    
  5. Review memory resource limits and requests for affected deployments and adjust if necessary.

SwapUsageDetected

PropertyValue
Severitywarning
ConditionSwap usage exceeds 5%
Must persist for1 minute

What it means: The node is swapping memory to disk. Swap usage in a Kubernetes cluster is a strong indicator of memory pressure. It degrades performance significantly and may mask an underlying memory shortage that could escalate to a MemoryCriticallyLow event.

Recommended actions:

  1. Treat this as an early warning for the same conditions as MemoryCriticallyLow.
  2. Identify memory-intensive pods and investigate whether resource limits are configured appropriately.
  3. Swap should ideally never be active on a production Kubernetes node. If it persists, escalate to a memory capacity review.

infra-network

Monitors network interface errors and traffic anomalies on cluster nodes.

NetworkInterfaceErrors

PropertyValue
Severitycritical
ConditionAny non-zero rate of inbound or outbound packet errors on a network interface
Must persist for1 minute

What it means: A network interface is dropping or corrupting packets. Even a low error rate can cause TCP retransmissions, increased latency, and connection failures — directly impacting CDN Director communication and external traffic delivery.

Recommended actions:

  1. Identify the affected host and interface from the host and interface labels in the alert.
  2. Check interface error counters on the node:
    ip -s link show <interface>
    ethtool -S <interface> | grep -i error
    
  3. Check for duplex/speed mismatches between the node NIC and the upstream switch:
    ethtool <interface> | grep -E "Speed|Duplex"
    
  4. Escalate to network/hardware team if errors are persistent and cannot be attributed to a software configuration issue.

SuddenNetworkEgressDrop

PropertyValue
Severitycritical
ConditionEgress throughput drops to less than 50% of the 2-minute baseline, when baseline traffic is above 1 Mbit/s
Must persist for1 minute

What it means: A significant, sudden reduction in outbound traffic has been detected. This typically indicates a upstream network failure, link fault, or a routing issue. A CDN node that stops transmitting traffic is effectively out of service.

Recommended actions:

  1. Identify the affected node and interface from the alert labels.
  2. Verify the node’s network connectivity:
    ping <gateway-ip>
    traceroute <upstream-endpoint>
    
  3. Check for interface errors or link-down events:
    ip link show
    dmesg | grep -i "link\|eth\|nic"
    
  4. Verify that upstream routing and firewall rules have not changed.
  5. If the node is healthy and traffic has legitimately dropped (e.g. a CDN traffic shift), the alert can be silenced if the traffic reduction is expected and understood.

SuddenNetworkIngressSpike

PropertyValue
Severitywarning
ConditionIngress throughput exceeds twice the 5-minute baseline
Must persist for1 minute

What it means: A sudden surge of inbound traffic has been detected. This may indicate a legitimate traffic event (e.g. a large stream audience spike), a DDoS attempt, or a misconfigured client sending unexpected volume.

Recommended actions:

  1. Identify the affected node and interface from the alert labels.
  2. Review active connections and top talkers:
    ss -s
    netstat -an | awk '{print $5}' | cut -d: -f1 | sort | uniq -c | sort -rn | head
    
  3. Correlate with CDN Director metrics in Grafana to determine whether the spike is legitimate CDN traffic.
  4. If the spike is unexpected and sustained, consider rate-limiting or blocking the source at the network edge.

longhorn

Monitors the health of Longhorn distributed block storage, which backs persistent volumes for PostgreSQL, VictoriaMetrics, and other stateful components.

Note: Longhorn alert rules are always present in the alert configuration, but will not fire in environments where Longhorn is not installed (e.g. cloud deployments using external storage).

LonghornVolumeDegraded

PropertyValue
Severitywarning
ConditionA Longhorn volume’s robustness state is Degraded
Must persist for2 minutes

What it means: A Longhorn volume has fewer healthy replicas than its configured replication factor. Data is not at immediate risk, but the volume has reduced redundancy. A single additional node or disk failure could result in data loss or volume unavailability.

Recommended actions:

  1. Identify the affected volume from the volume label in the alert.
  2. Open the Longhorn UI and inspect the volume’s replica status.
  3. Check whether a replica is in the process of rebuilding (this is normal after a node restart). Rebuilding may take several minutes depending on volume size.
  4. If a replica has failed and is not rebuilding, attempt to evict and re-schedule it via the Longhorn UI.
  5. Investigate the health of the node that hosted the failed replica:
    kubectl get nodes
    kubectl describe node <node-name>
    

LonghornVolumeFaulted

PropertyValue
Severitycritical
ConditionA Longhorn volume’s robustness state is Faulted
Must persist for1 minute

What it means: A Longhorn volume has lost all healthy replicas and is no longer accessible. Any workload that depends on this volume (e.g. PostgreSQL, VictoriaMetrics) will be unable to write and may crash. Data may be at risk.

Recommended actions:

  1. Identify the affected volume from the volume label.
  2. Immediately check which pods are using the volume:
    kubectl get pods -A -o wide | grep -i <volume-name>
    
  3. Open the Longhorn UI. Check whether any replicas are still present and whether they can be recovered.
  4. Do not delete faulted replicas without first attempting recovery — they may contain the only copy of the data.
  5. Contact AgileTV support if the volume cannot be recovered, providing Longhorn UI screenshots and node logs.

LonghornNodeDown

PropertyValue
Severitycritical
ConditionA Longhorn node reports a non-ready state
Must persist for2 minutes

What it means: A storage node is unreachable or unhealthy from Longhorn’s perspective. All volumes with replicas on this node are at reduced redundancy. If more than one node goes down simultaneously, faulted volumes and data loss become a risk.

Recommended actions:

  1. Identify the affected node from the node label in the alert.
  2. Check the node’s status in Kubernetes:
    kubectl get nodes
    kubectl describe node <node-name>
    
  3. Attempt to SSH to the node and check system health:
    ssh root@<node-ip>
    systemctl status k3s
    
  4. If the node has crashed and cannot be recovered quickly, consider evicting its Longhorn replicas to allow rebuilding on healthy nodes — but only if the remaining healthy nodes have sufficient capacity.

LonghornDiskSpaceLow

PropertyValue
Severitywarning
ConditionAvailable Longhorn disk space on a node falls below 15%
Must persist for2 minutes

What it means: A node’s Longhorn-managed disk is running low on storage. When Longhorn disk space is exhausted, it cannot schedule new replicas or accommodate volume growth, which can lead to LonghornVolumeDegraded or LonghornVolumeFaulted conditions.

Recommended actions:

  1. Identify the affected node and disk from the node and disk labels in the alert.
  2. Open the Longhorn UI and check which volumes have replicas on this disk.
  3. Check for snapshots or backups that can be cleaned up to reclaim space.
  4. If space cannot be reclaimed, consider adding a disk to the node or expanding the underlying block device.
  5. Review Metrics Retention settings — reducing VictoriaMetrics retention is often the fastest way to reclaim Longhorn disk space in a monitoring-heavy deployment.

Adding Custom Alert Rules

Additional alert rules can be defined by extending the victoria_metrics_alert.server.config.alerts.groups list in your values.yaml. Rules follow the Prometheus alerting rule format.

Example: Adding a Custom Alert

The following example adds an alert group that fires when a Kafka consumer lag exceeds a threshold:

victoria_metrics_alert:
  server:
    config:
      alerts:
        groups:
          # ... existing groups are preserved alongside your additions ...
          - name: kafka
            interval: 15s
            rules:
              - alert: KafkaConsumerLagHigh
                expr: kafka_consumer_group_lag > 10000
                for: 5m
                labels:
                  severity: warning
                annotations:
                  summary: "High consumer lag on {{ $labels.topic }}"
                  description: "Consumer group {{ $labels.group }} is {{ $value }} messages behind on topic {{ $labels.topic }}."

Apply the change using the standard upgrade procedure in the Configuration Guide.

Rule Fields Reference

FieldRequiredDescription
alertYesAlert name. Must be unique within the group.
exprYesPromQL expression. The alert fires when this evaluates to a non-zero/non-empty result.
forNoHow long the condition must hold before the alert fires. Omitting this fires immediately.
labels.severityRecommendedSet to critical or warning to match the built-in routing rules.
annotations.summaryRecommendedShort human-readable description. Supports Go template labels (e.g. {{ $labels.host }}).
annotations.descriptionRecommendedDetailed description with context for the on-call operator.

Tip: Use the Alertmanager UI (https://<manager-host>/alertmanager) to verify that fired alerts are being received and routed correctly after adding new rules.


Configuring Alert Routes

By default, all alerts are routed to the built-in null receiver, which silently discards them. To receive alerts, configure one or more receivers and update the routing rules — all within the alertmanager.config section of your values.yaml.

Route Structure

The top-level route defines the default behaviour. Child routes under routes match alerts by label and direct them to specific receivers:

alertmanager:
  config:
    route:
      receiver: 'null'          # Default: discard unmatched alerts
      group_by: ['alertname']
      group_wait: 10s           # Wait before sending first notification for a new group
      group_interval: 10s       # Wait before sending updated notifications for a group
      repeat_interval: 1h       # Re-notify if an alert is still firing after this period
      routes:
        - matchers:
            - severity="critical"
          receiver: 'slack'
        - matchers:
            - severity="warning"
          receiver: 'email-warning'

Routes are evaluated top-to-bottom. The first matching route wins unless continue: true is set on the route.


Notification Channels

Email

Email requires an SMTP server to be configured globally. Both a critical and warning receiver can be defined independently.

alertmanager:
  config:
    global:
      smtp_smarthost: 'smtp.example.com:587'
      smtp_from: 'alertmanager@example.com'
      smtp_require_tls: true
    route:
      routes:
        - matchers:
            - severity="critical"
          receiver: 'email-critical'
        - matchers:
            - severity="warning"
          receiver: 'email-warning'
    receivers:
      - name: 'null'
      - name: 'email-critical'
        email_configs:
          - to: 'oncall@example.com'
            send_resolved: true
      - name: 'email-warning'
        email_configs:
          - to: 'alerts@example.com'
            send_resolved: true

Slack

Requires an incoming webhook URL created in your Slack workspace.

alertmanager:
  config:
    route:
      routes:
        - matchers:
            - severity="critical"
          receiver: 'slack'
    receivers:
      - name: 'null'
      - name: 'slack'
        slack_configs:
          - api_url: 'https://hooks.slack.com/services/YOUR/WEBHOOK/URL'
            channel: '#alerts'
            send_resolved: true
            title: '[{{ .Status | toUpper }}] {{ .CommonLabels.alertname }}'
            text: |
              *Severity:* {{ .CommonLabels.severity }}
              *Host:* {{ .CommonLabels.host }}
              {{ range .Alerts }}{{ .Annotations.description }}{{ end }}

Telegram

Requires a Telegram bot token and a channel/group chat ID. Create a bot via @BotFather and add it to your alert channel before configuring.

alertmanager:
  config:
    route:
      routes:
        - matchers:
            - severity="critical"
          receiver: 'telegram'
    receivers:
      - name: 'null'
      - name: 'telegram'
        telegram_configs:
          - bot_token: 'your-bot-token'
            chat_id: -1234567890
            parse_mode: 'Markdown'
            send_resolved: true
            message: |
              *Alert:* {{ .CommonLabels.alertname }}
              *Severity:* {{ .CommonLabels.severity }}
              *Host:* {{ .CommonLabels.host }}
              {{ range .Alerts }}
                {{ .Annotations.description }}
              {{ end }}

Finding your chat ID: Add your bot to the channel or group, send a message, then call https://api.telegram.org/bot<token>/getUpdates and read the chat.id from the response. Note that group and channel chat IDs are negative numbers.


Combining Multiple Receivers

Routes and receivers can be combined to send different alert severities to different channels simultaneously. For example, critical alerts to PagerDuty and Slack, warnings to email only:

alertmanager:
  config:
    route:
      receiver: 'null'
      routes:
        - matchers:
            - severity="critical"
          receiver: 'slack'
          continue: true        # Continue matching so the next route also fires
        - matchers:
            - severity="critical"
          receiver: 'email-critical'
        - matchers:
            - severity="warning"
          receiver: 'email-warning'
    receivers:
      - name: 'null'
      - name: 'slack'
        slack_configs:
          - api_url: 'https://hooks.slack.com/services/YOUR/WEBHOOK/URL'
            channel: '#critical-alerts'
            send_resolved: true
      - name: 'email-critical'
        email_configs:
          - to: 'oncall@example.com'
            send_resolved: true
      - name: 'email-warning'
        email_configs:
          - to: 'alerts@example.com'
            send_resolved: true

Apply any receiver or routing changes using the standard upgrade procedure in the Configuration Guide.


Silencing Alerts

Silences suppress alert notifications for a defined time window without disabling the underlying alert rule. They are useful during planned maintenance, known incidents, or when investigating a non-urgent condition.

Silences are managed via the Alertmanager UI, accessible at:

https://<manager-host>/alertmanager

Creating a Silence

  1. Navigate to the Alertmanager UI and click Silences in the top navigation.
  2. Click Create Silence.
  3. Set the Start and End times for the silence window.
  4. Add one or more matchers to scope which alerts are suppressed. For example:
    • alertname = StorageFillingUp — silence a specific alert
    • severity = warning — silence all warnings
    • host = node-01 — silence all alerts from a specific host
  5. Add a Comment describing the reason for the silence (e.g. Planned disk expansion on node-01).
  6. Click Create. The silence takes effect immediately.

Expiring a Silence

Silences expire automatically at the configured end time. To remove a silence early, navigate to Silences in the Alertmanager UI, locate the silence, and click Expire.

Next Steps

  1. Operations Guide - Day-to-day operational procedures
  2. Troubleshooting Guide - Resolve underlying issues surfaced by alerts
  3. Metrics & Monitoring Overview - Return to the monitoring overview

10 - API Guide

REST API reference and integration examples

Overview

The CDN Manager exposes versioned HTTP APIs under /api (v1 and v2), using JSON payloads by default. When sending request bodies, set Content-Type: application/json. Server errors typically respond with { "message": "..." } where available, or an empty body with the relevant status code.

Authentication uses a two-step flow:

  1. Create a session
  2. Exchange that session for an access token with grant_type=session

Use the access token in Authorization: Bearer <token> when calling bearer-protected routes. CORS preflight (OPTIONS) is supported and wildcard origins are accepted by default.

Durations such as TTLs use humantime strings (for example, 60s, 5m, 1h).

Base URL

All API endpoints are relative to:

https://<manager-host>/api

API Reference Guides

The API documentation is organized by functional area:

GuideDescription
Authentication APILogin, token exchange, logout, and session management
Health APILiveness and readiness probes
Selection Input APIKey-value and list storage with search capabilities
Data Store APIGeneric JSON key/value storage
Subnets APICIDR-to-value mappings for routing decisions
Routing APIGeoIP lookups and IP validation
Discovery APIHost and namespace discovery
Metrics APIMetrics submission and aggregation
Configuration APIConfiguration document management
Operator UI APIBlocked tokens, user agents, and referrers
OpenAPI SpecificationComplete OpenAPI 3.0 specification

Authentication Flow

All authenticated API calls follow the same authentication flow. For detailed instructions, see the Authentication API Guide.

Quick Start:

# Step 1: Login to get session
curl -s -X POST "https://cdn-manager/api/v1/auth/login" \
  -H "Content-Type: application/json" \
  -d '{
    "email": "user@example.com",
    "password": "Password1!"
  }' | tee /tmp/session.json

SESSION_ID=$(jq -r '.session_id' /tmp/session.json)
SESSION_TOKEN=$(jq -r '.session_token' /tmp/session.json)

# Step 2: Exchange session for access token
curl -s -X POST "https://cdn-manager/api/v1/auth/token" \
  -H "Content-Type: application/json" \
  -d "$(jq -nc --arg sid "$SESSION_ID" --arg st "$SESSION_TOKEN" \
    '{session_id:$sid,session_token:$st,grant_type:"session",scope:"openid"}')" \
  | tee /tmp/token.json

ACCESS_TOKEN=$(jq -r '.access_token' /tmp/token.json)

# Step 3: Call a protected endpoint
curl -s "https://cdn-manager/api/v1/metrics" \
  -H "Authorization: Bearer ${ACCESS_TOKEN}"

Error Responses

The API uses standard HTTP response codes to indicate the success or failure of an API request.

Most errors return an empty response body with the relevant HTTP status code (e.g., 404 Not Found or 409 Conflict).

In some cases, the server may return a JSON body containing a user-facing error message:

{
  "message": "Human-readable error message"
}

Next Steps

After learning the API:

  1. Operations Guide - Day-to-day operational procedures
  2. Troubleshooting Guide - Resolve API issues
  3. Configuration Guide - Full configuration reference

10.1 - Authentication API

Authentication and session management

Overview

The Authentication API provides endpoints for user authentication, session management, and token exchange. All authenticated API calls require a valid access token obtained through the authentication flow.

Base URL

https://<manager-host>/api/v1/auth

Endpoints

POST /api/v1/auth/login

Create a session from email/password credentials.

Request:

POST /api/v1/auth/login
Content-Type: application/json

{
  "email": "user@example.com",
  "password": "Password1!"
}

Success Response (200):

{
  "session_id": "session-1",
  "session_token": "token-1",
  "verified_at": "2024-01-01T00:00:00Z",
  "expires_at": "2024-01-01T01:00:00Z"
}

Errors:

  • 401 - Authentication failure (invalid credentials)
  • 500 - Backend/state errors

POST /api/v1/auth/token

Exchange a session for an access token (required for bearer auth).

Request:

POST /api/v1/auth/token
Content-Type: application/json

{
  "session_id": "session-1",
  "session_token": "token-1",
  "grant_type": "session",
  "scope": "openid profile"
}

Success Response (200):

{
  "access_token": "<token>",
  "scope": "openid profile",
  "expires_in": 3600,
  "token_type": "bearer"
}

Token Scopes

The scope parameter in the token exchange request is a space-separated string of permissions requested for the access token.

Scope Resolution When a token is requested, the backend system filters the requested scopes against the user’s actual permissions. The resulting access token will only contain the subset of requested scopes that the user is authorized to possess.

Naming and Design Scope names are defined by the applications that consume the tokens, not by the central IAM system. To prevent collisions between different applications or modules, it is highly recommended that application developers use URN-style prefixes for scope names (e.g., urn:acd:manager:config:read).

Errors:

  • 401 - Authentication failure (invalid session)
  • 500 - Backend/state errors

POST /api/v1/auth/logout

Revoke a session. Note: This does not revoke issued access tokens; they remain valid until expiration.

Request:

POST /api/v1/auth/logout
Content-Type: application/json

{
  "session_id": "session-1",
  "session_token": "token-1"
}

Success Response (200):

{
  "status": "Ok"
}

Errors:

  • 400 - Invalid session parameters
  • 500 - Backend/state errors

Complete Authentication Flow Example

# Step 1: Login to get session
curl -s -X POST "https://cdn-manager/api/v1/auth/login" \
  -H "Content-Type: application/json" \
  -d '{
    "email": "user@example.com",
    "password": "Password1!"
  }' | tee /tmp/session.json

SESSION_ID=$(jq -r '.session_id' /tmp/session.json)
SESSION_TOKEN=$(jq -r '.session_token' /tmp/session.json)

# Step 2: Exchange session for access token
curl -s -X POST "https://cdn-manager/api/v1/auth/token" \
  -H "Content-Type: application/json" \
  -d "$(jq -nc --arg sid "$SESSION_ID" --arg st "$SESSION_TOKEN" \
    '{session_id:$sid,session_token:$st,grant_type:"session",scope:"openid"}')" \
  | tee /tmp/token.json

ACCESS_TOKEN=$(jq -r '.access_token' /tmp/token.json)

# Step 3: Call a protected endpoint
curl -s "https://cdn-manager/api/v1/metrics" \
  -H "Authorization: Bearer ${ACCESS_TOKEN}"

Using the Access Token

Once you have obtained an access token, include it in the Authorization header of all API requests:

Authorization: Bearer <access_token>

Example:

curl -s "https://cdn-manager/api/v1/configuration" \
  -H "Authorization: Bearer ${ACCESS_TOKEN}"

Token Expiration

Access tokens expire after the duration specified in expires_in (typically 3600 seconds / 1 hour). When a token expires, you must re-authenticate to obtain a new token.

Next Steps

10.2 - Health API

Liveness and readiness probe endpoints

Overview

The Health API provides endpoints for Kubernetes health probes and service health checking.

Base URL

https://<manager-host>/api/v1/health

Endpoints

GET /api/v1/health/alive

Liveness probe that indicates whether the service is running. Always returns 200 OK.

Request:

GET /api/v1/health/alive

Response (200):

{
  "status": "Ok"
}

Use Case: Kubernetes liveness probe to determine if the pod should be restarted.


GET /api/v1/health/ready

Readiness probe that checks service readiness including downstream dependencies.

Request:

GET /api/v1/health/ready

Success Response (200):

{
  "status": "Ok"
}

Failure Response (503):

{
  "status": "Fail"
}

Use Case: Kubernetes readiness probe to determine if the pod should receive traffic. Returns 503 if any downstream dependencies (database, Kafka, Redis) are unavailable.


Kubernetes Configuration

Example Kubernetes probe configuration:

livenessProbe:
  httpGet:
    path: /api/v1/health/alive
    port: 8080
  initialDelaySeconds: 10
  periodSeconds: 10

readinessProbe:
  httpGet:
    path: /api/v1/health/ready
    port: 8080
  initialDelaySeconds: 5
  periodSeconds: 5

Next Steps

10.3 - Selection Input API

Key-value and list storage with search capabilities

Overview

The Selection Input API provides JSON key/value storage with search capabilities. It supports two API versions (v1 and v2) with different operation models.

Base URL

https://<manager-host>/api/v1/selection_input
https://<manager-host>/api/v2/selection_input

Version Comparison

Featurev1 /api/v1/selection_inputv2 /api/v2/selection_input
Primary operationMerge/UPSERT (POST)Insert/Replace (PUT)
List appendN/APOST to push to list
Search syntaxWildcard prefix (foo* implicit)Full wildcard (foo* explicit)
Query paramssearch, sort, limit, ttlsearch, ttl, correlation_id
Sort supportYes (asc/desc)No
Limit supportYesNo
Use caseSimple key-value with optional searchList-like operations, full wildcard

When to Use Each Version

ScenarioRecommended Version
Simple key-value storagev1
List/queue operations (append to array)v2 POST
Full wildcard pattern matchingv2
Need to sort or paginate resultsv1

v1 Endpoints

GET /api/v1/selection_input/{path}

Fetch stored JSON. If value is an object, optional search/limit/sort applies to its keys.

Query Parameters:

  • search - Wildcard prefix search (adds * implicitly)
  • sort - Sort order (asc or desc)
  • limit - Maximum results (must be > 0)

Success Response (200):

{
  "foo": 1,
  "foobar": 2
}

Errors:

  • 404 - Path does not exist
  • 400 - Invalid search/sort/limit parameters
  • 500 - Backend failure

Example:

curl -s "https://cdn-manager/api/v1/selection_input/config?search=foo&limit=2"

POST /api/v1/selection_input/{path}

Upsert (merge) JSON at path. Nested objects are merged recursively.

Query Parameters:

  • ttl - Expiry time as humantime string (e.g., 10m, 1h)

Request:

{
  "feature_flag": true,
  "ratio": 0.5
}

Success: 201 Created echoing the payload

Errors:

  • 500 / 503 - Backend failure

Example:

curl -s -X POST "https://cdn-manager/api/v1/selection_input/config?ttl=10m" \
  -H "Content-Type: application/json" \
  -d '{
    "feature_flag": true,
    "ratio": 0.5
  }'

DELETE /api/v1/selection_input/{path}

Delete stored value.

Success: 204 No Content

Errors: 503 - Backend failure


v2 Endpoints

GET /api/v2/selection_input/{path}

Fetch stored JSON with optional wildcard filtering.

Query Parameters:

  • search - Full wildcard pattern (e.g., foo*, *bar*)
  • correlation_id - Accepted but currently ignored (logging only)

Success Response (200):

{
  "foo": 1,
  "foobar": 2
}

Errors:

  • 400 - Invalid search pattern
  • 404 - Path does not exist
  • 500 - Backend failure

Example:

curl -s "https://cdn-manager/api/v2/selection_input/config?search=foo*"

PUT /api/v2/selection_input/{path}

Insert/replace value. Old value is discarded.

Query Parameters:

  • ttl - Expiry time as humantime string

Request:

{
  "items": ["a", "b", "c"]
}

Success: 200 OK

Example:

curl -s -X PUT "https://cdn-manager/api/v2/selection_input/catalog" \
  -H "Content-Type: application/json" \
  -d '{
    "items": ["a", "b", "c"]
  }'

POST /api/v2/selection_input/{path}

Push a value to the back of a list-like entry (append to array).

Query Parameters:

  • ttl - Expiry time as humantime string

Request (any JSON value):

{
  "item": 42
}

Or a simple string:

"ready-for-publish"

Success: 200 OK

Example:

curl -s -X POST "https://cdn-manager/api/v2/selection_input/queue" \
  -H "Content-Type: application/json" \
  -d '"ready-for-publish"'

DELETE /api/v2/selection_input/{path}

Delete stored value.

Success: 204 No Content


Next Steps

10.4 - Data Store API

Generic JSON key/value storage

Overview

The Data Store API provides generic JSON key/value storage for short-lived or simple structured data.

Base URL

https://<manager-host>/api/v1/datastore

Endpoints

GET /api/v1/datastore

List all known keys.

Query Parameters:

  • show_hidden - Boolean (default false). When true, includes internal keys starting with _.

Success Response (200):

["user:123", "config:settings", "session:abc"]

Hidden Keys: Keys starting with _ are reserved for internal use. Writing to hidden keys via the datastore API returns 400 Bad Request.


GET /api/v1/datastore/{key}

Retrieve the JSON value for a specific key.

Success Response (200): The stored JSON value

Errors:

  • 404 - Key does not exist
  • 500 - Backend failure

Example:

curl -s "https://cdn-manager/api/v1/datastore/user:123"

POST /api/v1/datastore/{key}

Create a new JSON value at the specified key. Fails if the key already exists.

Query Parameters:

  • ttl - Expiry time as humantime string (e.g., 60s, 1h)

Request:

{
  "id": 123,
  "name": "alice"
}

Success: 201 Created

Errors:

  • 409 Conflict - Key already exists
  • 500 - Backend failure

Example:

curl -s -X POST "https://cdn-manager/api/v1/datastore/user:123?ttl=1h" \
  -H "Content-Type: application/json" \
  -d '{"id":123,"name":"alice"}'

PUT /api/v1/datastore/{key}

Update or replace the JSON value at an existing key.

Query Parameters:

  • ttl - Expiry time as humantime string

Success: 200 OK

Errors:

  • 404 - Key does not exist
  • 500 - Backend failure

Example:

curl -s -X PUT "https://cdn-manager/api/v1/datastore/user:123" \
  -H "Content-Type: application/json" \
  -d '{"id":123,"name":"alice-updated"}'

DELETE /api/v1/datastore/{key}

Delete the value at the specified key. Idempotent operation.

Success: 204 No Content

Errors: 500 - Backend failure

Example:

curl -s -X DELETE "https://cdn-manager/api/v1/datastore/user:123"

Next Steps

10.5 - Subnets API

CIDR-to-value mappings for routing decisions

Overview

The Subnets API manages CIDR-to-value mappings used for routing decisions. This allows classification of IP ranges for routing purposes.

Base URL

https://<manager-host>/api/v1/subnets

Endpoints

PUT /api/v1/subnets

Create or update subnet mappings.

Request:

{
  "192.168.1.0/24": "office",
  "10.0.0.0/8": "internal",
  "203.0.113.0/24": "external"
}

Success: 200 OK

Errors:

  • 400 - Invalid CIDR format
  • 500 - Backend failure

Example:

curl -s -X PUT "https://cdn-manager/api/v1/subnets" \
  -H "Content-Type: application/json" \
  -d '{
    "192.168.1.0/24": "office",
    "10.0.0.0/8": "internal"
  }'

GET /api/v1/subnets

List all subnet mappings.

Success Response (200): JSON object of CIDR-to-value mappings

Example:

curl -s "https://cdn-manager/api/v1/subnets" | jq '.'

DELETE /api/v1/subnets

Delete all subnet mappings.

Success: 204 No Content


GET /api/v1/subnets/byKey/{subnet}

Retrieve subnet mappings whose CIDR begins with the given prefix.

Example:

curl -s "https://cdn-manager/api/v1/subnets/byKey/192.168" | jq '.'

GET /api/v1/subnets/byValue/{value}

Retrieve subnet mappings with the given classification value.

Example:

curl -s "https://cdn-manager/api/v1/subnets/byValue/office" | jq '.'

DELETE /api/v1/subnets/byKey/{subnet}

Delete subnet mappings whose CIDR begins with the given prefix.


DELETE /api/v1/subnets/byValue/{value}

Delete subnet mappings with the given classification value.


Next Steps

10.6 - Routing API

GeoIP lookups and IP validation

Overview

The Routing API provides GeoIP information lookup and IP address validation for routing decisions.

Base URL

https://<manager-host>/api/v1/routing

Endpoints

GET /api/v1/routing/geoip

Look up GeoIP information for an IP address.

Query Parameters:

  • ip - IP address to look up

Success Response (200):

{
  "city": {
    "name": "Washington"
  },
  "asn": 64512
}

Errors:

  • 400 - Invalid IP format
  • 500 - Backend failure

Caching: Cache-Control: public, max-age=86400 (24 hours)

Example:

curl -s "https://cdn-manager/api/v1/routing/geoip?ip=149.101.100.0"

GET /api/v1/routing/validate

Validate if an IP address is allowed (not blocked).

Query Parameters:

  • ip - IP address to validate

Success Response (200): Empty body (IP is allowed)

Forbidden Response (403):

Access Denied

Errors:

  • 400 - Invalid IP format
  • 500 - Backend failure

Caching: Cache-Control headers included (default: max-age=300, configurable via [tuning] section)

Example:

curl -i "https://cdn-manager/api/v1/routing/validate?ip=149.101.100.0"

Use Cases

GeoIP-Based Routing

Use the /geoip endpoint to determine the geographic location and ASN of an IP address for routing decisions:

# Get location data for routing
IP_INFO=$(curl -s "https://cdn-manager/api/v1/routing/geoip?ip=203.0.113.50")
CITY=$(echo "$IP_INFO" | jq -r '.city.name')
ASN=$(echo "$IP_INFO" | jq -r '.asn')

echo "Routing based on city: $CITY, ASN: $ASN"

IP Validation

Use the /validate endpoint to check if an IP is allowed before processing requests:

# Check if IP is allowed
RESPONSE=$(curl -s -o /dev/null -w "%{http_code}" \
  "https://cdn-manager/api/v1/routing/validate?ip=203.0.113.50")

if [ "$RESPONSE" = "200" ]; then
  echo "IP is allowed"
elif [ "$RESPONSE" = "403" ]; then
  echo "IP is blocked"
fi

Next Steps

10.7 - Discovery API

Host and namespace discovery

Overview

The Discovery API provides information about discovered hosts and namespaces. Discovery is configured via the Helm chart values.yaml file. Each entry defines a namespace with a list of hostnames.

Base URL

https://<manager-host>/api/v1/discovery

Endpoints

GET /api/v1/discovery/hosts

Return discovered hosts grouped by namespace.

Success Response (200):

{
  "directors": [
    { "name": "director-1.example.com" }
  ],
  "edge-servers": [
    { "name": "cdn1.example.com" },
    { "name": "cdn2.example.com" }
  ]
}

Example:

curl -s "https://cdn-manager/api/v1/discovery/hosts"

GET /api/v1/discovery/namespaces

Return discovery namespaces with their corresponding Confd URIs.

Success Response (200):

[
  {
    "namespace": "edge-servers",
    "confd_uri": "/api/v1/confd/edge-servers"
  },
  {
    "namespace": "directors",
    "confd_uri": "/api/v1/confd/directors"
  }
]

Example:

curl -s "https://cdn-manager/api/v1/discovery/namespaces"

Configuration

Discovery is configured via the Helm chart values.yaml file under cdn-manager.manager.discovery:

cdn-manager:
  manager:
    discovery:
      - namespace: "directors"
        hosts:
          - director-1.example.com
          - director-2.example.com
      - namespace: "edge-servers"
        hosts:
          - cdn1.example.com
          - cdn2.example.com

Each entry defines a namespace with a list of hostnames. Optionally, a pattern field can be specified for regex-based host matching.


Next Steps

10.8 - Metrics API

Metrics submission and aggregation

Overview

The Metrics API allows submission and retrieval of metrics data from CDN components.

Base URL

https://<manager-host>/api/v1/metrics

Endpoints

POST /api/v1/metrics

Submit metrics data.

Request:

{
  "example.com": {
    "metric1": 100,
    "metric2": 200
  }
}

Success: 200 OK

Errors: 500 - Validation/backend errors

Example:

curl -s -X POST "https://cdn-manager/api/v1/metrics" \
  -H "Content-Type: application/json" \
  -d '{
    "example.com": {
      "metric1": 100,
      "metric2": 200
    }
  }'

GET /api/v1/metrics

Return aggregated metrics per host.

Response: JSON object with aggregated metrics per host

Note: Metrics are stored per host for up to 5 minutes. Hosts that stop reporting disappear from aggregation after that window. When no metrics are being reported, returns empty object {}.

Example:

curl -s "https://cdn-manager/api/v1/metrics"

Metrics Retention

  • Metrics are stored for up to 5 minutes in the aggregation layer
  • For long-term metrics storage, data is forwarded to VictoriaMetrics
  • Query historical metrics via Grafana dashboards at /grafana

Next Steps

10.9 - Configuration API

Configuration document management

Overview

The Configuration API provides endpoints for managing the system configuration document. ETag is supported; send If-None-Match for conditional GET (may return 304).

Operational Note: This API is intended for internal verification only. Behavior is undefined in multi-replica clusters because pods do not coordinate config writes.

Base URL

https://<manager-host>/api/v1/configuration

Endpoints

GET /api/v1/configuration

Retrieve the configuration document.

Success: 200 OK with configuration JSON

Conditional GET: Returns 304 Not Modified if If-None-Match header matches current ETag

Example:

# Get ETag from response headers
etag=$(curl -s -D- "https://cdn-manager/api/v1/configuration" | awk '/ETag/{print $2}')

# Conditional GET - returns 304 if config unchanged
curl -s -H "If-None-Match: $etag" "https://cdn-manager/api/v1/configuration" -o /tmp/cfg.json -w "%{http_code}\n"

PUT /api/v1/configuration

Replace the configuration document.

Request:

{
  "feature_flag": false,
  "ratio": 0.25
}

Success: 200 OK

Errors:

  • 400 - Invalid configuration format
  • 500 - Backend failure

DELETE /api/v1/configuration

Delete the configuration document.

Success: 200 OK


ETag Usage

The configuration API supports ETags for optimistic concurrency control:

# 1. Get current config and ETag
response=$(curl -s -D headers.txt "https://cdn-manager/api/v1/configuration")
etag=$(grep -i ETag headers.txt | cut -d' ' -f2 | tr -d '\r')

# 2. Modify the config as needed
modified_config=$(echo "$response" | jq '.feature_flag = true')

# 3. Update with ETag to prevent overwriting concurrent changes
curl -s -X PUT "https://cdn-manager/api/v1/configuration" \
  -H "Content-Type: application/json" \
  -H "If-Match: $etag" \
  -d "$modified_config"

Next Steps

10.10 - Operator UI API

Blocked tokens, user agents, and referrers

Overview

The Operator UI API provides read-only helpers exposing curated selection input content for the operator interface.

Query Parameters: search, sort, limit (same as selection input v1)

Note: Stored keys for user agents/referrers are URL-safe base64; responses decode them to human-readable values.

Base URL

https://<manager-host>/api/v1/operator_ui

Endpoints

Blocked Household Tokens

GET /api/v1/operator_ui/modules/blocked_tokens

List all blocked household tokens.

Success Response (200):

[
  {
    "household_token": "house-001_token-abc",
    "expire_time": 1625247600
  }
]

GET /api/v1/operator_ui/modules/blocked_tokens/{token}

Get details for a specific blocked token.

Success Response (200):

{
  "household_token": "house-001_token-abc",
  "expire_time": 1625247600
}

Blocked User Agents

GET /api/v1/operator_ui/modules/blocked_user_agents

List all blocked user agents.

Success Response (200):

[
  {
    "user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)"
  },
  {
    "user_agent": "curl/7.68.0"
  }
]

GET /api/v1/operator_ui/modules/blocked_user_agents/{encoded}

Get details for a specific blocked user agent. The path variable is URL-safe base64 encoded.

Example:

# Encode the user agent
ENC=$(python3 -c "import base64; print(base64.urlsafe_b64encode(b'curl/7.68.0').decode().rstrip('='))")

# Get details
curl -s "https://cdn-manager/api/v1/operator_ui/modules/blocked_user_agents/$ENC"

Blocked Referrers

GET /api/v1/operator_ui/modules/blocked_referrers

List all blocked referrers.

Success Response (200):

[
  {
    "referrer": "https://spam-example.com"
  }
]

GET /api/v1/operator_ui/modules/blocked_referrers/{encoded}

Get details for a specific blocked referrer. The path variable is URL-safe base64 encoded.

Example:

# Encode the referrer
ENC=$(python3 -c "import base64; print(base64.urlsafe_b64encode(b'spam-example.com').decode().rstrip('='))")

# Get details
curl -s "https://cdn-manager/api/v1/operator_ui/modules/blocked_referrers/$ENC"

URL-Safe Base64 Encoding

The Operator UI API uses URL-safe base64 encoding for path parameters. To encode values:

Python:

import base64

# Encode
encoded = base64.urlsafe_b64encode(b'value').decode().rstrip('=')

# Decode
decoded = base64.urlsafe_b64decode(encoded + '=' * (-len(encoded) % 4)).decode()

Bash (with openssl):

# Encode
echo -n "value" | openssl base64 -urlsafe | tr -d '='

# Decode
echo "encoded" | openssl base64 -urlsafe -d

Next Steps

10.11 - OpenAPI Specification

Complete OpenAPI 3.0 specification

Overview

The CDN Manager API is documented using the OpenAPI 3.0 specification. This appendix provides the complete specification for reference and for generating API clients.

OpenAPI Specification (YAML)

openapi: 3.0.3
info:
  title: AgileTV CDN Manager API
  version: "1.0"
servers:
  - url: https://<manager-host>/api
    description: CDN Manager API server
paths:
  /v1/auth/login:
    post:
      summary: Login and create session
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/LoginRequest'
      responses:
        '200':
          description: Session created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/LoginResponse'
        '401': { description: Unauthorized, content: { application/json: { schema: { $ref: '#/components/schemas/ErrorResponse' } } } }
        '500': { description: Internal error, content: { application/json: { schema: { $ref: '#/components/schemas/ErrorResponse' } } } }
  /v1/auth/token:
    post:
      summary: Exchange session for access token
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/TokenRequest'
      responses:
        '200':
          description: Access token
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TokenResponse'
        '401': { description: Unauthorized, content: { application/json: { schema: { $ref: '#/components/schemas/ErrorResponse' } } } }
        '500': { description: Internal error, content: { application/json: { schema: { $ref: '#/components/schemas/ErrorResponse' } } } }
  /v1/auth/logout:
    post:
      summary: Revoke session
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/LogoutRequest'
      responses:
        '200': { description: Revoked, content: { application/json: { schema: { $ref: '#/components/schemas/LogoutResponse' } } } }
        '401': { description: Unauthorized, content: { application/json: { schema: { $ref: '#/components/schemas/ErrorResponse' } } } }
        '500': { description: Internal error, content: { application/json: { schema: { $ref: '#/components/schemas/ErrorResponse' } } } }
  /v1/selection_input{tail}:
    get:
      summary: Read selection input
      parameters:
        - $ref: '#/components/parameters/Tail'
        - $ref: '#/components/parameters/Search'
        - $ref: '#/components/parameters/Sort'
        - $ref: '#/components/parameters/Limit'
      responses:
        '200': { description: JSON value }
        '400': { description: Bad request, content: { application/json: { schema: { $ref: '#/components/schemas/ErrorResponse' } } } }
        '404': { description: Not found }
        '500': { description: Backend failure }
    post:
      summary: Merge selection input
      parameters:
        - $ref: '#/components/parameters/Tail'
        - $ref: '#/components/parameters/Ttl'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/AnyJson'
      responses:
        '201': { description: Created, content: { application/json: { schema: { $ref: '#/components/schemas/AnyJson' } } } }
        '500': { description: Backend failure }
        '503': { description: Service unavailable }
    delete:
      summary: Delete selection input
      parameters:
        - $ref: '#/components/parameters/Tail'
      responses:
        '204': { description: Deleted }
        '503': { description: Service unavailable }
  /v2/selection_input{tail}:
    get:
      summary: Read selection input v2
      parameters:
        - $ref: '#/components/parameters/TailV2'
        - $ref: '#/components/parameters/Search'
      responses:
        '200': { description: JSON value }
        '400': { description: Invalid search pattern }
        '404': { description: Not found }
        '500': { description: Backend failure }
    put:
      summary: Replace selection input v2
      parameters:
        - $ref: '#/components/parameters/TailV2'
        - $ref: '#/components/parameters/Ttl'
        - $ref: '#/components/parameters/CorrelationId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/AnyJson'
      responses:
        '200': { description: Updated }
        '500': { description: Backend failure }
    post:
      summary: Push to selection input v2
      parameters:
        - $ref: '#/components/parameters/TailV2'
        - $ref: '#/components/parameters/Ttl'
        - $ref: '#/components/parameters/CorrelationId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/AnyJson'
      responses:
        '200': { description: Pushed }
        '500': { description: Backend failure }
    delete:
      summary: Delete selection input v2
      parameters:
        - $ref: '#/components/parameters/TailV2'
      responses:
        '204': { description: Deleted }
        '500': { description: Backend failure }
  /v1/configuration:
    get:
      summary: Read configuration
      responses:
        '200': { description: Configuration, content: { application/json: { schema: { $ref: '#/components/schemas/AnyJson' } } }, headers: { ETag: { schema: { type: string } } } }
        '304': { description: Not modified }
        '500': { description: Backend failure }
    put:
      summary: Replace configuration
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/AnyJson'
      responses:
        '200': { description: Replaced }
        '500': { description: Backend failure }
    delete:
      summary: Delete configuration
      responses:
        '200': { description: Deleted }
        '500': { description: Backend failure }
  /v1/routing/geoip:
    get:
      summary: GeoIP lookup
      parameters:
        - name: ip
          in: query
          required: true
          schema: { type: string }
      responses:
        '200': { description: GeoIP data, content: { application/json: { schema: { $ref: '#/components/schemas/GeoIpResponse' } } } }
        '400': { description: Invalid IP }
        '500': { description: Backend failure }
  /v1/routing/validate:
    get:
      summary: Validate routing
      parameters:
        - name: ip
          in: query
          required: true
          schema: { type: string }
      responses:
        '200': { description: Allowed }
        '403': { description: Access Denied }
        '400': { description: Invalid IP }
        '500': { description: Backend failure }
  /v1/metrics:
    post:
      summary: Ingest metrics
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/MetricsIngress'
      responses:
        '200': { description: Stored }
        '500': { description: Validation/back-end error }
    get:
      summary: Aggregate metrics
      responses:
        '200': { description: Aggregated metrics, content: { application/json: { schema: { $ref: '#/components/schemas/AnyJson' } } } }
        '500': { description: Backend failure }
  /v1/discovery/hosts:
    get:
      summary: List discovered hosts by namespace
      responses:
        '200':
          description: Discovered hosts keyed by namespace
          content:
            application/json:
              schema:
                type: object
                additionalProperties:
                  type: array
                  items:
                    $ref: '#/components/schemas/DiscoveryHost'
        '500': { description: Backend failure }
  /v1/discovery/namespaces:
    get:
      summary: List discovery namespaces with Confd URIs
      responses:
        '200':
          description: Namespaces with Confd links
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/DiscoveryNamespace'
        '500': { description: Backend failure }
  /v1/datastore:
    get:
      summary: List datastore keys
      responses:
        '200': { description: Keys list, content: { application/json: { schema: { type: array, items: { type: string } } } } }
        '500': { description: Backend failure }
  /v1/datastore/{key}:
    get:
      summary: Get a JSON value by key
      parameters:
        - name: key
          in: path
          required: true
          schema: { type: string }
      responses:
        '200': { description: JSON value, content: { application/json: { schema: { $ref: '#/components/schemas/AnyJson' } } } }
        '404': { description: Not found }
        '500': { description: Backend failure }
    post:
      summary: Create a JSON value at key
      parameters:
        - name: key
          in: path
          required: true
          schema: { type: string }
        - $ref: '#/components/parameters/Ttl'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/AnyJson'
      responses:
        '201': { description: Created }
        '409': { description: Conflict (already exists) }
        '500': { description: Backend failure }
    put:
      summary: Update/replace a JSON value at key
      parameters:
        - name: key
          in: path
          required: true
          schema: { type: string }
        - $ref: '#/components/parameters/Ttl'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/AnyJson'
      responses:
        '200': { description: Updated }
        '404': { description: Not found }
        '500': { description: Backend failure }
    delete:
      summary: Delete a datastore key
      parameters:
        - name: key
          in: path
          required: true
          schema: { type: string }
      responses:
        '204': { description: Deleted }
        '500': { description: Backend failure }
  /v1/subnets:
    get:
      summary: List all subnet mappings
      responses:
        '200': { description: Subnet mappings, content: { application/json: { schema: { type: object, additionalProperties: { type: string } } } } }
        '500': { description: Backend failure }
    put:
      summary: Create or update subnet mappings
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties:
                type: string
              description: Map of CIDR strings to classification values
      responses:
        '200': { description: Created }
        '400': { description: Invalid CIDR format }
        '500': { description: Backend failure }
    delete:
      summary: Delete all subnet mappings
      responses:
        '204': { description: Deleted }
        '500': { description: Backend failure }
  /v1/subnets/byKey/{subnet}:
    get:
      summary: Get subnet mappings by CIDR prefix
      parameters:
        - name: subnet
          in: path
          required: true
          schema: { type: string }
      responses:
        '200': { description: Subnet mappings, content: { application/json: { schema: { type: object, additionalProperties: { type: string } } } } }
        '500': { description: Backend failure }
    delete:
      summary: Delete subnet mappings by CIDR prefix
      parameters:
        - name: subnet
          in: path
          required: true
          schema: { type: string }
      responses:
        '204': { description: Deleted }
        '500': { description: Backend failure }
  /v1/subnets/byValue/{value}:
    get:
      summary: Get subnet mappings by value
      parameters:
        - name: value
          in: path
          required: true
          schema: { type: string }
      responses:
        '200': { description: Subnet mappings, content: { application/json: { schema: { type: object, additionalProperties: { type: string } } } } }
        '500': { description: Backend failure }
    delete:
      summary: Delete subnet mappings by value
      parameters:
        - name: value
          in: path
          required: true
          schema: { type: string }
      responses:
        '204': { description: Deleted }
        '500': { description: Backend failure }
  /v1/operator_ui/modules/blocked_tokens:
    get:
      summary: List blocked tokens
      parameters:
        - $ref: '#/components/parameters/Search'
        - $ref: '#/components/parameters/Sort'
        - $ref: '#/components/parameters/Limit'
      responses:
        '200': { description: Blocked tokens, content: { application/json: { schema: { type: array, items: { $ref: '#/components/schemas/BlockedToken' } } } } }
        '400': { description: Parse error, content: { application/json: { schema: { $ref: '#/components/schemas/ErrorResponse' } } } }
  /v1/operator_ui/modules/blocked_tokens/{token}:
    get:
      summary: Get blocked token
      parameters:
        - name: token
          in: path
          required: true
          schema: { type: string }
      responses:
        '200': { description: Blocked token, content: { application/json: { schema: { $ref: '#/components/schemas/BlockedToken' } } } }
        '404': { description: Not found }
        '400': { description: Parse error, content: { application/json: { schema: { $ref: '#/components/schemas/ErrorResponse' } } } }
  /v1/operator_ui/modules/blocked_user_agents:
    get:
      summary: List blocked user agents
      parameters:
        - $ref: '#/components/parameters/Search'
        - $ref: '#/components/parameters/Sort'
        - $ref: '#/components/parameters/Limit'
      responses:
        '200': { description: Blocked user agents, content: { application/json: { schema: { type: array, items: { $ref: '#/components/schemas/BlockedUserAgent' } } } } }
        '400': { description: Parse error, content: { application/json: { schema: { $ref: '#/components/schemas/ErrorResponse' } } } }
  /v1/operator_ui/modules/blocked_user_agents/{encoded}:
    get:
      summary: Get blocked user agent
      parameters:
        - name: encoded
          in: path
          required: true
          schema: { type: string }
      responses:
        '200': { description: Blocked user agent, content: { application/json: { schema: { $ref: '#/components/schemas/BlockedUserAgent' } } } }
        '400': { description: Parse error, content: { application/json: { schema: { $ref: '#/components/schemas/ErrorResponse' } } } }
  /v1/operator_ui/modules/blocked_referrers:
    get:
      summary: List blocked referrers
      parameters:
        - $ref: '#/components/parameters/Search'
        - $ref: '#/components/parameters/Sort'
        - $ref: '#/components/parameters/Limit'
      responses:
        '200': { description: Blocked referrers, content: { application/json: { schema: { type: array, items: { $ref: '#/components/schemas/BlockedReferrer' } } } } }
        '400': { description: Parse error, content: { application/json: { schema: { $ref: '#/components/schemas/ErrorResponse' } } } }
  /v1/operator_ui/modules/blocked_referrers/{encoded}:
    get:
      summary: Get blocked referrer
      parameters:
        - name: encoded
          in: path
          required: true
          schema: { type: string }
      responses:
        '200': { description: Blocked referrer, content: { application/json: { schema: { $ref: '#/components/schemas/BlockedReferrer' } } } }
        '400': { description: Parse error, content: { application/json: { schema: { $ref: '#/components/schemas/ErrorResponse' } } } }
  /v1/health/alive:
    get:
      summary: Liveness check
      responses:
        '200': { description: Alive, content: { application/json: { schema: { $ref: '#/components/schemas/HealthStatus' } } } }
  /v1/health/ready:
    get:
      summary: Readiness check
      responses:
        '200': { description: Ready, content: { application/json: { schema: { $ref: '#/components/schemas/HealthStatus' } } } }
        '503': { description: Unready, content: { application/json: { schema: { $ref: '#/components/schemas/HealthStatus' } } } }
components:
  parameters:
    Tail:
      name: tail
      in: path
      required: true
      schema: { type: string }
    TailV2:
      name: tail
      in: path
      required: true
      schema: { type: string }
    Search:
      name: search
      in: query
      required: false
      schema: { type: string }
    Sort:
      name: sort
      in: query
      required: false
      schema: { type: string, enum: [asc, desc] }
    Limit:
      name: limit
      in: query
      required: false
      schema: { type: integer, minimum: 1 }
    Ttl:
      name: ttl
      in: query
      required: false
      schema: { type: string, description: Humantime duration }
    CorrelationId:
      name: correlation_id
      in: query
      required: false
      schema: { type: string }
  schemas:
    LoginRequest:
      type: object
      required: [email, password]
      properties:
        email: { type: string, format: email }
        password: { type: string, format: password }
    LoginResponse:
      type: object
      properties:
        session_id: { type: string }
        session_token: { type: string }
        verified_at: { type: string, format: date-time }
        expires_at: { type: string, format: date-time }
    LogoutRequest:
      type: object
      required: [session_id]
      properties:
        session_id: { type: string }
        session_token: { type: string }
    LogoutResponse:
      type: object
      properties:
        status: { $ref: '#/components/schemas/StatusValue' }
    TokenRequest:
      type: object
      required: [session_id, session_token, grant_type]
      properties:
        session_id: { type: string }
        session_token: { type: string }
        scope: { type: string }
        grant_type: { type: string, enum: [session] }
    TokenResponse:
      type: object
      required: [access_token, scope, expires_in, token_type]
      properties:
        access_token: { type: string }
        scope: { type: string }
        expires_in: { type: integer, format: int64 }
        token_type: { type: string, enum: [bearer] }
    ErrorResponse:
      type: object
      properties:
        message: { type: string }
    AnyJson:
      description: Arbitrary JSON value
    MetricsIngress:
      type: object
      additionalProperties:
        type: object
        additionalProperties: { type: number }
    GeoIpResponse:
      type: object
      properties:
        city:
          type: object
          properties:
            name: { type: string }
        asn: { type: integer }
        is_anonymous: { type: boolean }
    BlockedToken:
      type: object
      properties:
        household_token: { type: string }
        expire_time: { type: integer, format: int64 }
    BlockedUserAgent:
      type: object
      properties:
        user_agent: { type: string }
    BlockedReferrer:
      type: object
      properties:
        referrer: { type: string }
    DiscoveryHost:
      type: object
      properties:
        name: { type: string }
    DiscoveryNamespace:
      type: object
      properties:
        namespace: { type: string }
        confd_uri: { type: string }
    HealthStatus:
      type: object
      properties:
        status: { $ref: '#/components/schemas/StatusValue' }
    StatusValue:
      type: string
      enum: [Ok, Fail]

Using the OpenAPI Specification

Generating API Clients

The OpenAPI specification can be used to generate client libraries in multiple languages:

Using openapi-generator:

# Generate Python client
openapi-generator generate -i openapi.yaml -g python -o ./python-client

# Generate TypeScript client
openapi-generator generate -i openapi.yaml -g typescript-axios -o ./typescript-client

# Generate Go client
openapi-generator generate -i openapi.yaml -g go -o ./go-client

Using swagger-codegen:

swagger-codegen generate -i openapi.yaml -l python -o ./python-client

Validating the Specification

To validate the OpenAPI specification:

# Using swagger-cli
swagger-cli validate openapi.yaml

# Using spectral
spectral lint openapi.yaml

Next Steps

11 - Troubleshooting Guide

Common issues and resolution procedures

Overview

This guide provides troubleshooting procedures for common issues encountered when operating the AgileTV CDN Manager (ESB3027). Use the diagnostic commands and resolution steps to identify and resolve problems.

Diagnostic Tools

On a server, acd-admin covers the most common checks and fixes by admin target name: acd-admin status, acd-admin logs <target> and acd-admin restart <target>.

Cluster Status

# Check node status
kubectl get nodes

# Check all pods
kubectl get pods -A

# Check events sorted by time
kubectl get events --sort-by='.lastTimestamp'

# Check resource usage
kubectl top nodes
kubectl top pods

Component Status

# Check deployments
kubectl get deployments

# Check statefulsets
kubectl get statefulsets

# Check persistent volumes
kubectl get pvc
kubectl get pv

# Check services
kubectl get services

# Check ingress
kubectl get ingress

Common Issues

Pods Stuck in Pending State

Symptoms: Pods remain in Pending state indefinitely.

Causes:

  • Insufficient cluster resources (CPU/memory)
  • No nodes match scheduling constraints
  • PersistentVolume not available

Diagnosis:

# Describe the pending pod
kubectl describe pod <pod-name>

# Check events for scheduling failures
kubectl get events --field-selector reason=FailedScheduling

# Check node capacity
kubectl describe nodes | grep -A 5 "Allocated resources"

# Check available PVs
kubectl get pv

Resolution:

# Free up resources by scaling down non-critical workloads
kubectl scale deployment <deployment> --replicas=0

# Or add additional nodes to the cluster

# If PV is stuck, delete and recreate
kubectl delete pvc <pvc-name>
kubectl delete pod <pod-name>

Pods Stuck in ContainerCreating

Symptoms: Pods remain in ContainerCreating state.

Causes:

  • Image pull failures
  • Volume mount issues
  • Network configuration problems

Diagnosis:

kubectl describe pod <pod-name>

# Check for image pull errors
kubectl get events | grep -i "failed to pull"

# Check volume mount status
kubectl get events | grep -i "mount"

Resolution:

# For image pull issues, verify image exists and credentials
kubectl get secret <pull-secret-name> -o yaml

# For volume issues, check Longhorn volume status
kubectl get volumes -n longhorn-system

# Delete stuck pod to trigger recreation
kubectl delete pod <pod-name> --force --grace-period=0

Persistent Volume Mount Failures

Symptoms: Pod fails to start with error “AttachVolume.Attach failed for volume… is not ready for workloads” or similar volume attachment errors.

Causes:

  • Longhorn volume created but unable to be successfully mounted
  • Network connectivity issues between nodes (Longhorn requires iSCSI and NFS traffic)
  • Longhorn service unhealthy
  • Incorrect storage class configuration

Diagnosis:

# Describe the failing pod to see the error
kubectl describe pod <pod-name>

# Check Longhorn volumes status
kubectl get volumes -n longhorn-system

# Check Longhorn UI for detailed volume status
kubectl port-forward -n longhorn-system svc/longhorn-frontend 8080:80
# Access: http://localhost:8080

Resolution:

# Longhorn traffic runs over the pod network, which deploy trusts between nodes.
# Check that each node's firewall still matches what deploy set up: run deploy
# again, and it stops with an [ACD-VERIFY-...] code if a node's network differs.

# Check Longhorn is healthy
kubectl get pods -n longhorn-system

# If volume is stuck, delete PVC and pod to trigger recreation
kubectl delete pvc <pvc-name>
kubectl delete pod <pod-name>

Pods in CrashLoopBackOff

Symptoms: Pods repeatedly crash and restart.

Causes:

  • Application configuration errors
  • Missing dependencies (database not ready)
  • Resource limits too low
  • Liveness probe failures

Diagnosis:

# View current logs
kubectl logs <pod-name>

# View previous instance logs
kubectl logs <pod-name> -p

# Describe pod for restart reasons
kubectl describe pod <pod-name>

# Check if dependencies are healthy
kubectl get pods | grep -E "(postgres|kafka|redis)"

Resolution:

# For dependency issues, wait for dependencies to be ready
kubectl wait --for=condition=Ready pod/<dependency-pod> --timeout=300s

# For resource issues, increase limits
kubectl edit deployment <deployment-name>

# For configuration issues, check ConfigMaps and Secrets
kubectl get configmap <configmap-name> -o yaml
kubectl get secret <secret-name> -o yaml

# Restart the deployment
kubectl rollout restart deployment/<deployment-name>

Pods in Terminating State

Symptoms: Pods stuck in Terminating state indefinitely.

Causes:

  • Volume detachment issues
  • Node communication problems
  • Finalizer blocking deletion

Diagnosis:

kubectl describe pod <pod-name>

# Check if node is reachable
kubectl get nodes

# Check finalizers
kubectl get pod <pod-name> -o jsonpath='{.metadata.finalizers}'

Resolution:

# Force delete the pod
kubectl delete pod <pod-name> --force --grace-period=0

# If node is unreachable, drain and remove from cluster
acd-admin drain <node-name>
# Add --force only if drain refuses and you accept losing the pods it names.
# Pods on an unreachable node never finish terminating, so once drain is
# waiting only on those, press Ctrl-C. The node stays drained.
kubectl delete node <node-name>

acd-admin drain names each pod it is waiting on, with when its deletion started and the finalizer holding it, if any.

Leftover acd-admin Pods

Symptoms: acd-admin-* pods remain in the acd-system namespace after an SSH session was lost.

Cause: Each acd-admin command runs in its own pod, which is removed when the command ends, Ctrl-C included. A session lost so abruptly that the command cannot clean up leaves its pod behind, holding its resources. Its command may still be running, such as a drain or the wait of a restart. A drain never evicts these pods. A leftover pod ends on its own at its session deadline, which for a drain, shell or exec is 12 hours and for logs -f 24 hours: see acd-admin.

Diagnosis:

# The age tells a leftover from a command someone is running now
kubectl get pods -n acd-system

Resolution:

kubectl delete pod -n acd-system <pod-name>

Deleting the pod stops its command. A drain stopped this way leaves the node as Ctrl-C does: drained, with the pods already moved where they went. A restart already started carries on in the cluster.

Service Unreachable

Symptoms: Service endpoints not accessible.

Causes:

  • No ready pods backing the service
  • Network policy blocking traffic
  • Service port mismatch

Diagnosis:

# Check service endpoints
kubectl get endpoints <service-name>

# Check if pods are ready
kubectl get pods -l app=<label>

# Check network policies
kubectl get networkpolicies

# Test connectivity from within cluster
kubectl run test --rm -it --image=busybox -- wget -O- <service-name>:<port>

Resolution:

# Ensure pods are ready and matching service selector
kubectl get pods --show-labels

# Check service selector matches pod labels
kubectl get service <service-name> -o jsonpath='{.spec.selector}'

# Temporarily disable network policy for testing
kubectl edit networkpolicy <policy-name>

Ingress Not Working

Symptoms: External access via ingress fails.

Causes:

  • Traefik ingress controller not running
  • Ingress configuration errors
  • TLS certificate issues
  • DNS resolution problems

Diagnosis:

# Check Traefik pods
kubectl get pods -n kube-system -l app.kubernetes.io/name=traefik

# Check ingress resources
kubectl get ingress

# Describe ingress for errors
kubectl describe ingress <ingress-name>

# Check Traefik logs
kubectl logs -n kube-system -l app.kubernetes.io/name=traefik

# Test DNS resolution
nslookup <hostname>

Resolution:

# Restart Traefik, which runs as a DaemonSet: one pod per node
kubectl rollout restart daemonset -n kube-system traefik

# Fix ingress configuration
kubectl edit ingress <ingress-name>

# Renew or recreate TLS secret
kubectl create secret tls <secret-name> --cert=tls.crt --key=tls.key \
  --dry-run=client -o yaml | kubectl apply -f -

# Verify hostname matches certificate
openssl x509 -in tls.crt -noout -subject -issuer

Database Connection Failures

Symptoms: Application cannot connect to PostgreSQL.

Causes:

  • PostgreSQL cluster not ready
  • Connection pool exhausted
  • Network connectivity issues
  • Authentication failures

Diagnosis:

# Check PostgreSQL cluster status
kubectl get clusters

# Check PostgreSQL pods
kubectl get pods -l app.kubernetes.io/name=postgresql

# Check PostgreSQL logs
kubectl logs -l app.kubernetes.io/name=postgresql

# Test connectivity
kubectl exec -it <app-pod> -- psql -h <postgres-service> -U <user> -d <database>

Resolution:

# Wait for PostgreSQL to be ready
kubectl wait --for=condition=Ready pod -l app.kubernetes.io/name=postgresql --timeout=300s

# Check connection string in application config
kubectl get secret <secret-name> -o jsonpath='{.data}' | base64 -d

# Restart application pods
kubectl rollout restart deployment/<deployment-name>

Kafka Connection Issues

Symptoms: Application cannot connect to Kafka.

Causes:

  • Kafka controllers not ready
  • Topic not created
  • Network connectivity issues

Diagnosis:

# Check Kafka pods
kubectl get pods -l app.kubernetes.io/name=kafka

# Check Kafka logs
kubectl logs -l app.kubernetes.io/name=kafka

# List topics
kubectl exec -it <kafka-pod> -- kafka-topics.sh --bootstrap-server localhost:9092 --list

Resolution:

# Wait for Kafka controllers to be ready
kubectl wait --for=condition=Ready pod -l app.kubernetes.io/name=kafka --timeout=300s

# Create missing topic
kubectl exec -it <kafka-pod> -- kafka-topics.sh --bootstrap-server localhost:9092 \
  --create --topic <topic-name> --partitions 3 --replication-factor 3

# Restart application to reconnect
kubectl rollout restart deployment/<deployment-name>

Redis Connection Issues

Symptoms: Application cannot connect to Redis.

Diagnosis:

# Check Redis pods
kubectl get pods -l app.kubernetes.io/name=redis

# Check Redis logs
kubectl logs -l app.kubernetes.io/name=redis

# Test connectivity
kubectl exec -it <redis-pod> -- redis-cli ping

Resolution:

# Wait for Redis to be ready
kubectl wait --for=condition=Ready pod -l app.kubernetes.io/name=redis --timeout=300s

# Restart application
kubectl rollout restart deployment/<deployment-name>

High Memory Usage

Symptoms: Pods approaching or hitting memory limits.

Diagnosis:

# Check memory usage
kubectl top pods

# Check OOMKilled pods
kubectl get pods --field-selector=status.phase=Failed

# Check for memory leaks in logs
kubectl logs <pod-name> | grep -i "memory\|oom"

Resolution:

# Temporarily increase memory limit
kubectl edit deployment <deployment-name>

# Or scale horizontally if HPA is enabled
kubectl scale deployment <deployment-name> --replicas=<n>

# Long-term: set the resources in /etc/acd/deploy/values.override.yaml and run deploy

High CPU Usage

Symptoms: Pods consistently using high CPU.

Diagnosis:

# Check CPU usage
kubectl top pods

# Check for runaway processes
kubectl top pods --sort-by=cpu

Resolution:

# Scale horizontally if HPA is enabled
kubectl scale deployment <deployment-name> --replicas=<n>

# Or increase CPU limits
kubectl edit deployment <deployment-name>

Persistent Volume Issues

Symptoms: PVC not binding or volume errors.

Diagnosis:

# Check PVC status
kubectl get pvc

# Check PV status
kubectl get pv

# Check Longhorn volumes
kubectl get volumes -n longhorn-system

# Check Longhorn UI for details
kubectl port-forward -n longhorn-system svc/longhorn-frontend 8080:80

Resolution:

# For stuck PVC, delete and recreate
kubectl delete pvc <pvc-name>
kubectl delete pod <pod-name>

# For Longhorn issues, check Longhorn UI
# Access via http://localhost:8080

# Recreate Longhorn volume if necessary

Zitadel Authentication Failures

Symptoms: Users cannot authenticate via Zitadel.

Causes:

  • CORS configuration mismatch
  • External domain misconfigured
  • Zitadel pods not healthy

Diagnosis:

# Check Zitadel pods
kubectl get pods -l app.kubernetes.io/name=zitadel

# Check Zitadel logs
kubectl logs -l app.kubernetes.io/name=zitadel

# Verify external domain configuration
grep -A 5 ExternalDomain /etc/acd/deploy/values.yaml

Resolution: Browse to the main manager hostname, the first entry of manager_hostnames in /etc/acd/deploy/group_vars/all.yaml. deploy sets Zitadel’s external domain from it. Sign-ins under any other name, including a machine’s IP address, fail.

# Restart Zitadel
kubectl rollout restart deployment -l app.kubernetes.io/name=zitadel

Certificate Errors

Symptoms: TLS/SSL errors in browser or API calls.

Diagnosis:

# Check certificate expiration
kubectl get secret <tls-secret> -o jsonpath='{.data.tls\.crt}' | base64 -d | \
  openssl x509 -noout -dates

# Check certificate subject
kubectl get secret <tls-secret> -o jsonpath='{.data.tls\.crt}' | base64 -d | \
  openssl x509 -noout -subject -issuer

Resolution: Replace the certificate and key in /etc/acd/deploy/certs/ and run deploy. See TLS Certificates.

/mnt/esb3027/deploy

Log Collection

The AgileTV CDN Manager ships with acd-support-dump, a single command that captures a point-in-time diagnostic snapshot of the whole cluster into one compressed, redacted tarball suitable for attaching to a support ticket. It is preinstalled on every control-plane node and works in air-gapped environments with no additional setup or downloads.

Running a support dump

Run the command from a control-plane (server) node:

acd-support-dump

The tool writes a tarball named support-dump-<hostname>-<timestamp>.tar.gz to the current directory and prints the path when it finishes. Collection usually takes a minute or two.

To choose the output location, pass a path as the first argument:

acd-support-dump /tmp/support-dump.tar.gz

See the acd-support-dump reference for its options and exit status.

What the dump contains

A support dump captures the management-plane state needed to diagnose the majority of field issues without direct access to the cluster:

  • Pod logs from the Manager workloads, covering up to the last 72 hours.
  • Cluster resources and events, including OOMKilled, restart reasons, and eviction events.
  • Node system logs (journald) — kernel messages such as the out-of-memory killer, plus k3s, containerd, and systemd unit output.
  • Database health for PostgreSQL and Redis — connectivity, version, sizes, and replication/keyspace statistics. This is health information only; application data is never collected.
  • Longhorn volume and replica health.
  • Metric history for the last 72 hours (node CPU, memory, and disk; per-container memory; container restarts; and volume usage) from the monitoring stack.
  • Automated analyzers that flag common failure modes — out-of-memory kills, disk exhaustion, and unhealthy or crash-looping pods — as a quick triage summary.

Kubernetes Secret values are excluded, and the tool’s built-in redactors run over the collected data before the tarball is written.

Note: A support dump captures Manager (management-plane) state only; it does not collect data-plane state. It also captures database health, not database contents — for data, use the backup procedures in the Operations Guide.

Sharing a support dump

Attach the generated .tar.gz to your support ticket. Because it contains log output and cluster configuration, treat it as potentially sensitive and share it only through approved support channels.

Manual Log Collection

If acd-support-dump is unavailable — for example, on an older installation — collect the essential logs by hand:

# Capture timestamp once to ensure consistency
TS=$(date +%Y%m%d-%H%M%S)

# Create log collection directory
mkdir -p ~/cdn-logs-$TS
cd ~/cdn-logs-$TS

# Collect pod logs
for pod in $(kubectl get pods -o name); do
  kubectl logs $pod > ${pod#pod/}.log 2>&1
  kubectl logs $pod -p > ${pod#pod/}.previous.log 2>&1 || true
done

# Collect cluster events
kubectl get events --sort-by='.lastTimestamp' > events.log

# Collect pod descriptions
for pod in $(kubectl get pods -o name); do
  kubectl describe $pod > ${pod#pod/}.describe.txt
done

# Compress for transfer
tar czf cdn-logs-$TS.tar.gz *.log *.txt

Emergency Procedures

Complete Cluster Recovery

If the cluster is completely down:

  1. Assess node status:

    kubectl get nodes
    
  2. Restart k3s on every machine. Start it on all the servers at once, without waiting for one to finish before the next, then on the agents. The unit is k3s on servers and k3s-agent on agents:

    # On every server, at the same time
    systemctl restart --no-block k3s
    
    # Then on every agent
    systemctl restart --no-block k3s-agent
    

    The cluster datastore needs a majority of the servers running, so a server’s k3s does not finish starting until enough other servers have started too. With three servers, the Kubernetes API returns once two are up. Restart servers one at a time only during maintenance, while the rest of the cluster is running: see Safety.

  3. Restore from backup if necessary: see Backups.

The first server holds /etc/acd/deploy and the backups. Keep a copy of the latest backup folder off it, so that you can recover the configuration if the first server is lost. The backup folder holds every Kubernetes Secret: protect the copy as Backups describes.

Data Recovery

For data recovery scenarios:

  • PostgreSQL: Use Cloudnative PG backup/restore
  • Longhorn: Restore from volume snapshots
  • Kafka: Topics other than selection_input survive the loss of one broker. selection_input has a single replica: see Data Replication

Getting Help

If issues persist:

  1. Generate a support dump with acd-support-dump (see Log Collection), or collect logs manually if the tool is unavailable
  2. Check release notes for known issues
  3. Contact support with the support dump and a description of the issue

Next Steps

After resolving issues:

  1. Operations Guide - Preventive maintenance procedures
  2. Configuration Guide - Verify configuration is correct
  3. Architecture Guide - Understand component dependencies

12 - Command Line Reference

Reference for the commands that install, operate and support the CDN Manager

Overview

These pages are the reference for each command line tool shipped with the CDN Manager: what it accepts, what it reads and writes, and how it exits. For step-by-step use, follow the links under each page’s See Also.

CommandWhere it runsPurpose
deployThe first server, from the installation ISOInstalls, upgrades and reconfigures the cluster, and takes backups
acd-adminAny server, as rootRestarts, logs, shells, status and node maintenance by admin target name
acd-support-dumpAny server, as rootWrites a redacted diagnostic tarball for AgileTV support

12.1 - deploy

Install, upgrade or reconfigure the cluster, and take backups

Synopsis

deploy init [prod|lab]
deploy [run] [-i <inventory>] [<ansible-playbook option>...]
deploy backup [-i <inventory>] [<ansible-playbook option>...]
deploy -h | --help

Description

deploy is on the installation ISO, at /mnt/esb3027/deploy; it is not on the PATH. Run it as root on the first server in the inventory, with the ISO mounted at /mnt/esb3027. It reaches every machine in the inventory, the first server included, over SSH.

deploy reads its configuration from /etc/acd/deploy, applies it to every machine, and installs or upgrades the cdn-platform Helm release. The first run installs; every later run re-applies the configuration and changes only what differs. Running it again is safe, including after a failed run. It is the only supported way to change what is installed.

When deploy stops on a problem it recognizes, its message starts with a code such as [ACD-TLS-HOSTNAME]. Each code is described in Installation Troubleshooting.

With -h or --help, deploy prints its usage and exits.

Commands

init

deploy init [prod|lab]

Prepares the first server. Run it once before the first deploy, and again after mounting a new ISO elsewhere. It:

  1. Installs ansible-core from the operating system’s repositories with dnf, unless it is already installed.
  2. Installs the Ansible collections bundled on the ISO, every time, replacing any version already installed.
  3. Creates /etc/acd/deploy and every file in it that is missing: see Files. It never overwrites a file that exists.
  4. Points preset.yaml at the chosen preset on the ISO.
ArgumentEffect
prodUses the production preset. A new configuration gets prod when no preset is given.
labUses the lab preset.
(none)On an existing configuration, keeps the current preset and re-points preset.yaml at the mounted ISO.

deploy init takes no other arguments.

run

deploy [run] [-i <inventory>] [<ansible-playbook option>...]

Runs the deployment. run is the default and can be left out. Every run takes a backup before it changes anything, even with the preflight checks skipped, and stops if the backup fails. The stages of a run are described in Running deploy.

backup

deploy backup [-i <inventory>] [<ansible-playbook option>...]

Takes a backup and changes nothing. It runs only the gather-inputs and backup stages, so it also skips the preflight checks. See Files for what a backup holds.

Options

OptionCommandsEffect
-i <inventory>run, backupUses this inventory file instead of /etc/acd/deploy/inventory.ini. The settings still come from /etc/acd/deploy/group_vars/all.yaml, which must exist.
-h, --helpallPrints the usage and exits with status 0.
--skip-tags preflightrunSkips the preflight checks. The run still works out every setting and takes the backup. The check of each machine’s network after it is changed cannot be skipped.
-v, -vv, -vvvrun, backupMore detailed output, for support.

Every other option is passed to ansible-playbook. These are rejected, because every run must start with gathering inputs and the backup:

OptionRejected with
--start-at-task, or any prefix of it from --staevery command
--skip-tags naming alwaysevery command
-t, --tags, or any prefix of --tags from --tadeploy backup
--limitrun: the run stops with [ACD-PARTIAL-RUN], because what each machine’s firewall trusts depends on all of them

Environment

VariableEffect
ACD_CONFIG_DIRThe configuration folder. Default: /etc/acd/deploy. Every path on this page under /etc/acd/deploy moves with it.

Files

Configuration

In /etc/acd/deploy, created by deploy init and edited by you:

FileContents
inventory.iniThe machines and their roles. See Inventory.
group_vars/all.yamlThe site settings. See Site Settings.
values.override.yamlYour application settings. See Application Settings.
preset.yamlA link to the chosen preset on the ISO. Change it with deploy init prod or deploy init lab.
certs/The TLS certificate and key. See TLS Certificates.
maxmind/The MaxMind databases. See GeoIP Databases.
README.mdA copy of the configuration reference shipped on the ISO.

Written by a Run

In /etc/acd/deploy:

FileContents
deploy.logEvery run’s output, newest last, each under a header with its start time in UTC
values.yamlThe Helm values the cluster received. Generated: do not edit. See Application Settings.
decision-record.jsonWhat the run worked out about the cluster and each machine
probe/<machine>.jsonEverything the run read from each machine
support-bundle-acd.tar.gzWritten only when the final health check fails

Backups

Each run, and each deploy backup, writes a folder on the first server, readable only by root: <backup_dir>/<UTC time>-<release>/, where <release> is the version on the installation ISO, such as /var/lib/acd/backups/20261019T201232Z-1.8.0/. backup_dir is a site setting and defaults to /var/lib/acd/backups. deploy never deletes a backup.

FileContents
<machine>.tar.gzThat machine’s files as they were before the run: k3s configuration, unit files and token, network connection profiles, firewall, sysctl and kernel module settings, SELinux configuration, /etc/hosts and routing table names. The first server’s archive also holds /etc/acd/deploy, except certs/, maxmind/ and deploy.log. On a running cluster, one server’s archive also holds an etcd snapshot of the cluster.
<machine>.files.sha256A checksum for every file in that archive
SHA256SUMSA checksum for each archive
manifest.yamlThe release, the machines, the run time and the etcd snapshot’s path

A backup holds configuration only, never the data stored in the cluster. See Running deploy.

Exit Status

StatusMeaning
0The run, backup or initialization finished, or -h printed the usage
1deploy refused to start: an unknown command or bad arguments, a rejected option, no configuration in /etc/acd/deploy (run deploy init), ansible-core missing, a preset.yaml that points at an ISO no longer mounted there, or a failed deploy init
otherThe exit status of ansible-playbook: the run started and stopped on a failure. The last message names the cause, usually with an [ACD-…] code.

Examples

Prepare a new lab installation:

/mnt/esb3027/deploy init lab

Install, or apply a changed configuration:

/mnt/esb3027/deploy

Apply the configuration without the preflight checks:

/mnt/esb3027/deploy --skip-tags preflight

Take a backup and change nothing:

/mnt/esb3027/deploy backup

See Also

12.2 - acd-admin

Day-to-day cluster operations by admin target name

Synopsis

acd-admin list
acd-admin status [<target>]
acd-admin volumes [<name> [--avg]]
acd-admin logs <target> [-f] [-n <lines>] [-p] [-c <container>] [--since <time>] [--until <time>]
acd-admin restart <target> [--timeout <duration> | --no-wait | --node <node>]
acd-admin shell <target> [--pod <pod>] [--node <node>] [-l <selector>] [-c <container>]
acd-admin exec <target> [--pod <pod>] [--node <node>] [-l <selector>] [-c <container>] -- <command> [<argument>...]
acd-admin exec <target> --all [-l <selector>] [-c <container>] -- <command> [<argument>...]
acd-admin cordon <node>
acd-admin uncordon <node>
acd-admin drain <node> [--force]
acd-admin help [<command>]
acd-admin [<command>] -h | --help
acd-admin -V | --version

Description

acd-admin manages the running cluster by admin target: a short name such as manager, kafka or postgresql. It works out which Kubernetes objects, labels and namespaces a target means, and handles each kind the safe way for it. acd-admin list prints the valid names. A name that is not a target is an error that lists them.

It is installed on every machine at /usr/local/bin/acd-admin, and on every server /usr/bin/acd-admin links to it, so sudo acd-admin works without changing the sudo configuration. Run it on a server, as root or with sudo, after deploy has installed the cluster. Anywhere else it stops at once with:

Error: run acd-admin on a server node as root, or set KUBECONFIG.

Before deploy has installed the cdn-platform release, it stops with:

Error: the cdn-platform chart is not installed; run deploy first.

Each command runs in a short-lived pod in the acd-system namespace, started from the release that is installed, so the tool always matches it. The pod is removed when the command ends, Ctrl-C included, and when the session is hung up or terminated. A pod left behind by a dropped session can be removed: see Leftover acd-admin Pods.

Each session also has a deadline, so that one whose terminal is gone still ends. At the deadline the session stops, whether or not anyone is still attached:

CommandDeadline
logs -f24 hours. To carry on, run it again, with --since to pick up where it stopped.
drain12 hours. The node stays drained; run drain again to carry on where it stopped.
shell, exec12 hours
restart, waitingIts --timeout plus 5 minutes, when the --timeout is written as whole numbers with units, such as 90s or '1h 30m'. A --timeout in any other form gets 12 hours, which is also the most any --timeout gets. Without --timeout, 20 minutes.
restart --no-wait, restart --node10 minutes
any other command, logs without -f included10 minutes

acd-admin never changes a chart setting. A later deploy leaves its changes alone, and does not undo a drain.

restart, cordon, uncordon, drain, and opening a shell or exec session each record a Kubernetes Event on the affected object. The Event’s message holds the command line, quoted as a shell would need, the user and the server’s hostname:

acd-admin exec manager -- sh -c 'exit 7' run by alice on manager-1

The user is the one who logged in to the server, which sudo and su keep: alice when she logged in as herself and ran sudo acd-admin or sudo -i, and root only after a root login. This is attribution, not proof: it is only as reliable as the server’s logins. acd-admin status <target> shows the Events of restart, which are on the target’s workloads, and of shell and exec, which are on the pod the session ran in. A shell or exec Event is shown only while its pod exists. Once the pod is replaced, by a restart for example, read it with kubectl:

kubectl get events -n default --field-selector reason=Exec
kubectl get events -n default --field-selector reason=Shell

The Events of cordon, uncordon and drain are on the node, in the default namespace:

kubectl get events -n default --field-selector involvedObject.kind=Node

The command line includes every argument, exec’s command among them, and anyone who can read Events in the namespace can see it until the Event expires, after about an hour by default. Read or save an Event you need before then. Never pass a password or other secret as an argument: pass it on standard input (not with --all, which gives the command no standard input).

Bash completion for commands, options, targets and nodes is installed on every machine.

Commands

list

acd-admin list

Prints every admin target and its kind, including targets with no pods.

TARGET      KIND

status

acd-admin status [<target>]

With no target, prints a table of admin targets, then a table of nodes:

TARGET                     READY  RESTARTS/1H  HEALTH  CHANGED
alertmanager               1/1    0            ok      103m ago
confd                      1/1    0            ok      2h ago
confsej                    2/2    0            ok      104m ago
director                   0/0    0            ok      -
gateway                    2/2    0            ok      104m ago
geoip-loader               1/1    0            ok      104m ago
grafana                    2/2    0            ok      104m ago
kafka                      3/3    0            ok      103m ago
kube-state-metrics         1/1    0            ok      104m ago
manager                    3/3    0            ok      57m ago
metrics-aggregator         1/1    0            ok      104m ago
mib-frontend               2/2    0            ok      104m ago
node-exporter              4/4    0            ok      2h ago
postgresql                 3/3    0            ok      55m ago
redis                      1/1    0            ok      2h ago
selection-input            1/1    0            ok      2h ago
vector                     1/1    0            ok      2h ago
victoria-metrics           1/1    0            ok      103m ago
victoria-metrics-longterm  1/1    0            ok      103m ago
vmagent                    1/1    0            ok      103m ago
vmalert                    1/1    0            ok      118m ago
zitadel                    6/6    0            ok      104m ago

NODE       READY  ROLES               SCHEDULING   DIRECTOR
manager-1  yes    control-plane,etcd  schedulable  no
manager-2  yes    control-plane,etcd  schedulable  no
manager-3  yes    control-plane,etcd  schedulable  no
manager-4  yes    -                   schedulable  no

The admin target table:

ColumnContents
TARGETThe admin target
READYReady pods out of desired pods, such as 2/3
RESTARTS/1HContainers whose last restart was in the past hour. A container that restarted several times counts once, so this is a lower bound.
HEALTHThe first that applies: down (pods are desired but none is Ready), rolling (a new version is rolling out; never shown for kafka or postgresql), degraded (some desired pods are not Ready), ok
CHANGEDHow long ago a pod of the target last became Ready or stopped being Ready, such as 12s ago; - for a target with no pods

The node table:

ColumnContents
NODEThe node
READYyes when the node reports Ready, otherwise no
ROLESThe node’s roles, such as control-plane,etcd; - for an agent
SCHEDULINGschedulable, cordoned, or drained for a drained node. The column ignores taints, so a server with the CriticalAddonsOnly taint shows schedulable, although cordon and drain do not count it as schedulable.
DIRECTORyes when the node is labeled to run the CDN Director, otherwise no

status exits 0 whatever the health it shows. To check health, read HEALTH: every admin target should show ok.

With a target, prints one line per pod (POD, NODE, READY, RESTARTS, AGE, LAST TERMINATION). For kafka it adds the Strimzi conditions, and for postgresql the CloudNativePG phase and the current primary. Then it lists the Volumes the target’s pods mount, if any. It ends with the target’s recent Warning events and the audit Events of acd-admin itself (WHEN, KIND, OBJECT, REASON, MESSAGE).

The Volume table of acd-admin status redis looks like this:

VOLUME      SIZE  USED     HEALTH    REPLICAS
maxmind     1Gi   unknown  healthy   1/1
redis-data  8Gi   unknown  degraded  1/2

The output is for reading; there is no machine-readable format.

volumes

acd-admin volumes [<name> [--avg]]

A Volume is the persistent storage that a pod of an admin target mounts, named by its claim (PersistentVolumeClaim). volumes lists every claim in the platform’s namespace. It only reads and changes nothing.

With no name, it prints one row per Volume:

VOLUME        TARGET                      SIZE  USED             HEALTH      REPLICAS
maxmind       alertmanager,confd +3 more  1Gi   unknown          healthy     3/3
data-kafka-0  kafka                       20Gi  ━━╸───────  25%  healthy     1/1
redis-data    redis                       8Gi   ━━━━━━━━╸─  88%  rebuilding  1/3
scratch       -                           2Gi   unknown          -           -
ColumnContents
VOLUMEThe claim name
TARGETThe admin targets whose pods mount the Volume; - when none does
SIZEThe claim’s size
USEDHow full the Volume’s filesystem is; unknown when no running pod mounts it
HEALTHLonghorn’s view of the Volume, in the words below; - for a Volume that Longhorn does not back
REPLICASReplicas in sync out of the replicas the Volume is configured to have, such as 2/3; - for a Volume that Longhorn does not back
HealthMeaning
healthyEvery configured replica is in sync.
rebuildingFewer replicas are in sync than configured, and one is catching up. It becomes healthy when the rebuild ends.
degradedFewer replicas are in sync than configured, and none is catching up.
faultedNo replica is healthy.
detachedThe Volume is not attached to any node, such as when its workload is scaled down. This is not a fault.
unknownLonghorn cannot assess the Volume, or its records could not be read.

On a narrow terminal, the less important columns are left out, but VOLUME and HEALTH always show. Piped or redirected output always has every column.

One Volume

With a Volume name, volumes prints that Volume in detail. The detail adds the Longhorn volume name (as Longhorn’s events and support dumps name it), the access mode, the pods that mount the Volume and their nodes, and the node and state of each replica.

Volume:        data
Target:        kafka
Longhorn:      pvc-data
Size:          20Gi
Used:          ━━╸───────  25%  5.0 GiB of 20.0 GiB
Health:        healthy
Mode:          ReadWriteOnce
Locality:      local (pod and a running replica on node-1)
IO (last 1s):  read 1.50 MiB/s 120 IOPS 0.80 ms, write 4.00 KiB/s 6 IOPS 1.05 ms

POD      NODE
kafka-0  node-1

REPLICA NODE  STATE
node-1        running

Locality says whether a running replica is on the node where the Volume’s pod runs: local or remote. It is - when there is no single pod node, as for a detached or ReadWriteMany Volume.

For a Volume with one replica, Locality decides all of its IO: when it is remote, every read and write crosses the network to another node. For a Volume with several replicas, every write goes to every replica, so write latency includes the network even when Locality is local. A remote multi-replica Volume is not, by itself, the cause of slow writes.

IO (last 1s) is the Volume’s read and write load over the last second: throughput, IOPS and latency.

OptionEffect
--avgShows the average over the last 5 minutes, IO (5m avg), from the platform’s VictoriaMetrics instead.

Only --avg uses VictoriaMetrics. When VictoriaMetrics does not answer, volumes --avg prints the rest of the detail, then exits 1 with an error.

Partial Data

When part of the storage information cannot be read, such as a node’s kubelet that does not answer, only the affected cells show unknown, a warning goes to standard error, and volumes still exits 0. Only failing to list the claims is an error. For example:

warning: reading the kubelet on node node-2 failed; fill of its Volumes is unknown: HTTP 503

logs

acd-admin logs <target> [-f] [-n <lines>] [-p] [-c <container>] [--since <time>] [--until <time>]

Shows the logs of every pod and container of the target, init containers excluded. Each line starts with its pod, and its container when the pod has more than one: <pod>[/<container>] <line>. A bounded window is merged in time order across pods.

OptionEffect
-f, --followKeeps printing new lines, including from pods created meanwhile, such as during a restart.
-n, --lines <lines>Shows only the last <lines> lines of each container. Default: all. Not with --until.
-p, --previousShows the previous instance of each container, such as one that crashed.
-c, --container <container>Shows only this container.
--since <time>Shows lines from this time on.
--until <time>Shows lines up to this time. Not with --follow.

<time> is a duration before now, such as 2m, '1h 30m' or '10m ago', or a timestamp such as '2026-10-16 12:00:00' or 2026-10-16T12:00:00Z. Quote a value that contains a space, as shown: unquoted, --since 1h 30m is refused with unexpected argument '30m'. Timestamps are always UTC, never the server’s local time, unlike journalctl. Only the logs the cluster still holds are available: those of the current and the previous instance of each container.

restart

acd-admin restart <target> [--timeout <duration> | --no-wait | --node <node>]

Restarts every pod of the target, the safe way for its kind: a rolling restart for ordinary workloads, a broker-by-broker rolling update through Strimzi for kafka, and for postgresql a CloudNativePG restart of the replicas first, then a switchover of the primary.

By default it waits, showing progress such as 2/3 pods updated, until the restart is complete. Ctrl-C stops the waiting only: the restart carries on, and acd-admin says so. A target with nothing running is left alone, and acd-admin says so.

OptionEffect
--timeout <duration>How long to wait, such as 90s or 10m, before exiting non-zero and reporting what is stuck. The restart carries on, and nothing is rolled back. Default: 5m for a Deployment, 10m for a StatefulSet, DaemonSet or postgresql, and 15m for kafka. A target with several kinds of workload gets the longest.
--no-waitStarts the restart and returns at once. Not with --timeout.
--node <node>Restarts only the target’s pods on this node, by deleting every one of them for its controller to recreate. The scheduler places the replacements, which may start on another node. Returns once the deletions are requested, without waiting for the pods to go. Refused for kafka and postgresql, whose operators restart their own pods. Not with --timeout or --no-wait.

A workload that a restart would not roll is refused before anything changes. The message says why, and what to do where there is an alternative. For example, a workload with the OnDelete update strategy is restarted one node at a time with --node.

Most admin targets are one workload. zitadel is two, the Deployments cdn-platform-zitadel and cdn-platform-zitadel-login, so restart zitadel --node <node> deletes the pods of both on that node.

shell

acd-admin shell <target> [--pod <pod>] [--node <node>] [-l <selector>] [-c <container>]

Opens an interactive session in one Ready pod of the target:

TargetSession
postgresqlpsql on the current primary
kafkaA shell on a broker, with the Kafka command line tools on the PATH and the bootstrap address set
any other/bin/sh

For example, to list the Kafka topics, run this in acd-admin shell kafka:

kafka-topics.sh --bootstrap-server "$BOOTSTRAP_SERVER" --list

Caution: shell postgresql opens psql as the database superuser. Every statement runs with full rights over every database of the manager, and nothing asks for confirmation. Prefer a replica (-l cnpg.io/instanceRole=replica) for reading, and run changes on the primary only when you mean to.

OptionEffect
--pod <pod>Uses this pod.
--node <node>Uses the target’s pod on this node.
-l, --selector <selector>Uses a pod of the target whose labels match <selector>.
-c, --container <container>Uses this container instead of the pod’s first.

Without --pod, --node or --selector, it uses the first Ready pod. For postgresql it uses the primary, and refuses if the primary is not Ready, even when replicas are. With only --selector on postgresql, it uses the primary if the selector matches it, and otherwise the first Ready pod that matches. To use a replica, pass -l cnpg.io/instanceRole=replica. If no Ready pod matches, it refuses and lists the pods.

The options combine, and each only narrows the target’s own pods: a selector never reaches a pod outside the target. <selector> takes the label selector syntax of kubectl -l, such as key=value, key!=value, key in (a,b) or !key. The cluster checks it: one it cannot parse is refused with the cluster’s error before anything runs in a pod.

exec

acd-admin exec <target> [--pod <pod>] [--node <node>] [-l <selector>] [-c <container>] -- <command> [<argument>...]
acd-admin exec <target> --all [-l <selector>] [-c <container>] -- <command> [<argument>...]

Runs a command, exactly as given after --, in one pod of the target, chosen as for shell. It gets a terminal only when both standard input and standard output are terminals, so output can be redirected or piped.

On kafka the command runs in the same environment as shell kafka: the Kafka command line tools on the PATH and the bootstrap address in BOOTSTRAP_SERVER. No shell parses the command in the pod, though. Each argument arrives as your own shell on the server left it, and that shell has already replaced $BOOTSTRAP_SERVER with its own value, which is empty. To use the pod’s variables, run the command through sh -c, in single quotes:

acd-admin exec kafka -- sh -c 'kafka-topics.sh --bootstrap-server "$BOOTSTRAP_SERVER" --list'

A command that cannot start, such as one not found in the container, prints the reason on standard error. On kafka the pod’s shell reports it, and exits 127.

OptionEffect
--pod <pod>Runs in this pod.
--node <node>Runs in the target’s pod on this node.
-l, --selector <selector>Runs in a pod of the target whose labels match <selector>, as for shell. With --all, runs in every Ready pod it matches.
--allRuns in every Ready pod of the target, one after another, with no terminal and no standard input. Each line starts with its pod. Pods that are not Ready are skipped and named before the run starts. Ends with a summary of the pods where the command failed and the pods skipped, and exits non-zero only if the command failed in a pod. Not with --pod or --node.
-c, --container <container>Runs in this container instead of the pod’s first.

The command and its arguments are recorded in the audit Event on each pod, where anyone who can read Events in the namespace can see them. Pass a password or other secret on standard input (not with --all), never as an argument.

cordon

acd-admin cordon <node>

Stops new pods from being scheduled on the node. Pods already there keep running.

cordon refuses, changing nothing, when no other node would stay schedulable, because no pod that is moved, restarted or recreated could then start anywhere. A node counts as unschedulable when it is cordoned or carries a NoSchedule or NoExecute taint, such as CriticalAddonsOnly on dedicated servers. Cordoning a node that is already unschedulable (cordoned or tainted) is never refused. drain refuses the same way, even with --force, but without that exemption: it refuses a node that is already unschedulable too.

Error: cordon manager-3

Caused by:
    cordoning manager-3 would leave no schedulable node, so no pod moved, restarted or recreated could start anywhere; make another node schedulable first, or run `k3s kubectl cordon manager-3` to cordon it anyway

kubectl adds a line such as pod acd-system/acd-admin-... terminated (Error) after this and some other errors. It names the session pod ending, not a second failure.

A refused drain says:

draining manager-3 would leave no schedulable node, so no pod moved, restarted or recreated could start anywhere; make another node schedulable first, or run `k3s kubectl drain --ignore-daemonsets --delete-emptydir-data manager-3` to drain it anyway

To cordon the last schedulable node anyway, run k3s kubectl cordon <node>, and to drain it anyway, k3s kubectl drain --ignore-daemonsets --delete-emptydir-data <node>. acd-admin keeps working with every node cordoned, so acd-admin uncordon can still return a node to service. On a cluster with dedicated servers, which carry the CriticalAddonsOnly taint, the acd-admin session cannot start once every agent is cordoned: return a node to service with k3s kubectl uncordon <node> instead.

uncordon

acd-admin uncordon <node>

Returns a cordoned or drained node to service: it clears both the unschedulable mark and the drained label.

drain

acd-admin drain <node> [--force]

Takes the node out of service for maintenance. It cordons the node, marks it as drained, and moves its workloads to other nodes through the eviction API, respecting their disruption budgets. Metrics and ingress forwarding keep running on the node, so it can still be observed. Longhorn’s node components keep running too, but its storage replicas on the node stop. acd-admin session pods are never moved either, whether the drain’s own, another operator’s or one left behind: a session on the node runs until it ends.

While it waits, it reports what it is waiting for. A disruption budget can refuse an eviction for now, such as for a storage volume’s last healthy replica. The drain names that pod and keeps retrying. It also names any pod that is terminating but not yet gone, with when its deletion started and the finalizer holding it, if any. On a node that is not Ready, such a pod can stay until the node comes back.

The drain has no timeout of its own, but its session ends after 12 hours: see the deadlines under Description. Ctrl-C, or the end of the session, stops the waiting, not the drain: the node stays drained, and pods already moved stay where they went. Pods still waiting to move are not retried until drain runs again, which carries on where it stopped; uncordon returns the node to service instead.

Before it changes anything, drain refuses, and exits non-zero, when:

  • no other node would stay schedulable, as for cordon. --force does not override this.
  • the node runs a pod that no controller recreates, such as one started by hand, because evicting it deletes it for good. The message names each such pod as <namespace>/<name>. A pod that has finished does not count, and neither does an acd-admin session pod.
  • the node runs every Ready CDN Director pod, because draining it would stop CDN routing.
OptionEffect
--forceDrains despite a pod that no controller recreates, or the last CDN Director pods. Pods that no controller recreates are gone for good, and CDN routing stops until a CDN Director pod is Ready again.

Drained Nodes

A drained node carries two marks, which drain sets:

  • the node label acd.agiletv.com/drained=true, which status shows as drained under SCHEDULING, and which keeps the CDN Director off the node;
  • the unschedulable mark that cordon also sets (spec.unschedulable, shown by kubectl as the node.kubernetes.io/unschedulable taint).

A drained node stays drained through reboots and later deploy runs, until acd-admin uncordon clears both marks.

kubectl uncordon clears only the unschedulable mark. The node then takes new pods again and is back in service, but it keeps the drained label: status still shows it as drained, and the CDN Director stays off it. Run acd-admin uncordon on it to clear the label.

Safety

Read this before you drain, cordon or restart anything on a production cluster.

  • One server at a time. The servers hold the cluster datastore (etcd), which needs a majority of them. With three servers, two down at once stops the Kubernetes API: nothing can be scheduled, moved or changed until one returns. Drain, reboot or restart one server, and return it to service, before you start on the next.

  • Wait for the cluster to recover before the next drain. After you return a drained node to service, wait until acd-admin status shows every admin target ok, and acd-admin volumes shows no Volume degraded, rebuilding or faulted. A drain stops the drained node’s storage replicas, and Volumes stay degraded or rebuilding until they recover: see Longhorn Storage. Plain acd-admin status does not show this.

  • Kafka tolerates one broker down, because every topic but one keeps three replicas. The exception is selection_input, which has a single replica: selection input pauses while the broker that holds it restarts or moves, and is lost with that broker’s volume. To see which broker holds it, run this in acd-admin shell kafka:

    kafka-topics.sh --bootstrap-server "$BOOTSTRAP_SERVER" --describe --topic selection_input
    

    The Leader number N is the pod cdn-platform-kafka-N, and acd-admin status kafka shows its node.

  • Cordoning or draining the PostgreSQL primary’s node switches the primary. CloudNativePG moves the primary to an instance on another node, which briefly interrupts database writes. acd-admin status postgresql shows the current primary.

  • Some admin targets run a single pod. In the production preset these are alertmanager, confd, geoip-loader (with MaxMind databases), kube-state-metrics, metrics-aggregator, redis, selection-input, vector, victoria-metrics, victoria-metrics-longterm, vmagent and vmalert. Restarting one, or draining its node, stops it until its new pod is Ready.

  • restart --node skips disruption budgets. It deletes the target’s pods on the node rather than evicting them, so nothing holds it back, even when they are the target’s only Ready pods.

Options

Every command accepts:

OptionEffect
-hPrints a summary of the help for acd-admin, or for the command, and exits.
--helpPrints the full help, with details the summary leaves out, such as the HEALTH rules of status, and exits.

acd-admin help prints the same as acd-admin --help, and acd-admin help <command> the same as acd-admin <command> --help.

acd-admin alone also accepts:

OptionEffect
-V, --versionPrints the version and exits.

Environment

VariableEffect
KUBECONFIGThe cluster credentials to use. Default: /etc/rancher/k3s/k3s.yaml.

Exit Status

StatusMeaning
0The command succeeded. status and volumes exit 0 whatever the health they show, and volumes also when some of its sources could not be read.
1The command failed, including a restart that timed out, an exec --all that failed in any pod, a refused cordon or drain, and a volumes --avg that VictoriaMetrics did not answer
2Invalid command line, such as an unknown option or two options that cannot be combined
129The session was hung up, such as by a dropped SSH connection
130Interrupted with Ctrl-C
143Terminated, such as by kill

An interrupted, hung-up or terminated restart carries on in the cluster, and such a drain leaves the node drained.

shell, and exec in one pod, exit with the status of the command in the pod. Any of these statuses from them may come from that command.

Examples

See which targets exist and how they are doing:

acd-admin list
acd-admin status
acd-admin status postgresql

Check the cluster’s storage, then look into one Volume and its 5-minute IO average:

acd-admin volumes
acd-admin volumes data-kafka-0
acd-admin volumes data-kafka-0 --avg

Follow the manager’s logs from ten minutes ago:

acd-admin logs manager -f --since '10m ago'

See why a container crashed:

acd-admin logs manager -p

Restart Kafka, allowing up to 20 minutes:

acd-admin restart kafka --timeout 20m

Restart only the manager’s pods on one node:

acd-admin restart manager --node manager-3

Open psql on the PostgreSQL primary:

acd-admin shell postgresql

List the Kafka topics:

acd-admin exec kafka -- sh -c 'kafka-topics.sh --bootstrap-server "$BOOTSTRAP_SERVER" --list'

Run a command in every Redis pod:

acd-admin exec redis --all -- redis-cli ping

Open psql on a PostgreSQL replica rather than the primary:

acd-admin shell postgresql -l cnpg.io/instanceRole=replica

Take a node out of service and return it:

acd-admin drain manager-3
acd-admin uncordon manager-3

See Also

12.3 - acd-support-dump

Write a redacted diagnostic tarball for AgileTV support

Synopsis

acd-support-dump [<output path>]

Description

acd-support-dump captures a point-in-time diagnostic snapshot of the cluster into one compressed tarball, for attaching to a support ticket. It is installed on every machine at /usr/local/bin/acd-support-dump, and on every server /usr/bin/acd-support-dump links to it, so sudo acd-support-dump works without changing the sudo configuration. Run it on a server, as root or with sudo. It needs no network access beyond the cluster, so it works air-gapped.

The tarball holds pod logs, cluster resources and events, node system logs, database and Longhorn health, metric history and automated triage findings. Secret values are never collected, and the collected data is redacted before the tarball is written. It does not collect database contents. See Support Dumps for the full list.

The tarball is readable by its owner only. Progress goes to standard error. The only line written to standard output is the path of the tarball written, so a script can capture it.

Arguments

ArgumentEffect
<output path>Where to write the tarball. .tar.gz is added to a path that does not end in it. A path that starts with - is taken for an option and refused: write it as ./-name. Default: support-dump-<hostname>-<UTC time>.tar.gz in the current folder, where <UTC time> looks like 20261016T120000Z.

Environment

VariableEffect
KUBECONFIGThe cluster credentials to use. Default: /etc/rancher/k3s/k3s.yaml.

Files

FileContents
/usr/share/acd/acd-support-dump.yamlWhat to collect and how to redact it. Shipped with the release: do not edit.

Exit Status

StatusMeaning
0The tarball was written
2Invalid command line: more than one argument, or an option. acd-support-dump takes no options.
other non-zeroThe collection failed, or the collection engine or its specification is missing

Examples

Write the tarball to the current folder:

acd-support-dump

Write it to a chosen path:

acd-support-dump /tmp/support-dump.tar.gz

See Also

13 - Glossary

Terminology and definitions

Overview

This glossary defines key terms and acronyms used throughout the AgileTV CDN Manager (ESB3027) documentation.

A

ACD (Agile Content Delivery)

The overall CDN solution comprising the Manager (ESB3027) and CDN Director (ESB3024) components.

Agent Node

A Kubernetes node that runs workloads but does not participate in the control plane. Agent nodes provide additional capacity for running application pods.

API Gateway

See NGinx Gateway.

ASN (Autonomous System Number)

A unique identifier for a network on the internet. Used in GeoIP-based routing decisions.

B

Backplane

The network the cluster machines use to reach each other (east-west traffic), set with backplane_network. Optional but recommended, and ideally separate from the public network that clients and CDN Directors use. Other trusted hosts, such as CDN Directors, may also be on it. See Configuring Segregated Networks.

C

CDN Director

The Edge Server Business (ESB3024) component that handles actual content routing and delivery. Multiple CDN Directors can be managed by a single CDN Manager.

Cloudnative PG (CNPG)

A Kubernetes operator that manages PostgreSQL clusters. Provides high availability, automatic failover, and backup capabilities for the Manager’s database layer.

Confd

Configuration daemon that synchronizes configuration from the Manager to CDN Directors. Runs as a sidecar or separate deployment.

CORS (Cross-Origin Resource Sharing)

A security mechanism that allows web applications to make requests to a different domain. Zitadel enforces CORS policies requiring the external domain to match the configured hostname.

CrashLoopBackOff

A Kubernetes pod state indicating the container is repeatedly crashing and being restarted. Typically indicates a configuration or dependency issue.

D

Datastore

The internal key-value storage system used by the Manager for short-lived or simple structured data. Backed by Redis.

Descheduler

A Kubernetes component that periodically analyzes pod distribution and evicts pods from overutilized nodes to optimize cluster balance.

Director

See CDN Director.

E

EDB (EnterpriseDB)

A company that provides PostgreSQL-related software and services. The Cloudnative PG operator was originally developed by EDB.

Ephemeral Storage

Temporary storage available to pods. Used for temporary files and caches. Not persistent across pod restarts.

ESB (Edge Server Business)

The product family designation for CDN components. ESB3027 is the Manager, ESB3024 is the CDN Director.

etcd

A distributed key-value store used by Kubernetes for cluster state management. Runs on Server nodes as part of the control plane.

F

FailedScheduling

A Kubernetes event indicating a pod could not be scheduled due to insufficient resources or scheduling constraints.

Flannel

A network overlay solution for Kubernetes. Provides VXLAN-based networking for pod-to-pod communication.

Frontend GUI

See MIB Frontend.

G

GeoIP

Geographic IP lookup service using MaxMind databases. Used for location-based routing decisions.

Grafana

A visualization and dashboard platform for time-series data. Used to display metrics collected by Telegraf and stored in VictoriaMetrics.

H

Helm Chart

A package of pre-configured Kubernetes resources. The CDN Manager is deployed via a Helm chart that handles all component installation.

HPA (Horizontal Pod Autoscaler)

A Kubernetes feature that automatically scales the number of pods based on CPU/memory utilization or custom metrics.

HTTP Server

The main API server component of the Manager, built with Actix Web (Rust framework).

I

Ingress

A Kubernetes resource that exposes HTTP/HTTPS routes from outside the cluster to services within. The CDN Manager uses Traefik as the ingress controller.

Ingress Controller

A component that implements ingress rules. The CDN Manager uses Traefik for primary ingress and NGinx for external CDN Director communication.

K

Kafka

A distributed event streaming platform used by the Manager for asynchronous communication and event processing.

K3s

A lightweight Kubernetes distribution optimized for edge and production deployments. Used as the underlying cluster technology.

Kubernetes (K8s)

An open-source container orchestration platform. The CDN Manager runs on a K3s-based Kubernetes cluster.

L

Login Name

The identifier a user signs in with. Zitadel composes it from the user’s username and the organization’s primary domain, which is derived from the manager’s configured external hostname — for example, admin@zitadel.manager.example.com. A login name is distinct from the user’s email address.

Longhorn

A distributed block storage system for Kubernetes. Provides persistent volumes for stateful components like PostgreSQL and Kafka.

Liveness Probe

A Kubernetes health check that determines if a container is running properly. Failed liveness probes trigger container restart.

M

Manager

The central management component (ESB3027) for configuring and monitoring CDN Directors.

MaxMind

A provider of IP intelligence databases including GeoIP City, GeoLite2 ASN, and Anonymous IP databases used by the Manager.

MIB Frontend

The web-based configuration GUI for CDN operators. Provides a user interface for managing streams, CDN Directors, and other configuration.

Multi-Factor Authentication (MFA)

An authentication method requiring multiple forms of verification. Note: MFA is not currently supported in the CDN Manager and should be skipped during setup.

N

Name-based Virtual Hosting

A technique where multiple hostnames are served from the same IP address. Zitadel uses this to match the URL a client connects to against its configured external hostname.

Namespace

A Kubernetes abstraction for organizing cluster resources. The CDN Manager uses namespaces to group related components.

NGinx Gateway

An NGinx-based gateway that handles external communication with CDN Directors.

Node Token

A secret token used to authenticate new nodes joining a K3s cluster. Located at /var/lib/rancher/k3s/server/node-token on Server nodes.

O

Operator

A method of packaging, deploying, and managing a Kubernetes application. Cloudnative PG is an operator for PostgreSQL.

OOMKilled

A Kubernetes pod state indicating the container was terminated due to exceeding memory limits.

P

PDB (Pod Disruption Budget)

A Kubernetes feature that ensures a minimum number of pods remain available during voluntary disruptions like maintenance.

PersistentVolume (PV)

A piece of storage in the Kubernetes cluster. Created dynamically by Longhorn for stateful components.

PersistentVolumeClaim (PVC)

A request for storage by a pod. Bound to a PersistentVolume.

Pod

The smallest deployable unit in Kubernetes. Contains one or more containers.

PostgreSQL

An open-source relational database. Used by the Manager for persistent data storage, managed by Cloudnative PG.

Probe

A Kubernetes health check mechanism. Types include liveness, readiness, and startup probes.

Prometheus

An open-source monitoring and alerting toolkit. Telegraf exports metrics in Prometheus format.

R

RBAC (Role-Based Access Control)

A method of regulating access to resources based on user roles. Used by Kubernetes for authorization.

Readiness Probe

A Kubernetes health check that determines if a container is ready to receive traffic. Failed readiness probes remove the pod from service load balancing.

Redis

An in-memory data structure store used for caching and as the datastore backend for the Manager.

Replica

A copy of a pod. Multiple replicas provide high availability and load distribution.

Resource Preset

Predefined resource configurations (nano, micro, small, medium, large, xlarge, 2xlarge) for common deployment sizes.

Rolling Update

A deployment strategy that updates pods one at a time to maintain availability during upgrades.

S

Selection Input

A key-value storage mechanism used for configuration data that can be queried with wildcard patterns. Available in v1 and v2 APIs with different semantics.

Server Node

A Kubernetes node that participates in the control plane (etcd, API server). Can also run workloads unless tainted.

Service

A Kubernetes abstraction that defines a logical set of pods and a policy for accessing them. Provides stable networking endpoints.

ServiceAccount

A Kubernetes identity for processes running in pods. Used for authentication between Kubernetes components.

StatefulSet

A Kubernetes workload API object for managing stateful applications. Used for PostgreSQL and Kafka deployments.

Startup Probe

A Kubernetes health check that determines if a container application has started. Disables liveness and readiness checks until it succeeds.

Stream

A content stream configuration defining source and routing parameters.

T

Telegraf

An agent for collecting, processing, aggregating, and writing metrics. Runs on each node to gather system and application metrics.

TLS (Transport Layer Security)

A cryptographic protocol for secure communication. The CDN Manager uses TLS for all external HTTPS connections.

Topology Aware Hints

A Kubernetes feature that prefers routing traffic to pods in the same zone as the source. Reduces latency by keeping traffic local.

Traefik

A modern HTTP reverse proxy and ingress controller. Used as the primary ingress controller for the CDN Manager.

TTL (Time To Live)

The duration after which data expires. Used in the datastore and selection input APIs.

V

Values.yaml

The Helm chart configuration file. Contains all configurable parameters for the CDN Manager deployment.

VictoriaMetrics

A time-series database used for storing metrics data. Provides long-term storage and querying capabilities.

VXLAN

Virtual Extensible LAN. A network virtualization technology used by Flannel for pod networking.

Z

Zitadel

An identity and access management (IAM) platform used for authentication and authorization in the CDN Manager. Provides OAuth2/OIDC capabilities.

Default Credentials

The following table lists all default credentials used by the CDN Manager. Change these defaults before deploying to production.

ServiceUsernamePasswordNotes
Zitadel Console and GUIadmin@zitadel.<manager-host>Password1!The login name of the default administrator; <manager-host> is the first entry of manager_hostnames. The Zitadel Console, at /ui/console, also accepts the account’s email address, admin@agiletv.dev; the GUI does not.

Security Warning: Use the default administrator account only to create a new administrator account with proper roles. After verifying the new account works, disable or delete the default admin account before exposing the system to users. For details on required roles and administrator permissions, see Zitadel’s Administrator Documentation. See the Next Steps Guide for initial configuration procedures.

Common Abbreviations

AbbreviationMeaning
APIApplication Programming Interface
ASNAutonomous System Number
CORSCross-Origin Resource Sharing
CPUCentral Processing Unit
DNSDomain Name System
EDBEnterpriseDB
ESBEdge Server Business
GUIGraphical User Interface
HAHigh Availability
HelmHelm Package Manager
HPAHorizontal Pod Autoscaler
HTTPHypertext Transfer Protocol
HTTPSHTTP Secure
IAMIdentity and Access Management
IPInternet Protocol
JSONJavaScript Object Notation
K8sKubernetes
MFAMulti-Factor Authentication
MIBManagement Information Base
NICNetwork Interface Card
OAuthOpen Authorization
OIDCOpenID Connect
PVCPersistentVolumeClaim
PVPersistentVolume
RBACRole-Based Access Control
SSLSecure Sockets Layer
TCPTransmission Control Protocol
TLSTransport Layer Security
TTLTime To Live
UDPUser Datagram Protocol
UIUser Interface
VPAVertical Pod Autoscaler
VXLANVirtual Extensible LAN

Next Steps

After reviewing terminology:

  1. Architecture Guide - Understand component relationships
  2. Configuration Guide - Full configuration reference
  3. Operations Guide - Day-to-day operational procedures