diff --git a/.gitignore b/.gitignore index c7abeb8e7..b4b027a59 100644 --- a/.gitignore +++ b/.gitignore @@ -160,6 +160,11 @@ package-lock.json /docker/secrets/* !/docker/secrets/.keep +# Local Cobbler distro source tree bind-mounted into cobblerd/http-api (see compose.common.yaml +# and docker/orthos/setup_fake_cobbler_distro.py) +/distro-sources/ + + # Ignore AI stuff .github/instructions/codacy.instructions.md .claude diff --git a/compose.common.yaml b/compose.common.yaml index 61d838536..3949b04c2 100644 --- a/compose.common.yaml +++ b/compose.common.yaml @@ -56,6 +56,10 @@ services: orthos: aliases: - authentik.orthos2.test + # cobblerd/http-api don't listen on port 80 themselves (see the "cobblerd"/"http-api" services + # below) - orthos2's plain "http://cobbler.orthos2.test/cobbler_api" XML-RPC calls only work + # because this alias sends that hostname straight to Traefik, which then path-routes it. + - cobbler.orthos2.test security_opt: - label:disable orthos2: @@ -121,23 +125,188 @@ services: interval: 1s timeout: 5s retries: 10 - cobbler: - hostname: cobbler.orthos2.test - build: - context: docker/cobbler - dockerfile: cobbler.dockerfile - secrets: - - SCCcredentials + # Cobbler now runs as upstream's own published container images (see + # https://cobbler.readthedocs.io/en/latest/user-guide/docker-deployment.html). + cobblerd: + image: ghcr.io/cobbler/cobblerd:v4.0.0a7 + command: ["cobblerd", "-F"] + environment: + COBBLER_XMLRPC_BIND_ADDRESS: "0.0.0.0" + configs: + - source: cobbler-settings + target: /etc/cobbler/settings.yaml + mode: 0640 + - source: cobbler-logging-config + target: /etc/cobbler/logging_config.conf + mode: 0644 + # Registered as the active "dhcpv4"/"dhcpv6" templates by + # docker/orthos/setup_cobbler_dhcp_templates.py - only cobblerd renders DHCP config + # (modules.process_management/isc.py), so http-api doesn't need these. + - source: cobbler-dhcp-template + target: /var/lib/cobbler/templates/orthos2-dhcp.template + mode: 0644 + - source: cobbler-dhcpv6-template + target: /var/lib/cobbler/templates/orthos2-dhcp6.template + mode: 0644 + # Registered as the active "named_primary" template by + # docker/orthos/setup_cobbler_dns_templates.py. + - source: cobbler-named-template + target: /var/lib/cobbler/templates/orthos2-named.template + mode: 0644 + volumes: + - cobbler-etc:/etc/cobbler:z + - cobbler-var-lib:/var/lib/cobbler:z + - cobbler-webdir:/srv/www/cobbler:z + - cobbler-tftproot:/srv/tftpboot:z + - cobbler-dhcp-config:/etc/cobbler-dhcp:z + - cobbler-dns-config:/etc/cobbler-dns:z + # bind.py writes named.conf's referenced zone files here (settings.bind_zonefile_path) - + # without sharing it, they'd land on cobblerd's own ephemeral filesystem, same bug class as + # the dhcpd6.conf fix in the Cobbler repo's docker/images/cobblerd/Dockerfile. + - cobbler-dns-zones:/var/lib/named:z + # Required to restart the sidecar DHCP & DNS containers + - /var/run/docker.sock:/var/run/docker.sock:ro + # Read-only source tree for distro kernels/initrds - modules.httpd/tftpd below serve + # straight from here (managers.dynamic_httpd/dynamic_tftp) instead of copying content + # into cobbler-webdir/cobbler-tftproot, and it's also where a Distro's "kernel"/"initrd" + # paths must point so cobblerd's own file-existence check finds them. + - type: bind + source: ${COBBLER_DISTRO_SOURCE_DIR:-./distro-sources} + target: /srv/distro-sources + read_only: true + bind: + create_host_path: true + networks: + - orthos + labels: + - "traefik.enable=true" + - "traefik.http.routers.cobbler-api-http.rule=Host(`cobbler.orthos2.test`) && PathPrefix(`/cobbler_api`)" + - "traefik.http.routers.cobbler-api-http.entrypoints=web" + - "traefik.http.routers.cobbler-api-http.middlewares=cobbler-api-strip" + - "traefik.http.routers.cobbler-api-https.rule=Host(`cobbler.orthos2.test`) && PathPrefix(`/cobbler_api`)" + - "traefik.http.routers.cobbler-api-https.entrypoints=websecure" + - "traefik.http.routers.cobbler-api-https.tls=true" + - "traefik.http.routers.cobbler-api-https.middlewares=cobbler-api-strip" + - "traefik.http.middlewares.cobbler-api-strip.stripprefix.prefixes=/cobbler_api" + - "traefik.http.services.cobbler-api.loadbalancer.server.port=25151" + security_opt: + - label:disable + restart: unless-stopped + + http-api: + image: ghcr.io/cobbler/cobblerd:v4.0.0a7 + command: ["gunicorn", "cobbler.services:application", "--bind", "0.0.0.0:8000"] + environment: + COBBLER_XMLRPC_HOST: "cobblerd" + configs: + - source: cobbler-settings + target: /etc/cobbler/settings.yaml + mode: 0440 + volumes: + - cobbler-webdir:/srv/www/cobbler:ro,z + - cobbler-tftproot:/srv/tftpboot:ro,z + - type: bind + source: ${COBBLER_DISTRO_SOURCE_DIR:-./distro-sources} + target: /srv/distro-sources + read_only: true + bind: + create_host_path: true networks: - orthos + depends_on: + cobblerd: + condition: service_healthy labels: - "traefik.enable=true" - - "traefik.http.routers.cobbler-http.rule=Host(`cobbler.orthos2.test`)" - - "traefik.http.routers.cobbler-http.entrypoints=web" - - "traefik.http.routers.cobbler-https.rule=Host(`cobbler.orthos2.test`)" - - "traefik.http.routers.cobbler-https.entrypoints=websecure" - - "traefik.http.routers.cobbler-https.tls=true" - - "traefik.http.services.cobbler.loadbalancer.server.port=80" + - "traefik.http.routers.cobbler-http-api-http.rule=Host(`cobbler.orthos2.test`) && (PathPrefix(`/cblr`) || PathPrefix(`/httpboot`) || PathPrefix(`/images`))" + - "traefik.http.routers.cobbler-http-api-http.entrypoints=web" + - "traefik.http.routers.cobbler-http-api-http.middlewares=cobbler-svc-strip" + - "traefik.http.routers.cobbler-http-api-https.rule=Host(`cobbler.orthos2.test`) && (PathPrefix(`/cblr`) || PathPrefix(`/httpboot`) || PathPrefix(`/images`))" + - "traefik.http.routers.cobbler-http-api-https.entrypoints=websecure" + - "traefik.http.routers.cobbler-http-api-https.tls=true" + - "traefik.http.routers.cobbler-http-api-https.middlewares=cobbler-svc-strip" + - "traefik.http.middlewares.cobbler-svc-strip.stripprefix.prefixes=/cblr/svc" + - "traefik.http.services.cobbler-http-api.loadbalancer.server.port=8000" + healthcheck: + test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/healthz', timeout=5)"] + interval: 10s + timeout: 5s + retries: 6 + start_period: 15s + restart: unless-stopped + + cobbler-tftp: + image: ghcr.io/cobbler/cobbler-tftp:latest + environment: + COBBLER_XMLRPC_HOST: "cobblerd" + ports: + - "69:69/udp" + networks: + - orthos + depends_on: + cobblerd: + condition: service_healthy + restart: unless-stopped + + cobbler-dhcp: + image: ghcr.io/cobbler/cobbler-dhcp:v4.0.0a7 + networks: + - orthos + volumes: + - cobbler-dhcp-config:/etc/cobbler-dhcp:ro,z + depends_on: + cobblerd: + condition: service_healthy + restart: unless-stopped + + # Same image as "cobbler-dhcp", running ISC dhcpd's separate "-6" (DHCPv6) mode against the + # dhcpd6.conf the cobblerd Dockerfile fix now shares into this same volume. Reads it straight + # from the shared directory rather than via a "/etc/dhcpd6.conf" symlink, since this image (built + # for the v4 role) only sets one up for dhcpd.conf. dhcpd6 needs its own lease directory/file + # bootstrapped first, same reason the image's own Dockerfile does this for dhcpd.leases - ISC + # dhcpd never creates one itself and exits immediately if it's missing. + # + # This image's Dockerfile bakes in "cobbler.io/managed-service=dhcp" (see + # docker/images/dhcp/Dockerfile in the Cobbler repo) - reusing it verbatim for a second container + # would make cobblerd's process_management.docker restart-by-label lookup ambiguous (it hard-errors + # if more than one container carries the label it's searching for). Overriding the label here to + # "dhcp6", paired with the "dhcpd6: dhcp6" entry in modules.process_management.docker_service_labels + # below, keeps the v4/v6 sidecars independently restartable. + cobbler-dhcp6: + image: ghcr.io/cobbler/cobbler-dhcp:v4.0.0a7 + entrypoint: + - sh + - -c + - | + mkdir -p /var/lib/dhcp6/db && touch -a /var/lib/dhcp6/db/dhcpd6.leases && \ + exec dhcpd -6 -f -cf /etc/cobbler-dhcp/dhcpd6.conf -lf /var/lib/dhcp6/db/dhcpd6.leases + labels: + - "cobbler.io/managed-service=dhcp6" + networks: + - orthos + volumes: + - cobbler-dhcp-config:/etc/cobbler-dhcp:ro,z + depends_on: + cobblerd: + condition: service_healthy + restart: unless-stopped + + cobbler-dns: + image: ghcr.io/cobbler/cobbler-dns:v4.0.0a7 + volumes: + - cobbler-dns-config:/etc/cobbler-dns:ro,z + # Read-write, unlike cobbler-dns-config above: BIND's own "directory" option (where + # dump-file/statistics-file/memstatistics-file and its own runtime state live) is this same + # path, and named refuses to start ("directory ... is not writable") if it can't write there + # - even though it only ever reads the zone files cobblerd renders into it. + - cobbler-dns-zones:/var/lib/named:z + networks: + - orthos + depends_on: + cobblerd: + condition: service_healthy + restart: unless-stopped + serial_console: hostname: sconsole.orthos2.test networks: @@ -176,8 +345,145 @@ services: secrets: SCCcredentials: file: docker/secrets/SCCcredentials + +# Injected into both cobblerd and http-api above; only lists keys that differ from cobbler.settings's +# built-in defaults (Settings.from_dict() overlays these onto an already fully-populated Settings()). +# "modules" is given in full, not as a partial override - from_dict() replaces each top-level key +# wholesale rather than deep-merging, so a partial "modules" override would silently blank out any +# sub-key left out below instead of leaving it at its default. +configs: + cobbler-settings: + content: | + tftpboot_location: "/srv/tftpboot" + webdir: "/srv/www/cobbler" + manage_tftpd: false + server: "cobbler.orthos2.test" + # Without these, `cobbler sync` never writes /etc/cobbler-dhcp/dhcpd.conf or + # /etc/cobbler-dns/named.conf (both default to false) - the cobbler-dhcp/cobbler-dns + # sidecars below would then crash-loop forever on a dangling symlink to a file that + # sync never creates, instead of just until the first sync (as documented upstream). + manage_dhcp_v4: true + manage_dhcp_v6: true + manage_dns: true + # False (the default) is only safe once settings.yaml is already on the current schema + # version, which it is here (freshly generated by this image) - true would let cobblerd + # silently rewrite this config on a schema change instead of refusing to start with a + # clear "automigration disabled but required" error. Flip to true only when upgrading + # cobblerd across a settings-schema-breaking version and migrating an existing volume. + auto_migrate_settings: false + # Forward zone: plain domain name(s), matched by longest DNS-name suffix. + manage_forward_zones: + - "orthos2.test" + # Reverse zone: a dotted-decimal *prefix* (not CIDR, not in-addr.arpa) - "192.0.2" here + # covers the whole 192.0.2.0/24 network (cobbler/modules/managers/bind.py matches it via + # "^192\.0\.2\." against each system's address, then reverses the remaining octet). + # + # The "2001:db8" entry isn't optional once manage_dhcp_v6/the "2001:db8::/64" DHCPv6 subnet + # below are in play: bind.py's __reverse_zones() only pre-populates its zone dict from this + # list, then does an unconditional zones[zone][...] = ... for every system's address - any + # IPv6 host under a network with no matching entry here makes `cobbler sync` raise a bare + # KeyError and abort (not skip that one host). The exact string only needs to contain a ":" + # and be a parseable IPv6 prefix - bind.py re-derives the real zone key from + # __expand_ipv6(zone + "::1")[:19], which independently normalizes "2001:db8"/"2001:db8::"/ + # etc. to "2001:0db8:0000:0000" - the same value it computes for each host's own address. + manage_reverse_zones: + - "192.0.2" + - "2001:db8" + # Matches the "cobbler-dns-zones" volume mounted rw on both cobblerd and cobbler-dns below - + # bind.py writes named.conf's referenced zone files here, same as the default already is. + bind_zonefile_path: "/var/lib/named" + + modules: + authentication: + module: "authentication.configfile" + hash_algorithm: "sha3_512" + authorization: + module: "authorization.allowall" + dns: + module: "managers.bind" + dhcp: + module: "managers.isc" + httpd: + module: "managers.dynamic_httpd" + tftpd: + module: "managers.dynamic_tftp" + process_management: + module: "auto" + docker_socket_path: "/var/run/docker.sock" + docker_service_labels: + dhcpd: "dhcp" + dhcpd4: "dhcp" + # Distinct from "dhcp" ("cobbler-dhcp") - "cobbler-dhcp6" overrides its + # cobbler.io/managed-service label to "dhcp6" precisely so these two don't collide. + dhcpd6: "dhcp6" + named: "dns" + dnsmasq: "dnsmasq" + serializers: + module: "serializers.file" + + # Overrides the RPM-shipped /etc/cobbler/logging_config.conf: raises handler_stdout to DEBUG + # (root logger is already DEBUG) so debug-level output actually reaches "docker compose logs + # cobblerd" instead of being filtered out by the handler, and drops the FileHandler entirely - + # containers log to stdout, not to a file that would silently accumulate inside the container + # (or need its own volume/rotation) at /var/log/cobbler/cobbler.log. + cobbler-logging-config: + content: | + [loggers] + keys=root + + [handlers] + keys=stdout + + [formatters] + keys=stdout + + [logger_root] + level=DEBUG + handlers=stdout + + [handler_stdout] + class=StreamHandler + level=DEBUG + formatter=stdout + args=(sys.stdout,) + + [formatter_stdout] + format=%(levelname)s | %(message)s + class=logging.Formatter + + # Custom DHCPv4/v6 templates covering the orthos2 dev stack's "192.0.2.0/24"/"2001:db8::/64" + # networks - Cobbler 4.0 templates are DB objects selected by tag, not a fixed + # /etc/cobbler/dhcp.template path (see docs/user-guide/templating.rst), so these files alone + # do nothing until docker/orthos/setup_cobbler_dhcp_templates.py registers them as Template + # items tagged "dhcpv4"/"dhcpv6" + "active". + cobbler-dhcp-template: + file: docker/orthos/cobbler-templates/dhcp.template + cobbler-dhcpv6-template: + file: docker/orthos/cobbler-templates/dhcp6.template + + # Fixes the built-in "named_primary" template's logging block so the "cobbler-dns" sidecar + # actually starts (see docker/orthos/cobbler-templates/named.template) - registered as the active + # "named_primary" template by docker/orthos/setup_cobbler_dns_templates.py. + cobbler-named-template: + file: docker/orthos/cobbler-templates/named.template + volumes: database: driver: local + cobbler-etc: + cobbler-var-lib: + cobbler-webdir: + cobbler-tftproot: + cobbler-dhcp-config: + cobbler-dns-config: + cobbler-dns-zones: networks: + # Pinned (rather than Docker's default auto-assigned subnet) because the "cobbler-dhcp" sidecar + # joins this same network - dhcpd refuses to listen on any interface without a matching "subnet" + # declaration for its actual address, so docker/orthos/cobbler-templates/dhcp.template's dummy + # subnet has to name this exact, stable value rather than chase whatever Docker happens to pick. orthos: + ipam: + config: + - subnet: "172.20.0.0/16" + gateway: "172.20.0.1" diff --git a/docker/cobbler/cobbler-startup.sh b/docker/cobbler/cobbler-startup.sh deleted file mode 100755 index 59bd74f5d..000000000 --- a/docker/cobbler/cobbler-startup.sh +++ /dev/null @@ -1,4 +0,0 @@ -#!/bin/bash - -/usr/bin/ssh-keygen -A -/usr/sbin/sshd && cobblerd && apachectl -D FOREGROUND diff --git a/docker/cobbler/cobbler.dockerfile b/docker/cobbler/cobbler.dockerfile deleted file mode 100644 index c5f33d738..000000000 --- a/docker/cobbler/cobbler.dockerfile +++ /dev/null @@ -1,9 +0,0 @@ -FROM registry.opensuse.org/systemsmanagement/cobbler/github-ci/containers/cobbler-test-github:release33 - -RUN zypper --gpg-auto-import-keys ref \ - && zypper in -y cobbler lynx w3m - -COPY ./cobbler-startup.sh / - -# Set entrypoint for development -CMD ["/cobbler-startup.sh"] diff --git a/docker/orthos/cobbler-templates/dhcp.template b/docker/orthos/cobbler-templates/dhcp.template new file mode 100644 index 000000000..8fe904d84 --- /dev/null +++ b/docker/orthos/cobbler-templates/dhcp.template @@ -0,0 +1,173 @@ +# ************************************************************************************************************ +# Cobbler managed dhcpd.conf file +# +# generated from Cobbler dhcp.conf template ($date) +# Do NOT make changes to this file! Instead, make your changes inside the template, as /etc/dhcpd.conf will be +# overwritten. +# +# Adapted from Cobbler's built-in cheetah/etc/dhcp.template (see cobbler/data/templates/cheetah/etc/dhcp.template +# in the Cobbler repo) - only the "subnet" block below was changed, to the "192.0.2.0/24" network used by the +# orthos2 dev stack, registered as the active "dhcpv4" template via docker/orthos/setup_cobbler_dhcp_templates.py. +# ************************************************************************************************************ + +ddns-update-style interim; + +allow booting; +allow bootp; + +ignore client-updates; +set vendorclass = option vendor-class-identifier; + +option system-arch code 93 = unsigned integer 16; + +subnet 192.0.2.0 netmask 255.255.255.0 { + option routers 192.0.2.1; + option domain-name-servers 192.0.2.1; + option domain-name "orthos2.test"; + option subnet-mask 255.255.255.0; + range dynamic-bootp 192.0.2.100 192.0.2.200; + default-lease-time 21600; + max-lease-time 43200; + next-server $next_server_v4; + class "pxeclients" { + match if substring (option vendor-class-identifier, 0, 9) = "PXEClient"; + + # Legacy + if option system-arch = 00:00 { + filename "grub/grub.0"; + } + # UEFI-32-1 + if option system-arch = 00:06 { + # Not supported, no 32 bit UEFI grub executable + filename "unsupported"; + } + # UEFI-32-2 + if option system-arch = 00:02 { + # Not supported, no 32 bit UEFI grub executable + filename "unsupported"; + } + # UEFI-64-1 + else if option system-arch = 00:07 { + filename "grub/grubx64.efi"; + } + # UEFI-64-2 + else if option system-arch = 00:08 { + filename "grub/grubx64.efi"; + } + # UEFI-64-3 + else if option system-arch = 00:09 { + filename "grub/grubx64.efi"; + } + # armv7 (aka arm 32 bit) + else if option system-arch = 00:0a { + filename "grub/armv7.efi"; + } + # aarch64 (aka arm 64 bit) + else if option system-arch = 00:0b { + filename "grub/grubaa64.efi"; + } + # RiskV 32 bit + else if option system-arch = 00:25 { + #ToDo petitboot loader + # petitboot should support pxelinux config files + filename "unsupported"; + } + #RiskV 32 bit + else if option system-arch = 00:27 { + #ToDo petitboot loader + # petitboot should support pxelinux config files + filename "unsupported"; + } + else if option system-arch = 00:0e { + # FIXME add petitboot support for ppc64(le) + filename "grub/grub.ppc64le"; + } + else + { + filename "grub/grub.0"; + } + } +} + +# Dummy subnet for the "orthos" Docker network itself (see compose.common.yaml's pinned +# "networks.orthos.ipam.config") - dhcpd refuses to listen on *any* interface unless every network +# it's actually attached to has a matching "subnet" declaration, and the "cobbler-dhcp" sidecar's +# only interface is on this bridge network, not a real "192.0.2.0/24" segment. No "range" here on +# purpose: this subnet exists only so dhcpd accepts binding to that interface at all - it hands out +# no leases and PXE clients are served by the "192.0.2.0/24" subnet above. +subnet 172.20.0.0 netmask 255.255.0.0 { +} + +#for dhcp_tag in $dhcp_tags.keys(): + ## group could be subnet if your dhcp tags line up with your subnets or really any valid dhcpd.conf construct ... + ## if you only use the default dhcp tag in cobbler, the group block can be deleted for a flat configuration +# group for Cobbler DHCP tag: $dhcp_tag +group { + #for mac in $dhcp_tags[$dhcp_tag].keys(): + #set iface = $dhcp_tags[$dhcp_tag][$mac] + host $iface.name { + #if $iface.interface_type == "infiniband": + option dhcp-client-identifier = $mac; + #else + hardware ethernet $mac; + #end if + #if $iface.ipv4.address: + fixed-address $iface.ipv4.address; + #end if + #if $iface.dns.name: + option host-name "$iface.dns.name"; + #else if $iface.hostname: + option host-name "$iface.hostname"; + #end if + #if $iface.ipv4.netmask: + option subnet-mask $iface.ipv4.netmask; + #end if + #if $iface.if_gateway: + option routers $iface.if_gateway; + #else if $iface.gateway: + option routers $iface.gateway; + #end if + #set breed = $getVar('iface.distro.breed',None) + #if $breed == "vmware": + #if $iface.enable_ipxe: + if option system-arch = 00:07 or option system-arch = 00:09 { ## UEFI + if exists user-class and option user-class = "iPXE" { + filename = "$iface.filename_esxi"; + } else { + filename = "esxi/snponly.efi"; + } + } else { ## BIOS + if exists user-class and option user-class = "iPXE" { + filename = "esxi/pxelinux.0"; + } else { + filename = "esxi/undionly.pxe"; + } + } + #else + if option system-arch = 00:07 or option system-arch = 00:09 { + filename = "$iface.filename_esxi"; + } else { + filename = "esxi/pxelinux.0"; + } + #end if + #else if $iface.enable_ipxe: + if exists user-class and option user-class = "iPXE" { + filename "http://$cobbler_server/cblr/svc/op/ipxe/system/$iface.owner"; + } else { + filename "undionly.pxe"; + } + #end if + #if $iface.next_server_v4: + next-server $iface.next_server_v4; + #end if + #if $iface.filename: + filename "$iface.filename"; + #end if + #if $iface.dns.name_servers: + #set $mynameservers = ','.join($iface.dns.name_servers) + option domain-name-servers $mynameservers; + #end if + } + #end for +} +#end for diff --git a/docker/orthos/cobbler-templates/dhcp6.template b/docker/orthos/cobbler-templates/dhcp6.template new file mode 100644 index 000000000..889b865ab --- /dev/null +++ b/docker/orthos/cobbler-templates/dhcp6.template @@ -0,0 +1,105 @@ +# ****************************************************************** +# Cobbler managed dhcpd6.conf file +# +# generated from cobbler dhcp6.conf template ($date) +# Do NOT make changes to /etc/dhcpd6.conf. Instead, make your changes +# in this template, as /etc/dhcpd6.conf will be overwritten. +# +# Adapted from Cobbler's built-in cheetah/etc/dhcp6.template (see +# cobbler/data/templates/cheetah/etc/dhcp6.template in the Cobbler repo) - only the +# "subnet6" block below was changed, to the "2001:db8::/64" network used by the orthos2 +# dev stack, registered as the active "dhcpv6" template via +# docker/orthos/setup_cobbler_dhcp_templates.py. +# ****************************************************************** + +log-facility local0; + +default-lease-time 14400; # 4 hours +max-lease-time 172800; # 48 hours +allow leasequery; + +# Server side command to enable rapid-commit (2 packet exchange) +#option dhcp6.rapid-commit; + +# The delay before information-request refresh +# (minimum is 10 minutes, maximum one day, default is to not refresh) +# (set to 6 hours) +option dhcp6.info-refresh-time 21600; + +# Set preference to 255 (maximum) in order to avoid waiting for +# additional servers when there is only one +option dhcp6.preference 255; + +# Global definitions for name server address(es) and domain search list +#option dhcp6.name-servers fc00:501:ffff:100:200:ff:fe00:3f3e; +option dhcp6.domain-search "orthos2.test"; + +option dhcp6.bootfile-url code 59 = string ; + +class "pxeclients" { + match if substring (option vendor-class-identifier, 0, 9) = "PXEClient"; + if substring (option vendor-class-identifier, 15, 5) = "00007" { + option dhcp6.bootfile-url "tftp://[$next_server_v6]/grub/grubx64.efi"; + } + else if substring (option vendor-class-identifier, 15, 5) = "00000" { + option dhcp6.bootfile-url "tftp://[$next_server_v6]/grub/grub.0"; + } +} + +subnet6 2001:db8::/64 { + range6 2001:db8::100 2001:db8::1ff; + option dhcp6.name-servers 2001:db8::1; + option dhcp6.domain-search "orthos2.test"; +} + + +#for dhcp_tag in $dhcp_tags.keys(): + ## group could be subnet if your dhcp tags line up with your subnets + ## or really any valid dhcpd.conf construct ... if you only use the + ## default dhcp tag in cobbler, the group block can be deleted for a + ## flat configuration + ## group for Cobbler DHCP tag: $dhcp_tag +group { + #for mac in $dhcp_tags[$dhcp_tag].keys(): + #set iface = $dhcp_tags[$dhcp_tag][$mac] +host $iface.name { + ## + ## dhcp-client-identifier + #if $iface.interface_type == "infiniband": + option dhcp-client-identifier = $mac; + #else + hardware ethernet $mac; + #end if + ## + ## fixed-address6 + ## "ipv6_address" is only ever populated by isc.py's bond/bridge-slave branch + ## (cobbler/modules/managers/isc.py) - every other (i.e. almost all) interfaces only carry the + ## nested "ipv6"/"address" from NetworkInterface.to_dict(), same as $iface.ipv4.address on the + ## IPv4 side, causing a Cheetah "cannot find 'ipv6_address'" NameMapper error for them. + fixed-address6 $iface.ipv6.address; + ## + ## host-name + ## Note: upstream's built-in dhcp6.template (cobbler/data/templates/cheetah/etc/dhcp6.template) + ## references "$iface.dns_name" here, which cobbler/modules/managers/isc.py never populates on + ## the per-interface dict it passes to this template (it only sets "hostname" and nested + ## "dns"/"name" - see isc.py's write_v6_config()) - that's a real templating bug upstream, + ## reproduced here as "$iface.dns.name" instead, matching dhcp.template's already-working + ## equivalent check. + #if $iface.hostname: + option host-name "$iface.hostname"; + #else if $iface.dns.name: + option host-name "$iface.dns.name"; + #end if + ## + ## dhcp6.bootfile-url + ## Do we have a host specific next_server or filename + ## In DHCPv6 that means: Do we point to a host specific boot loader + #set host_filename = $iface.get("filename", None) + #set host_next_server_v6 = $iface.get("next_server_v6", $next_server_v6) + #if $host_next_server_v6 != $next_server_v6 or $host_filename: + option dhcp6.bootfile-url "tftp://[$host_next_server_v6]/$host_filename"; + #end if +} + #end for +} +#end for diff --git a/docker/orthos/cobbler-templates/named.template b/docker/orthos/cobbler-templates/named.template new file mode 100644 index 000000000..9b2ad7386 --- /dev/null +++ b/docker/orthos/cobbler-templates/named.template @@ -0,0 +1,43 @@ +options { + listen-on port 53 { 127.0.0.1; }; + directory "@@bind_zonefiles@@"; + dump-file "@@bind_zonefiles@@/data/cache_dump.db"; + statistics-file "@@bind_zonefiles@@/data/named_stats.txt"; + memstatistics-file "@@bind_zonefiles@@/data/named_mem_stats.txt"; + allow-query { localhost; }; + recursion yes; +}; + +# Adapted from Cobbler's built-in cheetah/etc/named.template (see +# cobbler/data/templates/cheetah/etc/named.template in the Cobbler repo) - the built-in "logging" +# block below writes to a *relative* path ("data/named.run", under "directory" above) whose parent +# "data/" is never created by either cobblerd or this image, so named fails at startup validating it +# ("checking logging configuration failed: file not found") before it ever gets to loading zones. +# Log to stderr instead - Docker captures it into "docker compose logs cobbler-dns" the same as +# stdout - and drop the dependency on that missing directory entirely. +logging { + channel stdout_log { + stderr; + severity dynamic; + print-time yes; + print-severity yes; + print-category yes; + }; + category default { stdout_log; }; + category queries { stdout_log; }; +}; + +#for $zone in $forward_zones +zone "${zone}." { + type master; + file "$zone"; +}; + +#end for +#for $zone, $arpa in $reverse_zones +zone "${arpa}." { + type master; + file "$zone"; +}; + +#end for diff --git a/docker/orthos/setup_cobbler_dhcp_templates.py b/docker/orthos/setup_cobbler_dhcp_templates.py new file mode 100644 index 000000000..9f61449ff --- /dev/null +++ b/docker/orthos/setup_cobbler_dhcp_templates.py @@ -0,0 +1,66 @@ +""" +Registers the orthos2 dev stack's custom DHCPv4/v6 templates (docker/orthos/cobbler-templates/) +as active Cobbler Template items, so `cobbler sync` renders subnets for "192.0.2.0/24" (IPv4) and +"2001:db8::/64" (IPv6) instead of the built-in example templates. Run inside the "orthos2" +container via: + + python3.11 manage.py shell =4.0.0 no longer reads a fixed filesystem path like /etc/cobbler/dhcp.template at +render time (see docs/user-guide/templating.rst) - modules.managers.isc looks up a Template item +by tag instead (api.find_template(tags=<"dhcpv4"|"dhcpv6">), preferring one tagged "active" over +one tagged "default"). Registering one is therefore a two-step process: the actual template file +has to already exist under settings.autoinstall_templates_dir (done here by compose.common.yaml's +"cobblerd" service mounting the "cobbler-dhcp-template"/"cobbler-dhcpv6-template" configs into +/var/lib/cobbler/templates/), then a Template item is created pointing at it by relative path. +""" + +import xmlrpc.client # nosec: B411 + +from orthos2.data.models import Domain + +DOMAIN_NAME = "orthos2.test" + +# (template name, relative path under /var/lib/cobbler/templates, tag) +TEMPLATES = [ + ("orthos2-dhcpv4", "orthos2-dhcp.template", "dhcpv4"), + ("orthos2-dhcpv6", "orthos2-dhcp6.template", "dhcpv6"), +] + + +def register_template( + server: xmlrpc.client.Server, token: str, name: str, path: str, tag: str +) -> None: + if server.has_item("template", name, token): + print(f'Template "{name}" already exists, skipping.') + return + + template_id = server.new_template(token) + server.modify_template(template_id, ["name"], name, token) + server.modify_template(template_id, ["template_type"], "cheetah", token) + server.modify_template(template_id, ["uri", "schema"], "file", token) + server.modify_template(template_id, ["uri", "path"], path, token) + # "active" beats a "default"-tagged template (the built-in one) for the same tag - see + # cobbler/modules/managers/isc.py's _write_config(). + server.modify_template(template_id, ["tags"], [tag, "active"], token) + server.save_template(template_id, True, True, "new", token) + print(f'Created template "{name}" (tags: {tag}, active).') + + +def main() -> None: + domain = Domain.objects.get(name=DOMAIN_NAME) + if not domain.cobbler_server: + raise RuntimeError(f'Domain "{DOMAIN_NAME}" has no Cobbler server configured!') + + server = xmlrpc.client.Server( # nosec: B411 + f"http://{domain.cobbler_server.fqdn}/cobbler_api" + ) + token = server.login(domain.cobbler_server_username, domain.cobbler_server_password) + + for name, path, tag in TEMPLATES: + register_template(server, token, name, path, tag) + + +main() diff --git a/docker/orthos/setup_cobbler_dns_templates.py b/docker/orthos/setup_cobbler_dns_templates.py new file mode 100644 index 000000000..3e31e3bbf --- /dev/null +++ b/docker/orthos/setup_cobbler_dns_templates.py @@ -0,0 +1,56 @@ +""" +Registers the orthos2 dev stack's fixed "named_primary" template (docker/orthos/cobbler-templates/ +named.template) as the active Cobbler Template item, so the "cobbler-dns" sidecar actually starts. +Run inside the "orthos2" container via: + + python3.11 manage.py shell None: + domain = Domain.objects.get(name=DOMAIN_NAME) + if not domain.cobbler_server: + raise RuntimeError(f'Domain "{DOMAIN_NAME}" has no Cobbler server configured!') + + server = xmlrpc.client.Server( # nosec: B411 + f"http://{domain.cobbler_server.fqdn}/cobbler_api" + ) + token = server.login(domain.cobbler_server_username, domain.cobbler_server_password) + + if server.has_item("template", TEMPLATE_NAME, token): + print(f'Template "{TEMPLATE_NAME}" already exists, skipping.') + return + + template_id = server.new_template(token) + server.modify_template(template_id, ["name"], TEMPLATE_NAME, token) + server.modify_template(template_id, ["template_type"], "cheetah", token) + server.modify_template(template_id, ["uri", "schema"], "file", token) + server.modify_template(template_id, ["uri", "path"], TEMPLATE_PATH, token) + # "active" beats a "default"-tagged template (the built-in one) for the same tag - see + # cobbler/modules/managers/bind.py's __write_named_conf(). + server.modify_template(template_id, ["tags"], [TEMPLATE_TAG, "active"], token) + server.save_template(template_id, True, True, "new", token) + print(f'Created template "{TEMPLATE_NAME}" (tags: {TEMPLATE_TAG}, active).') + + +main() diff --git a/docker/orthos/setup_fake_cobbler_distro.py b/docker/orthos/setup_fake_cobbler_distro.py new file mode 100644 index 000000000..7084f89b3 --- /dev/null +++ b/docker/orthos/setup_fake_cobbler_distro.py @@ -0,0 +1,110 @@ +""" +Creates a fake Cobbler Distro+Profile pair for local dev-stack experimentation, so a Machine can be +set up in Orthos 2 without a real distro tree. Run inside the "orthos2" container via: + + python3.11 manage.py shell *", so the + profile name must start with the architecture name. + * setup() reconstructs the profile name as f"{machine.architecture}:{choice}", where "choice" is + whatever get_setup_records*() returned - which strips a leading ":" segment when the raw + Cobbler name has two colons. For that round-trip to land back on the real profile, the name must + be the 3-segment form: "::". +""" + +import os +import xmlrpc.client # nosec: B411 + +from orthos2.data.models import Architecture, Domain + +DOMAIN_NAME = "orthos2.test" +ARCHITECTURE_NAME = "x86_64" +DISTRO_LABEL = "FAKE-1.0" +INSTALL_CHOICE = "install" + +DISTRO_NAME = f"{ARCHITECTURE_NAME}:{DISTRO_LABEL}" +PROFILE_NAME = f"{ARCHITECTURE_NAME}:{DISTRO_LABEL}:{INSTALL_CHOICE}" + +# Bind-mounted read-only into cobblerd/http-api at this same path (see compose.common.yaml's +# "cobblerd"/"http-api" services) from "./distro-sources" at the repository root - which is also +# where this container sees it, at "/code/distro-sources", since the whole repo is bind-mounted at +# "/code". Cobbler's own kernel-filename check requires the basename "vmlinuz" (or a handful of other +# recognized kernel names); the initrd name is unconstrained. +DISTRO_SOURCE_DIR_IN_ORTHOS2 = f"/code/distro-sources/{DISTRO_LABEL}" +DISTRO_SOURCE_DIR_IN_COBBLERD = f"/srv/distro-sources/{DISTRO_LABEL}" + + +def create_fake_kernel_and_initrd() -> None: + os.makedirs(DISTRO_SOURCE_DIR_IN_ORTHOS2, exist_ok=True) + kernel_path = os.path.join(DISTRO_SOURCE_DIR_IN_ORTHOS2, "vmlinuz") + initrd_path = os.path.join(DISTRO_SOURCE_DIR_IN_ORTHOS2, "initrd.img") + if not os.path.exists(kernel_path): + with open(kernel_path, "w", encoding="utf-8") as kernel_file: + kernel_file.write("FAKE-KERNEL-FOR-ORTHOS2-TESTING\n") + if not os.path.exists(initrd_path): + with open(initrd_path, "w", encoding="utf-8") as initrd_file: + initrd_file.write("FAKE-INITRD-FOR-ORTHOS2-TESTING\n") + + +def main() -> None: + create_fake_kernel_and_initrd() + + domain = Domain.objects.get(name=DOMAIN_NAME) + if not domain.cobbler_server: + raise RuntimeError(f'Domain "{DOMAIN_NAME}" has no Cobbler server configured!') + + server = xmlrpc.client.Server( # nosec: B411 + f"http://{domain.cobbler_server.fqdn}/cobbler_api" + ) + token = server.login(domain.cobbler_server_username, domain.cobbler_server_password) + + if server.has_item("distro", DISTRO_NAME, token): + print(f'Distro "{DISTRO_NAME}" already exists, skipping.') + else: + distro_id = server.new_distro(token) + server.modify_distro(distro_id, ["name"], DISTRO_NAME, token) + server.modify_distro(distro_id, ["arch"], ARCHITECTURE_NAME, token) + server.modify_distro( + distro_id, + ["kernel"], + f"{DISTRO_SOURCE_DIR_IN_COBBLERD}/vmlinuz", + token, + ) + server.modify_distro( + distro_id, + ["initrd"], + f"{DISTRO_SOURCE_DIR_IN_COBBLERD}/initrd.img", + token, + ) + server.modify_distro(distro_id, ["breed"], "generic", token) + server.save_distro(distro_id, True, True, "new", token) + print(f'Created distro "{DISTRO_NAME}".') + + if server.has_item("profile", PROFILE_NAME, token): + print(f'Profile "{PROFILE_NAME}" already exists, skipping.') + else: + # Profile.distro is resolved by Distro *uid*, not by name (see + # cobbler/items/profile.py's "distro" setter) - fetch it via get_distro(). + distro_uid = server.get_distro(DISTRO_NAME, False, False, token)["uid"] + profile_id = server.new_profile(token) + server.modify_profile(profile_id, ["name"], PROFILE_NAME, token) + server.modify_profile(profile_id, ["distro"], distro_uid, token) + server.save_profile(profile_id, True, True, "new", token) + print(f'Created profile "{PROFILE_NAME}".') + + architecture = Architecture.objects.get(name=ARCHITECTURE_NAME) + if architecture.default_profile != PROFILE_NAME: + architecture.default_profile = PROFILE_NAME + architecture.save() + print( + f'Set Architecture "{ARCHITECTURE_NAME}".default_profile = "{PROFILE_NAME}".' + ) + + +main() diff --git a/orthos2/utils/cobbler.py b/orthos2/utils/cobbler.py index b5e46c2ea..4af4a39c0 100644 --- a/orthos2/utils/cobbler.py +++ b/orthos2/utils/cobbler.py @@ -1,6 +1,6 @@ """ Utility module that wraps functionality that is related to Cobbler. This is assuming that the used Cobbler server -has version 3.3.6 or newer. +has version 4.0.0 or newer. """ import enum @@ -97,7 +97,7 @@ def get_filename(machine: "Machine") -> Optional[str]: def login_required( - func: Callable[Concatenate["CobblerServer", P], R] + func: Callable[Concatenate["CobblerServer", P], R], ) -> Callable[Concatenate["CobblerServer", P], R]: """ Decorator to ensure that the user is logged in. This only works for "CobblerServer". @@ -224,45 +224,61 @@ def add_machine( ) if not self._xmlrpc_server.has_item("profile", default_profile, self._token): raise CobblerException("default profile didn't exist on cobbler server") + default_profile_uid = self._get_profile_uid(default_profile) tftp_server = get_tftp_server(machine) kernel_options = machine.kernel_options if machine.kernel_options else "" if save == CobblerSaveModes.NEW: object_id = self._xmlrpc_server.new_system(self._token) else: - object_id = self._xmlrpc_server.get_system_handle(machine.fqdn, self._token) + object_id = self._xmlrpc_server.get_system_handle(machine.fqdn) old_machine_dict = self._get_cobbler_datastructure(machine) - if "bmc" in old_machine_dict.get("interfaces", {}): - old_machine_has_bmc = True + # BMC is now a separate item identified by name + old_machine_has_bmc = self._xmlrpc_server.has_item( # type: ignore + "network_interface", f"{machine.fqdn}-bmc", self._token + ) if ( old_machine_dict.get("serial_device", -1) > -1 and old_machine_dict.get("serial_baud_rate", -1) > -1 ): old_machine_has_serial_console = True - if old_machine_dict.get("power_type", "") != "": + # Cobbler >=4.0.0 nests power fields under a "power" sub-object (see + # cobbler/items/options/power.py) rather than flat "power_type" etc. keys. + if old_machine_dict.get("power", {}).get("type", "") != "": old_machine_has_remote_power = True if not isinstance(object_id, str): raise TypeError("Cobbler System ID must be a string!") - self._xmlrpc_server.modify_system(object_id, "name", machine.fqdn, self._token) self._xmlrpc_server.modify_system( - object_id, "profile", default_profile, self._token + object_id, ["name"], machine.fqdn, self._token + ) + self._xmlrpc_server.modify_system( + object_id, ["profile"], default_profile_uid, self._token ) self.add_network_interfaces(machine, object_id) self._xmlrpc_server.modify_system( - object_id, "filename", get_filename(machine) or "", self._token + object_id, ["filename"], get_filename(machine) or "", self._token ) if tftp_server: + # Cobbler >=4.0.0 nests these under a "tftp" sub-object (see + # cobbler/items/options/tftp.py) rather than flat "next_server_v4"/ + # "next_server_v6" properties on the system itself. if tftp_server.ip_address_v4: self._xmlrpc_server.modify_system( - object_id, "next_server_v4", tftp_server.ip_address_v4, self._token + object_id, + ["tftp", "next_server_v4"], + tftp_server.ip_address_v4, + self._token, ) if tftp_server.ip_address_v6: self._xmlrpc_server.modify_system( - object_id, "next_server_v6", tftp_server.ip_address_v6, self._token + object_id, + ["tftp", "next_server_v6"], + tftp_server.ip_address_v6, + self._token, ) if old_machine_has_bmc and not machine.has_bmc(): - self.remove_bmc(object_id, save) + self.remove_bmc(machine.fqdn, save) if machine.has_bmc(): self.add_bmc(machine, object_id) if old_machine_has_remote_power and not machine.has_remotepower(): @@ -274,11 +290,13 @@ def add_machine( if machine.has_serialconsole(): self.add_serial_console(machine, object_id) self._xmlrpc_server.modify_system( - object_id, "kernel_options", kernel_options, self._token + object_id, ["kernel_options"], kernel_options, self._token ) if save != CobblerSaveModes.SKIP: - self._xmlrpc_server.save_system(object_id, self._token, save.value) + self._xmlrpc_server.save_system( + object_id, True, True, save.value, self._token + ) @login_required def add_network_interfaces( @@ -288,11 +306,11 @@ def add_network_interfaces( save: CobblerSaveModes = CobblerSaveModes.SKIP, ) -> None: """ - Add the primary network interface of the machine to Cobbler. + Add the network interfaces of the machine to Cobbler. :param machine: Machine that should be added or updated. - :param object_id: ID of object to be added. - :param save: Whether to save the machine or not. + :param object_id: UID of the Cobbler system object. + :param save: Unused; kept for API compatibility. """ for idx, intf in enumerate(machine.networkinterfaces.all()): # type: ignore if not intf.mac_address: @@ -301,7 +319,6 @@ def add_network_interfaces( machine.fqdn if intf.primary else str(idx), ) continue - if not intf.ip_address_v4 and not intf.ip_address_v6: logger.info( "Skipping machine interface %s because it has neither IPv4 nor IPv6 addresses", @@ -309,28 +326,48 @@ def add_network_interfaces( ) continue - interface_key = "default" if intf.primary else idx + intf_name = ( + f"{machine.fqdn}-default" if intf.primary else f"{machine.fqdn}-{idx}" + ) + + if self._xmlrpc_server.has_item( + "network_interface", intf_name, self._token + ): + intf_handle = self._xmlrpc_server.get_network_interface_handle( + intf_name + ) + edit_mode = "bypass" + else: + intf_handle = self._xmlrpc_server.new_network_interface( + object_id, self._token + ) + edit_mode = "new" - interface_options = { - f"macaddress-{interface_key}": intf.mac_address, - f"ipaddress-{interface_key}": intf.ip_address_v4 or "", - f"ipv6address-{interface_key}": intf.ip_address_v6 or "", - f"management-{interface_key}": True, - } + self._xmlrpc_server.modify_network_interface( + intf_handle, ["name"], intf_name, self._token + ) + self._xmlrpc_server.modify_network_interface( + intf_handle, ["mac_address"], intf.mac_address, self._token + ) + self._xmlrpc_server.modify_network_interface( + intf_handle, ["ipv4", "address"], intf.ip_address_v4 or "", self._token + ) + self._xmlrpc_server.modify_network_interface( + intf_handle, ["ipv6", "address"], intf.ip_address_v6 or "", self._token + ) + self._xmlrpc_server.modify_network_interface( + intf_handle, ["management"], True, self._token + ) if intf.primary: - interface_options[f"hostname-{interface_key}"] = get_hostname( - machine.fqdn + self._xmlrpc_server.modify_network_interface( + intf_handle, ["dns", "name"], machine.fqdn, self._token ) - interface_options[f"dnsname-{interface_key}"] = machine.fqdn - self._xmlrpc_server.modify_system( - object_id, "modify_interface", interface_options, self._token + self._xmlrpc_server.save_network_interface( + intf_handle, True, True, edit_mode, self._token ) - if save != CobblerSaveModes.SKIP: - self._xmlrpc_server.save_system(object_id, self._token, save.value) - @login_required def add_bmc( self, @@ -339,30 +376,53 @@ def add_bmc( save: CobblerSaveModes = CobblerSaveModes.SKIP, ) -> None: """ - Add the BMC of the machine to Cobbler. + Add the BMC of the machine to Cobbler as a dedicated network interface item. :param machine: Machine that should be added or updated. - :param object_id: ID of object to be added. - :param save: Whether to save the machine or not. + :param object_id: UID of the Cobbler system object. + :param save: Unused; kept for API compatibility. """ bmc = machine.bmc - interface_options = { - "interfacetype-bmc": "bmc", - "macaddress-bmc": bmc.mac, - "hostname-bmc": get_hostname(bmc.fqdn), - "dnsname-bmc": bmc.fqdn, - } + bmc_intf_name = f"{machine.fqdn}-bmc" + + if self._xmlrpc_server.has_item( + "network_interface", bmc_intf_name, self._token + ): + bmc_handle = self._xmlrpc_server.get_network_interface_handle(bmc_intf_name) + edit_mode = "bypass" + else: + bmc_handle = self._xmlrpc_server.new_network_interface( + object_id, self._token + ) + edit_mode = "new" + + self._xmlrpc_server.modify_network_interface( + bmc_handle, ["name"], bmc_intf_name, self._token + ) + self._xmlrpc_server.modify_network_interface( + bmc_handle, ["interface_type"], "bmc", self._token + ) + self._xmlrpc_server.modify_network_interface( + bmc_handle, ["mac_address"], bmc.mac, self._token + ) + self._xmlrpc_server.modify_network_interface( + bmc_handle, ["dns", "name"], bmc.fqdn, self._token + ) + ipv4_address = bmc.ip_address_v4 if ipv4_address is not None: - interface_options["ipaddress-bmc"] = ipv4_address + self._xmlrpc_server.modify_network_interface( + bmc_handle, ["ipv4", "address"], ipv4_address, self._token + ) ipv6_address = bmc.ip_address_v6 if ipv6_address is not None: - interface_options["ipv6address-bmc"] = ipv6_address - self._xmlrpc_server.modify_system( - object_id, "modify_interface", interface_options, self._token + self._xmlrpc_server.modify_network_interface( + bmc_handle, ["ipv6", "address"], ipv6_address, self._token + ) + + self._xmlrpc_server.save_network_interface( + bmc_handle, True, True, edit_mode, self._token ) - if save != CobblerSaveModes.SKIP: - self._xmlrpc_server.save_system(object_id, self._token, save.value) @login_required def add_serial_console( @@ -381,13 +441,15 @@ def add_serial_console( console = machine.serialconsole self._xmlrpc_server.modify_system( - object_id, "serial_device", console.kernel_device_num, self._token + object_id, ["serial_device"], console.kernel_device_num, self._token ) self._xmlrpc_server.modify_system( - object_id, "serial_baud_rate", console.baud_rate, self._token + object_id, ["serial_baud_rate"], console.baud_rate, self._token ) if save != CobblerSaveModes.SKIP: - self._xmlrpc_server.save_system(object_id, self._token, save.value) + self._xmlrpc_server.save_system( + object_id, True, True, save.value, self._token + ) @login_required def add_power_options( @@ -407,54 +469,62 @@ def add_power_options( fence = remotepower.get_remotepower_fence() self._xmlrpc_server.modify_system( - object_id, "power_type", fence.name, self._token + object_id, ["power", "type"], fence.name, self._token ) if fence.identity_file == "": username, password = remotepower.get_credentials() self._xmlrpc_server.modify_system( - object_id, "power_user", username, self._token + object_id, ["power", "user"], username, self._token ) self._xmlrpc_server.modify_system( - object_id, "power_pass", password, self._token + object_id, ["power", "password"], password, self._token ) else: self._xmlrpc_server.modify_system( - object_id, "power_user", fence.username, self._token + object_id, ["power", "user"], fence.username, self._token ) self._xmlrpc_server.modify_system( - object_id, "power_identity_file", fence.identity_file, self._token + object_id, + ["power", "identity_file"], + fence.identity_file, + self._token, ) if fence.use_hostname_as_port: # The following is ignored since at runtime we will always have a hostname dynamically added. self._xmlrpc_server.modify_system( - object_id, "power_id", get_hostname(machine.hostname), self._token # type: ignore + object_id, ["power", "id"], get_hostname(machine.hostname), self._token # type: ignore ) elif fence.use_port: # Temporary workaround until fence raritan accepts port as --plug param if fence.name == "raritan": self._xmlrpc_server.modify_system( object_id, - "power_id", + ["power", "id"], f"system1/outlet{remotepower.port}", self._token, ) else: self._xmlrpc_server.modify_system( - object_id, "power_id", remotepower.port, self._token + object_id, ["power", "id"], remotepower.port, self._token ) self._xmlrpc_server.modify_system( - object_id, "power_address", remotepower.get_power_address(), self._token + object_id, + ["power", "address"], + remotepower.get_power_address(), + self._token, ) if remotepower.options != "": self._xmlrpc_server.modify_system( - object_id, "power_options", remotepower.options, self._token + object_id, ["power", "options"], remotepower.options, self._token ) if save != CobblerSaveModes.SKIP: - self._xmlrpc_server.save_system(object_id, self._token, save.value) + self._xmlrpc_server.save_system( + object_id, True, True, save.value, self._token + ) @login_required def add_remote_power_device( @@ -468,33 +538,34 @@ def add_remote_power_device( :param device: RemotePowerDevice to be added. :param save: Whether to save the machine or update it. """ - if not self._xmlrpc_server.has_item( - "profile", device.architecture.default_profile, self._token + default_profile = device.architecture.default_profile + if not default_profile or not self._xmlrpc_server.has_item( + "profile", default_profile, self._token ): raise CobblerException( - f"profile {device.architecture.default_profile} didn't exist on cobbler server" + f"profile {default_profile} didn't exist on cobbler server" ) if save == CobblerSaveModes.NEW: object_id = self._xmlrpc_server.new_system(self._token) else: - object_id = self._xmlrpc_server.get_system_handle(device.fqdn, self._token) + object_id = self._xmlrpc_server.get_system_handle(device.fqdn) if not isinstance(object_id, str): raise TypeError("Cobbler System ID must be a string!") - self._xmlrpc_server.modify_system(object_id, "name", device.fqdn, self._token) + self._xmlrpc_server.modify_system(object_id, ["name"], device.fqdn, self._token) self._xmlrpc_server.modify_system( - object_id, "profile", device.architecture.default_profile, self._token + object_id, + ["profile"], + self._get_profile_uid(default_profile), + self._token, ) - self._xmlrpc_server.modify_system(object_id, "filename", "", self._token) + self._xmlrpc_server.modify_system(object_id, ["filename"], "", self._token) if not device.mac: logger.info( "Skipping remote power device %s because it has no MAC address", device.fqdn, ) - - if save != CobblerSaveModes.SKIP: - self._xmlrpc_server.save_system(object_id, self._token, save.value) return if not device.ip_address_v4 and not device.ip_address_v6: @@ -502,26 +573,45 @@ def add_remote_power_device( "Skipping remote power device %s because it has neither IPv4 nor IPv6 addresses", device.fqdn, ) - - if save != CobblerSaveModes.SKIP: - self._xmlrpc_server.save_system(object_id, self._token, save.value) return - interface_options = { - "macaddress-default": device.mac, - "ipaddress-default": device.ip_address_v4 or "", - "ipv6address-default": device.ip_address_v6 or "", - "management-default": True, - "hostname-default": get_hostname(device.fqdn), - "dnsname-default": device.fqdn, - } + intf_name = f"{device.fqdn}-default" - self._xmlrpc_server.modify_system( - object_id, "modify_interface", interface_options, self._token + if self._xmlrpc_server.has_item("network_interface", intf_name, self._token): + intf_handle = self._xmlrpc_server.get_network_interface_handle(intf_name) + edit_mode = "bypass" + else: + intf_handle = self._xmlrpc_server.new_network_interface( + object_id, self._token + ) + edit_mode = "new" + + self._xmlrpc_server.modify_network_interface( + intf_handle, ["name"], intf_name, self._token + ) + self._xmlrpc_server.modify_network_interface( + intf_handle, ["mac_address"], device.mac, self._token + ) + self._xmlrpc_server.modify_network_interface( + intf_handle, ["ipv4", "address"], device.ip_address_v4 or "", self._token + ) + self._xmlrpc_server.modify_network_interface( + intf_handle, ["ipv6", "address"], device.ip_address_v6 or "", self._token + ) + self._xmlrpc_server.modify_network_interface( + intf_handle, ["management"], True, self._token + ) + self._xmlrpc_server.modify_network_interface( + intf_handle, ["dns", "name"], device.fqdn, self._token + ) + self._xmlrpc_server.save_network_interface( + intf_handle, True, True, edit_mode, self._token ) if save != CobblerSaveModes.SKIP: - self._xmlrpc_server.save_system(object_id, self._token, save.value) + self._xmlrpc_server.save_system( + object_id, True, True, save.value, self._token + ) @login_required def _get_cobbler_datastructure(self, machine: "Machine") -> Dict[str, Any]: @@ -539,6 +629,31 @@ def _get_cobbler_datastructure(self, machine: "Machine") -> Dict[str, Any]: ) return system_dict + @login_required + def _get_profile_uid(self, profile_name: str) -> str: + """ + Resolve a Cobbler profile name to its uid. + + System.profile (see cobbler/items/system.py) is set by uid, not by name, unlike + e.g. has_item()/find_profile() which operate on the name. + + :param profile_name: Name of the profile to resolve. + """ + profile_dict = self._xmlrpc_server.get_profile( + profile_name, False, False, self._token + ) + if not isinstance(profile_dict, dict): + raise ValueError( + "Cobbler Server didn't return a dictionary for profile %s" + % profile_name + ) + uid = profile_dict.get("uid") + if not isinstance(uid, str): + raise TypeError( + "Cobbler Server returned incorrect data type for profile uid" + ) + return uid + @login_required def set_netboot_state( self, @@ -553,12 +668,14 @@ def set_netboot_state( :param netboot_state: Whether to enable or disable the netboot state. :param save: Whether to save the machine or not. """ - system_handle = self._xmlrpc_server.get_system_handle(machine.fqdn, self._token) + system_handle = self._xmlrpc_server.get_system_handle(machine.fqdn) self._xmlrpc_server.modify_system( - system_handle, "netboot_enabled", netboot_state, self._token + system_handle, ["netboot_enabled"], netboot_state, self._token ) if save != CobblerSaveModes.SKIP: - self._xmlrpc_server.save_system(system_handle, self._token, save.value) + self._xmlrpc_server.save_system( + system_handle, True, True, save.value, self._token + ) @login_required def machine_deployed(self, machine: "Machine") -> bool: @@ -613,7 +730,11 @@ def remove(self, machine: "Machine") -> None: "Cobbler server is not running: {}".format(self._cobbler_server.fqdn) ) try: - self._xmlrpc_server.remove_system(machine.fqdn, self._token, False) + # recursive=True: Cobbler >=4.0.0 stores a system's NetworkInterfaces + # as separate items, not embedded sub-objects - a non-recursive + # removal is refused with "removal would orphan network_interface(s)" + # for any system that has one. + self._xmlrpc_server.remove_system(machine.fqdn, self._token, True) except xmlrpc.client.Fault as xmlrpc_fault: logging.error( 'Removing %s failed with "%s"', @@ -633,7 +754,8 @@ def remove_by_name(self, fqdn: str) -> None: "Cobbler server is not running: {}".format(self._cobbler_server.fqdn) ) try: - self._xmlrpc_server.remove_system(fqdn, self._token, False) + # recursive=True: see remove()'s comment above. + self._xmlrpc_server.remove_system(fqdn, self._token, True) except xmlrpc.client.Fault as xmlrpc_fault: logging.error( 'Removing %s failed with "%s"', fqdn, xmlrpc_fault.faultString @@ -666,19 +788,19 @@ def prune_stale(self, orthos_fqdns: set[str], dry_run: bool = True) -> list[str] @login_required def remove_bmc( - self, object_id: str, save: CobblerSaveModes = CobblerSaveModes.SKIP + self, fqdn: str, save: CobblerSaveModes = CobblerSaveModes.SKIP ) -> None: """ - Remove the virtual network interface that is present to represent the out-of-band management. + Remove the BMC network interface from Cobbler. - :param object_id: ID of object to be added. - :param save: Whether to save the machine or not. + :param fqdn: FQDN of the machine whose BMC interface should be removed. + :param save: Unused; kept for API compatibility. """ - self._xmlrpc_server.modify_system( - object_id, "delete_interface", "bmc", self._token - ) - if save != CobblerSaveModes.SKIP: - self._xmlrpc_server.save_system(object_id, self._token, save.value) + bmc_intf_name = f"{fqdn}-bmc" + if self._xmlrpc_server.has_item( + "network_interface", bmc_intf_name, self._token + ): + self._xmlrpc_server.remove_network_interface(bmc_intf_name, self._token) @login_required def remove_serial_console( @@ -690,12 +812,14 @@ def remove_serial_console( :param object_id: ID of object to be added. :param save: Whether to save the machine or not. """ - self._xmlrpc_server.modify_system(object_id, "serial_device", -1, self._token) + self._xmlrpc_server.modify_system(object_id, ["serial_device"], -1, self._token) self._xmlrpc_server.modify_system( - object_id, "serial_baud_rate", -1, self._token + object_id, ["serial_baud_rate"], -1, self._token ) if save != CobblerSaveModes.SKIP: - self._xmlrpc_server.save_system(object_id, self._token, save.value) + self._xmlrpc_server.save_system( + object_id, True, True, save.value, self._token + ) @login_required def remove_power_options( @@ -707,17 +831,25 @@ def remove_power_options( :param object_id: ID of object to be added. :param save: Whether to save the machine or not. """ - self._xmlrpc_server.modify_system(object_id, "power_type", "", self._token) - self._xmlrpc_server.modify_system(object_id, "power_user", "", self._token) + self._xmlrpc_server.modify_system(object_id, ["power", "type"], "", self._token) + self._xmlrpc_server.modify_system(object_id, ["power", "user"], "", self._token) + self._xmlrpc_server.modify_system( + object_id, ["power", "identity_file"], "", self._token + ) self._xmlrpc_server.modify_system( - object_id, "power_identity_file", "", self._token + object_id, ["power", "password"], "", self._token + ) + self._xmlrpc_server.modify_system(object_id, ["power", "id"], "", self._token) + self._xmlrpc_server.modify_system( + object_id, ["power", "address"], "", self._token + ) + self._xmlrpc_server.modify_system( + object_id, ["power", "options"], "", self._token ) - self._xmlrpc_server.modify_system(object_id, "power_pass", "", self._token) - self._xmlrpc_server.modify_system(object_id, "power_id", "", self._token) - self._xmlrpc_server.modify_system(object_id, "power_address", "", self._token) - self._xmlrpc_server.modify_system(object_id, "power_options", "", self._token) if save != CobblerSaveModes.SKIP: - self._xmlrpc_server.save_system(object_id, self._token, save.value) + self._xmlrpc_server.save_system( + object_id, True, True, save.value, self._token + ) @login_required def sync_dhcp(self) -> None: @@ -758,7 +890,7 @@ def get_profiles(self, architecture: str) -> List[str]: :param architecture: Architecture name. """ found_profiles = self._xmlrpc_server.find_profile( - {"name": architecture + "*"}, False, self._token + {"name": architecture + "*"}, False, False, self._token ) if not isinstance(found_profiles, list): raise TypeError( @@ -796,14 +928,18 @@ def setup(self, machine: "Machine", choice: str) -> None: self._cobbler_server.fqdn, choice, ) - object_id = self._xmlrpc_server.get_system_handle(machine.fqdn, self._token) + object_id = self._xmlrpc_server.get_system_handle(machine.fqdn) if choice: + profile_name = f"{machine.architecture}:{choice}" self._xmlrpc_server.modify_system( - object_id, "profile", f"{machine.architecture}:{choice}", self._token + object_id, + ["profile"], + self._get_profile_uid(profile_name), + self._token, ) self.set_netboot_state(machine, True) - self._xmlrpc_server.save_system(object_id, self._token, "bypass") + self._xmlrpc_server.save_system(object_id, True, True, "bypass", self._token) @login_required def powerswitch(self, machine: "Machine", action: str) -> str: @@ -867,7 +1003,4 @@ def __get_task_status(self, event_id: str) -> str: raise TypeError( "Cobbler Server returned incorrect data type for event status" ) - if event_status == "notification": - # This is a bug that Cobbler has in the 3.3.7 release. - return "running" return event_status diff --git a/orthos2/utils/tests/test_cobbler.py b/orthos2/utils/tests/test_cobbler.py index bd9ceaefa..002135065 100644 --- a/orthos2/utils/tests/test_cobbler.py +++ b/orthos2/utils/tests/test_cobbler.py @@ -100,11 +100,20 @@ def test_cobbler_add_remote_power_device(self) -> None: domain.cobbler_server = Machine.objects.get(fqdn="cobbler.orthos2.test") server = cobbler.CobblerServer(domain) testsys = RemotePowerDevice.objects.get(fqdn="bmc.orthos2.test") + # Fixture's Architecture has no default_profile set; the method requires a truthy one. + testsys.architecture.default_profile = "default_profile" - # Act + # has_item: True for profile existence check, False for interface existence check + has_item_responses = [True, False] with mock.patch.object( - server._xmlrpc_server, "has_item", return_value=True # type: ignore + server._xmlrpc_server, # type: ignore + "has_item", + side_effect=has_item_responses, ) as mock_has_item, mock.patch.object( + server._xmlrpc_server, # type: ignore + "get_profile", + return_value={"uid": "profile-uid"}, + ) as mock_get_profile, mock.patch.object( server._xmlrpc_server, "modify_system" # type: ignore ) as mock_system_modify, mock.patch.object( server._xmlrpc_server, "save_system" # type: ignore @@ -112,14 +121,37 @@ def test_cobbler_add_remote_power_device(self) -> None: server._xmlrpc_server, # type: ignore "new_system", return_value="system::bmc.orthos2.test", - ) as mock_system_new: + ) as mock_system_new, mock.patch.object( + server._xmlrpc_server, # type: ignore + "new_network_interface", + return_value="intf::bmc.orthos2.test:default", + ) as mock_intf_new, mock.patch.object( + server._xmlrpc_server, "modify_network_interface" # type: ignore + ) as mock_intf_modify, mock.patch.object( + server._xmlrpc_server, "save_network_interface" # type: ignore + ) as mock_intf_save: server.add_remote_power_device(testsys, save=cobbler.CobblerSaveModes.NEW) - # Assert - self.assertEqual(mock_has_item.call_count, 1) + # Assert: has_item called for profile check + interface existence check + self.assertEqual(mock_has_item.call_count, 2) + self.assertEqual(mock_get_profile.call_count, 1) self.assertEqual(mock_system_new.call_count, 1) - self.assertEqual(mock_system_modify.call_count, 4) + # modify_system: name, profile, filename = 3 calls + self.assertEqual(mock_system_modify.call_count, 3) + # System.profile is set by uid (see cobbler/items/system.py), not by name. + mock_system_modify.assert_any_call( + mock.ANY, ["profile"], "profile-uid", server._token + ) self.assertEqual(mock_system_save.call_count, 1) + # new_network_interface: 1 call (interface doesn't exist yet) + self.assertEqual(mock_intf_new.call_count, 1) + # modify_network_interface: name, mac, ipv4.address, ipv6.address, management, dns.name = 6 + self.assertEqual(mock_intf_modify.call_count, 6) + self.assertEqual(mock_intf_save.call_count, 1) + + mock_intf_modify.assert_any_call( + mock.ANY, ["name"], "bmc.orthos2.test-default", server._token + ) def test_cobbler_remotepowerdevice_deployed(self) -> None: # Arrange @@ -151,6 +183,10 @@ def test_cobbler_add_machine(self) -> None: ) as mock_default_profile, mock.patch.object( server._xmlrpc_server, "has_item", return_value=True # type: ignore ) as mock_has_item, mock.patch.object( + server._xmlrpc_server, # type: ignore + "get_profile", + return_value={"uid": "profile-uid"}, + ) as mock_get_profile, mock.patch.object( server._xmlrpc_server, "modify_system" # type: ignore ) as mock_system_modify, mock.patch.object( server._xmlrpc_server, "save_system" # type: ignore @@ -172,6 +208,7 @@ def test_cobbler_add_machine(self) -> None: # Assert self.assertEqual(mock_default_profile.call_count, 1) self.assertEqual(mock_has_item.call_count, 1) + self.assertEqual(mock_get_profile.call_count, 1) self.assertEqual(mock_system_new.call_count, 1) self.assertEqual(mock_system_modify.call_count, 5) self.assertEqual(mock_system_save.call_count, 1) @@ -179,6 +216,10 @@ def test_cobbler_add_machine(self) -> None: self.assertEqual(mock_add_bmc.call_count, 1) self.assertEqual(mock_add_power.call_count, 1) self.assertEqual(mock_add_serial.call_count, 1) + # System.profile is set by uid (see cobbler/items/system.py), not by name. + mock_system_modify.assert_any_call( + mock.ANY, ["profile"], "profile-uid", server._token + ) def test_cobbler_add_network_interfaces(self) -> None: # Arrange @@ -188,14 +229,43 @@ def test_cobbler_add_network_interfaces(self) -> None: testsys = Machine.objects.get(fqdn="testsys.orthos2.test") object_id = "system::testsys.orthos2.test" - # Act with mock.patch.object( - server._xmlrpc_server, "modify_system" # type: ignore - ) as mock_system_modify: + server._xmlrpc_server, "has_item", return_value=False # type: ignore + ) as mock_has_item, mock.patch.object( + server._xmlrpc_server, # type: ignore + "new_network_interface", + return_value="intf::testsys.orthos2.test:default", + ) as mock_intf_new, mock.patch.object( + server._xmlrpc_server, "modify_network_interface" # type: ignore + ) as mock_intf_modify, mock.patch.object( + server._xmlrpc_server, "save_network_interface" # type: ignore + ) as mock_intf_save: server.add_network_interfaces(testsys, object_id) - # Assert - self.assertEqual(mock_system_modify.call_count, 2) + # testsys.orthos2.test has 2 processable interfaces: + # NI pk=2 (primary, mac present, ipv6 only): + # new x1, + # modify x6 (name,mac,ipv4,ipv6,management,dns.name), + # save x1 + # NI pk=3 (non-primary, mac present, ipv4 only): + # new x1, + # modify x5 (name,mac,ipv4,ipv6,management), + # save x1 + # NI pk=4 (non-primary, no mac): skipped + self.assertEqual(mock_has_item.call_count, 2) + self.assertEqual(mock_intf_new.call_count, 2) + self.assertEqual(mock_intf_modify.call_count, 11) + self.assertEqual(mock_intf_save.call_count, 2) + + # Interface names must use a dhcpd-safe separator ("-", not ":") - isc + # dhcpd's lexer terminates unquoted host-block identifiers at ":", and + # Cobbler's isc.py builds that identifier from these names directly. + mock_intf_modify.assert_any_call( + mock.ANY, ["name"], "testsys.orthos2.test-default", server._token + ) + mock_intf_modify.assert_any_call( + mock.ANY, ["name"], "testsys.orthos2.test-1", server._token + ) def test_cobbler_add_bmc(self) -> None: # Arrange @@ -204,14 +274,29 @@ def test_cobbler_add_bmc(self) -> None: server = cobbler.CobblerServer(domain) testsys = Machine.objects.get(fqdn="testsys.orthos2.test") - # Act with mock.patch.object( - server._xmlrpc_server, "modify_system" # type: ignore - ) as mock_system_modify: + server._xmlrpc_server, "has_item", return_value=False # type: ignore + ) as mock_has_item, mock.patch.object( + server._xmlrpc_server, # type: ignore + "new_network_interface", + return_value="intf::testsys.orthos2.test:bmc", + ) as mock_intf_new, mock.patch.object( + server._xmlrpc_server, "modify_network_interface" # type: ignore + ) as mock_intf_modify, mock.patch.object( + server._xmlrpc_server, "save_network_interface" # type: ignore + ) as mock_intf_save: server.add_bmc(testsys, "system::testsys.orthos2.test") - # Assert - self.assertEqual(mock_system_modify.call_count, 1) + self.assertEqual(mock_has_item.call_count, 1) + self.assertEqual(mock_intf_new.call_count, 1) + # name, interface_type, mac_address, dns.name = 4 calls + # (BMC fixture has no ip_address_v4/v6, so conditional IP calls are skipped) + self.assertEqual(mock_intf_modify.call_count, 4) + self.assertEqual(mock_intf_save.call_count, 1) + + mock_intf_modify.assert_any_call( + mock.ANY, ["name"], "testsys.orthos2.test-bmc", server._token + ) def test_cobbler_add_serial_console(self) -> None: # Arrange @@ -245,6 +330,15 @@ def test_cobbler_add_power_options(self) -> None: # Assert self.assertEqual(mock_system_modify.call_count, 4) + # Cobbler >=4.0.0 nests power fields under "power" (see + # cobbler/items/options/power.py) - modify_system must be called with a + # two-element ["power", ] attribute path, not the old flat "power_*" + # names, or the value silently lands on a disconnected instance attribute + # instead of the real system.power.* option. + for call in mock_system_modify.mock_calls: + attribute_path = call.args[1] + self.assertEqual(len(attribute_path), 2) + self.assertEqual(attribute_path[0], "power") def test_cobbler_set_netboot_state(self) -> None: # Arrange @@ -331,7 +425,7 @@ def test_cobbler_remove_by_name(self) -> None: # Assert mock_system_remove.assert_called_once_with( - "stale.orthos2.test", mock.ANY, False + "stale.orthos2.test", mock.ANY, True ) def test_cobbler_remove_by_name_not_running(self) -> None: @@ -425,14 +519,17 @@ def test_cobbler_remove_bmc(self) -> None: domain.cobbler_server = Machine.objects.get(fqdn="cobbler.orthos2.test") server = cobbler.CobblerServer(domain) - # Act with mock.patch.object( - server._xmlrpc_server, "modify_system" # type: ignore - ) as mock_system_modify: - server.remove_bmc("system::testsys.orthos2.test") + server._xmlrpc_server, "has_item", return_value=True # type: ignore + ) as mock_has_item, mock.patch.object( + server._xmlrpc_server, "remove_network_interface" # type: ignore + ) as mock_intf_remove: + server.remove_bmc("testsys.orthos2.test") - # Assert - self.assertEqual(mock_system_modify.call_count, 1) + self.assertEqual(mock_has_item.call_count, 1) + mock_intf_remove.assert_called_once_with( + "testsys.orthos2.test-bmc", server._token + ) def test_cobbler_remove_serial_console(self) -> None: # Arrange @@ -463,6 +560,11 @@ def test_cobbler_remove_power_options(self) -> None: # Assert self.assertEqual(mock_system_modify.call_count, 7) + # Same nested-"power"-attribute-path requirement as add_power_options. + for call in mock_system_modify.mock_calls: + attribute_path = call.args[1] + self.assertEqual(len(attribute_path), 2) + self.assertEqual(attribute_path[0], "power") def test_cobbler_sync_dhcp(self) -> None: # Arrange @@ -495,10 +597,18 @@ def test_cobbler_get_profiles(self) -> None: domain.cobbler_server = Machine.objects.get(fqdn="cobbler.orthos2.test") server = cobbler.CobblerServer(domain) - # Act - profiles = server.get_profiles("x86_64") + with mock.patch.object( + server._xmlrpc_server, # type: ignore + "find_profile", + return_value=[], + ) as mock_find_profile: + # Act + profiles = server.get_profiles("x86_64") - # Assert + # Assert: Cobbler 4.0.0 signature: (criteria, expand, resolved, token) + mock_find_profile.assert_called_once_with( + {"name": "x86_64*"}, False, False, server._token + ) self.assertTrue(isinstance(profiles, list)) # type: ignore self.assertEqual(len(profiles), 0)