Skip to content

DO NOT MERGE: CI-only check for the full SONiC E2E series - #2573

Closed
ideaship wants to merge 13 commits into
mainfrom
ci/sonic-e2e-full-stack-check
Closed

DO NOT MERGE: CI-only check for the full SONiC E2E series#2573
ideaship wants to merge 13 commits into
mainfrom
ci/sonic-e2e-full-stack-check

Conversation

@ideaship

@ideaship ideaship commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Do not review or merge. This PR exists only to get a CI signal, and will be
closed once it has one.

The series in #2562 is stacked, so eight of its nine PRs are based on
sonic-e2e-v2-* branches rather than main — and Zuul does not enqueue those.
Only #2563, whose base is main, has been checked. That leaves the part of the
series most in need of a check unchecked, including the Zuul job definition the
series itself adds.

This branch is the same commit as the head of #2571, retargeted at main so the
whole series gets checked as one change. Nothing here is unique to this PR;
review happens in the stack.

What this is actually testing

A local make sonic-e2e run exercises the harness script directly, so it
validates the fixtures, seeding, generation and golden comparison — but it says
nothing about the CI wiring, which is exactly the part that has never run. This
PR covers the rest:

  • the python-osism-sonic-e2e job definition and its files matcher
  • playbooks/pre-sonic-e2e.yml, including the accept_ra=2 sysctl the
    IPv6-only node needs to keep its default route once forwarding is enabled
  • playbooks/test-sonic-e2e.yml and the pipenv/[ansible] install it performs
  • the required-projects pull of netbox-manager, which supplies the seeding
    CLI
  • whether the job fits its 2400s timeout on a real CI node rather than a
    developer laptop

Expected result

Green, with python-osism-sonic-e2e passing against the nine committed goldens.
The coverage report is not part of the job, so its exit status does not affect
this run.

ideaship added 13 commits August 5, 2026 16:45
The directory holding the per-HWSKU port_config .ini files was hardcoded
to /etc/sonic/port_config, which only exists inside the conductor image
-- the Containerfile copies files/sonic/port_config there. A generator
run from a checkout instead, as in local development, finds nothing at
that path, and planting the files under /etc/sonic to work around it
needs root.

Introduce a SONIC_PORT_CONFIG_PATH setting in osism.settings, following
the existing SONIC_* environment variable convention, and wire
constants.PORT_CONFIG_PATH to it. The default is the previous hardcoded
path, so container behaviour is identical; setting the variable points
the generator at files/sonic/port_config/ in the checkout instead.

Tests cover the default, the environment override, and the
settings-to-constants wiring.

Assisted-by: Claude:claude-sonnet-5
Signed-off-by: Roger Luethi <luethi@osism.tech>
Add the Python side of the SONiC config-generation E2E golden test:
tests/e2e/generate.py and tests/e2e/compare.py.

generate.py drives sync_sonic() against a live NetBox and asserts
success itself, because sync_sonic() returns only a device -> config
dict and swallows per-device failures internally (it logs and moves
on so one bad device does not abort the whole sync). To surface those
failures here, generate.py installs a loguru sink that fails the run
on any ERROR record, then exports the resulting config_db.json files
for comparison.

compare.py checks the exported files against tests/e2e/golden/: exact
file-set equality (nothing missing, nothing extra) plus a structural
diff of each file's JSON content, so a mismatch reports the offending
keys/paths rather than an opaque "files differ".

tests/unit/e2e/test_generate.py and test_compare.py cover both
modules without needing a live NetBox.

Assisted-by: Claude:claude-sonnet-5
Signed-off-by: Roger Luethi <luethi@osism.tech>
Regeneration is the only path by which coverage can silently drop:
in the normal comparison path an emptied table (or a disappearing
device) changes the golden file and fails the comparison, but
inside a several-hundred-line regenerated JSON diff either kind of
loss is invisible.

regenerate() now returns a list of entries describing coverage that
was lost. There are two shapes: a "<file>: <TABLE>" entry for a
table that was populated in the previous golden and became empty in
the export, and a "<file>: file removed, had N populated tables"
entry for a golden file whose device stopped being exported
altogether and is being removed by the existing stale-file cleanup.
The latter is the largest-granularity loss there is, and the most
likely one in this project: a SONiC device is only generated when
it is active, carries the managed-by-metalbox tag, and its role is
in DEFAULT_SONIC_ROLES, so a single fixture typo silently drops a
device's entire golden with no error. A stale golden that had no
populated tables to begin with is not a loss and is still removed
silently.

main() prints this report and exits non-zero when the list is
non-empty, unless the new --allow-coverage-loss flag is passed,
making an intentional removal explicit in the command someone ran.
Goldens are still written (or removed) either way; the guard only
changes whether the run is reported as a failure.

A count of lost tables/files was considered and rejected as the
wrong shape: losing one table while gaining another would leave a
count unchanged.

main() now passes args.allow_coverage_loss through to regenerate()
instead of dropping it on the floor, and the report header no longer
claims every loss was "populated and are now empty" -- a removed
golden file is also reported here and that wording did not fit it.
Unit tests cover main()'s --regenerate branch itself (report text and
exit code) both with and without --allow-coverage-loss; the existing
tests all exercised regenerate() directly and never went through the
CLI.

Assisted-by: Claude:claude-sonnet-5
Signed-off-by: Roger Luethi <luethi@osism.tech>
Add the infrastructure side of the SONiC config-generation E2E
golden test: tests/e2e/compose.yaml, tests/e2e/deploy_netbox.sh and
tests/e2e/sonic_golden_test.sh, plus the Makefile target and
.gitignore entry that drive them.

compose.yaml defines three services (NetBox, its Postgres database
and Redis) -- no Kubernetes, so there is no kind/kubectl dependency
and no cluster bring-up latency. NetBox is configured entirely
through the environment variables its own baked-in configuration.py
already reads; nothing is templated or mounted over it.
API_TOKEN_PEPPERS is deliberately left unset so that a plain v1 API
token can be minted for the test run instead of the peppered v2
format. NETBOX_PORT is the only variable interpolated into the
compose file, because compose re-interpolates the whole file on
every subcommand (up, ps, down, ...): keeping the rest of the
configuration in the container environment avoids re-resolving values
on each invocation.

deploy_netbox.sh brings the stack up, waits for the healthchecks and
mints/prints the API token. sonic_golden_test.sh is the harness
entrypoint: it provisions NetBox via deploy_netbox.sh, then seeds it
by installing netbox-manager into a dedicated venv (so its package
pins never mutate this project's venv) and running it against the
fixtures under tests/e2e/scenario/ -- netbox-manager's own bundled
example/ data is not used. Concurrent seeding is disabled by default
(SEED_PARALLEL=1) because seeding files in the same numeric group
take KEY SHARE locks on shared parent dcim_device rows and can
deadlock; the script documents the observed error and the condition
that still causes it (200-fabric.yml cabling both leaves to a shared
spine). Finally it runs sync_sonic() via generate.py and compares the
result against tests/e2e/golden/.

make sonic-e2e wires the above together; make sonic-e2e-regen adds
--regenerate to rewrite the goldens.

--regenerate refuses to run against a reused stack (CREATED_STACK==0)
unless ALLOW_WARM_REGEN=1 is set: applying the fixtures as an UPDATE
over whatever is already in a reused database can produce goldens a
fresh stack -- the only kind CI ever uses -- would not reproduce, and
nothing would otherwise say so. sonic_golden_test.sh also now accepts
--allow-coverage-loss and forwards it to tests.e2e.compare, so an
intentional fixture removal can get a green regen without editing the
script by hand.

Assisted-by: Claude:claude-sonnet-5
Signed-off-by: Roger Luethi <luethi@osism.tech>
The SONiC E2E golden test previously depended on osism/testbed's
example seed data, seeded through netbox-manager. Replace it with
frozen, in-repo synthetic NetBox fixtures under
tests/e2e/scenario/resources/ (100-base.yml, 150-context.yml,
200-fabric.yml, 250-oob.yml, 260-metalbox.yml) plus the minimal
edgecore-7726-32x-e2e device type, and commit the first four golden
files this scenario produces (e2e-spine-1, e2e-leaf-1, e2e-leaf-2,
e2e-oob-1). The goldens are now reproducible without any reference to
osism/testbed.

Fixture topology: one spine (e2e-spine-1) cabled to two leaves
(e2e-leaf-1, e2e-leaf-2) on numbered /31 point-to-point links inside a
prefix with the Transfer IPAM role, a standalone OOB switch
(e2e-oob-1), and a metalbox (e2e-metalbox-1) that is not itself a
SONiC device. e2e-leaf-1 also carries an access port (untagged VLAN
100) and a trunk port (tagged VLAN 200) plus a VLAN200 SVI, and
e2e-leaf-2 carries a table_id-only VRF (vrf99) on a data port.

Device filter contract, reverse-engineered from
osism/tasks/conductor/sonic: a device is generated only when all
three hold -- status=active, tagged managed-by-metalbox, and
role.slug is one of DEFAULT_SONIC_ROLES (spine/leaf/switch here). Any
one missing silently skips the device: no golden, no error.
e2e-metalbox-1 deliberately fails this filter (role metalbox, no
managed-by-metalbox tag) so it is never generated.

Each switch's ASN is derived from its Loopback0 address (4200 +
zero-padded 3rd/4th octet), so the fixture's IP addresses are
load-bearing, not free choices:

  e2e-spine-1  172.16.10.1/20   192.168.20.1/32   ASN 4200020001
  e2e-leaf-1   172.16.10.11/20  192.168.20.11/32  ASN 4200020011
  e2e-leaf-2   172.16.10.12/20  192.168.20.12/32  ASN 4200020012
  e2e-oob-1    172.16.10.21/20  192.168.20.21/32  ASN 4200020021

The metalbox unlocks DNS_NAMESERVER/NTP_SERVER by holding
172.16.10.254/20 -- an address inside the switches' shared /20 OOB
subnet (172.16.0.0/20) -- on a non-mgmt_only interface.
_get_metalbox_ip_for_device() matches purely by that subnet
membership and explicitly skips mgmt_only interfaces; it never
follows a cable, so no cabling to the metalbox is required.

Two generator behaviours were not obvious from the code and cost
iterations to find, so are called out in comments in 200-fabric.yml:
a newly created interface not present in the device type requires an
explicit `type`, and a point-to-point link's address must fall inside
a prefix with the Transfer IPAM role or the link is treated as
IP-unnumbered and BGP_NEIGHBOR/BGP_NEIGHBOR_AF stay empty despite
being cabled and addressed.

This covers 30 of the 38 config_db tables; ACL_TABLE/ACL_RULE and the
rest come from the base scaffold regardless of fixtures.
PORTCHANNEL*, the breakout paths and the EVPN/VXLAN/multi-VRF tables
are out of scope here and land with their own scenario files and
goldens in later PRs, which must not modify these five base files
since everything device-wide is intentionally concentrated here.

Assisted-by: Claude:claude-sonnet-5
Signed-off-by: Roger Luethi <luethi@osism.tech>
The "N of M config_db tables covered" claim made whenever the golden set
grows had only ever been established by an ad-hoc script run once during
development, so nobody could re-derive or check it afterwards.

Add tests/e2e/coverage.py, run by `make sonic-e2e-coverage`. It works in
two independent steps: derive the tables the generator can emit from
config["TABLE"]/cfg["TABLE"] assignments under
osism/tasks/conductor/sonic/ (excluding the generated schema package,
which is data rather than emission logic), then collect the tables that
are non-empty in at least one file under tests/e2e/golden/. Both sides
are derived on every run, so this needs no updating as scenarios are
added -- each one simply makes the reported number go up.

It is a reporting tool, not a gate: it is not wired into
sonic_golden_test.sh or the Zuul job, and the golden comparison stays
the only check that can fail a run. It does exit non-zero while any
emitted table has no golden, which against the base fixtures alone is
the honest answer -- 30 of 38, naming the eight tables the breakout,
port-channel and EVPN scenarios go on to cover.

It lands here, with the first goldens, rather than with the last
scenario, so the number is available and meaningful while the golden set
is still being built up.

Assisted-by: Claude:claude-opus-5
Signed-off-by: Roger Luethi <luethi@osism.tech>
The README explains how to run the unit and integration suites, but the
E2E golden test added in this series had no entry point outside the
Makefile and the harness script's own header comment, so there was
nothing pointing a newcomer at "make sonic-e2e".

Add a third section in the same shape as the existing two: what the test
does, the prerequisites beyond the development dependencies (docker with
the compose plugin, openssl, and a netbox-manager checkout found as a
sibling directory or via NETBOX_MANAGER_DIR), and the Makefile targets
for running, iterating against a reused stack, and regenerating.

The coverage report gets its own mention because it is the one part of
this suite nothing else surfaces: it is not wired into the harness or
the Zuul job, so `make sonic-e2e-coverage` is the only way anyone sees
which config_db tables the golden set reaches.

Two behaviours are called out because neither is guessable from the
error it produces. Regeneration refuses a stack left over from an
earlier run, since applying the fixtures over a populated database can
yield goldens that a fresh stack -- which CI always uses -- would not
reproduce. And seeding applies every file under
tests/e2e/scenario/resources/ regardless of git status, so a stray file
there joins the fixture set; the harness header records that this has
broken a run twice.

The remaining environment overrides are left to
tests/e2e/sonic_golden_test.sh, which already documents them, rather
than duplicated here where they would drift.

Assisted-by: Claude:claude-opus-5
Signed-off-by: Roger Luethi <luethi@osism.tech>
Add python-osism-sonic-e2e as a Zuul job, wired via
playbooks/pre-sonic-e2e.yml (pre-run) and
playbooks/test-sonic-e2e.yml (run).

The job uses nodeset: ubuntu-noble and timeout: 2400, since bringing
up the compose stack, installing netbox-manager into its own venv,
seeding NetBox and generating/comparing SONiC configs for every
supported HWSKU takes longer than the default job timeout. A files
matcher restricts when it runs in the check pipeline to changes that
can affect the generated output or the harness itself (settings,
conductor/sonic code, the E2E tests, the playbooks, Pipfile.lock,
files/sonic/, requirements.txt and requirements.ansible.txt (the
sonic_golden_test.sh harness installs the [ansible] extra that
setup.cfg maps to the latter), setup.cfg itself, and
.zuul.yaml/Makefile); it also runs unconditionally on periodic-daily.
netbox-manager is still pulled at tip-of-main via required-projects
because it remains the seeding tool, so a Depends-On is honored for
its code -- but its example/ seed data is no longer used, so this job
no longer detects drift in that data.

pre-sonic-e2e.yml retains the accept_ra=2 sysctl because the Zuul
node is IPv6-only and learns its default route via SLAAC; without
it, router advertisements are not accepted on interfaces where
forwarding is enabled and the node loses its route to the outside
network.

Assisted-by: Claude:claude-sonnet-5
Signed-off-by: Roger Luethi <luethi@osism.tech>
The bundled netbox-manager example models no breakout ports and
sets no explicit interface speeds, so the generator's breakout
paths and its kbps->Mbps speed normalisation were never exercised
by the SONiC E2E golden test.

Add two standalone leaf devices on the shared E2E rack (positions
6 and 7, taking no cabling and needing none):

- e2e-breakout-derived: Eth1/1/1..4 use the device type's
  100gbase-x-qsfp28 interface type with no explicit speed, so the
  sub-port speed is derived from the interface type. Also carries
  a tagged VLAN on the plain Eth1/5 port, covering the VLAN /
  tagged-VLAN-to-port paths.
- e2e-breakout-explicit: the same four sub-ports instead carry an
  explicit NetBox speed of 100000000 kbps, exercising the other
  unit the collection step must normalise.

Both must yield sub-port speed 100000 in the generated config;
confirmed via the regenerated goldens (BREAKOUT_CFG and
BREAKOUT_PORTS populated on both, no bare speed "100" present).
This brings config_db table coverage from 30 to 32 of 38.

The two devices reuse the site, location, tenant, roles, tags and
custom fields already seeded by 100-base.yml, and the
edgecore-9726-32d-e2e device type is ported from ab8da03a.

Related-Bug: #2478
Related-Bug: #2246
Assisted-by: Claude:claude-sonnet-5
Signed-off-by: Roger Luethi <luethi@osism.tech>
Give SONiC breakout detection an explicit, authoritative signal
instead of inferring breakout structure from incidental NetBox
artifacts. A device-level custom field sonic_parameters.breakout maps
a master port (NetBox Eth1/N or canonical EthernetN, normalized via
the hwsku port_config) to a mode string NxSpeedG.

When a declaration is present for a resolvable master it is
authoritative and fail-closed: the master is claimed into
suppressed_masters before validation, so the inference path is
suppressed for it even when the declared mode is invalid or two keys
collide. The mode is validated structurally against the port_config
lane count (L % N == 0); children and their exact per-child lane
slices are computed from the mode (fixing 2x*/8x* which the
count-based inference never handled), the physical port comes from
the port_config index (correct on mixed-lane platforms), and
config_generator uses the declared per-child speed and lanes ahead of
any NetBox-derived value.

Absent a breakout map, behaviour is unchanged by construction: the
declared pass no-ops, suppressed_masters stays empty, and the
inference branches (which only consult the set, never populate it)
and their existing dedup are untouched. Structural validation only;
the platform (platform.json) may still reject a structurally-valid
mode.

Adds unit coverage for the parsers, the resolver, mode emission
across 4x/2x/8x/4x100G, key normalization, mixed-layout port index,
collision and invalid/unresolvable/single-lane/malformed
declarations, and the declared-child downstream precedence.

Adds a third device, e2e-breakout-declared, to the SONiC E2E golden
test: an Accton-AS9726-32D leaf carrying an authoritative
sonic_parameters.breakout map with no sub-ports modelled in NetBox,
so the committed golden proves the declared-mode path end to end
rather than the inference fallback. It declares three splits on the
8-lane platform -- Ethernet0 4x100G, Ethernet8 2x50G, and physical
key Eth1/9 (Ethernet64) 8x50G -- exercising key normalization, the
2x/8x cases count-based inference never handled, and the
mixed-lane port_config index. This adds no new config_db table;
coverage stays at 32 of 38 and the golden is the assertion that the
new code path works. Regenerating on a fresh stack left the six
existing goldens byte-unchanged.

The declared path needs the same protection the detected paths now
have. A declared breakout names its children after the master's lane
offsets, so on Accton-AS7726-32X a 4x declaration on Ethernet124
would claim Ethernet125 and Ethernet126 -- two independent 10G SFP+
ports -- and silently rewrite their lanes, speed and alias, because a
breakout_ports entry is authoritative for a port's lanes and speed.
2x50G collides the same way, on Ethernet126 alone.

Reuse _breakout_child_collisions() and refuse before mutating
anything, leaving BREAKOUT_CFG unset so the master stays an ordinary
port and the declaration is dropped whole rather than half-applied.
Tests cover both directions against the real shipped .ini via the
existing real_port_config fixture.

Assisted-by: Claude:claude-sonnet-5
Signed-off-by: Roger Luethi <luethi@osism.tech>
Add a port-channel (LAG) device to the SONiC E2E synthetic
fixtures. Until now the base/breakout fixtures modelled no LAGs, so
PORTCHANNEL, PORTCHANNEL_INTERFACE and PORTCHANNEL_MEMBER were
emitted by the generator but always empty in every golden -- that
code path had no coverage.

e2e-portchannel (rack E2E, position 9, edgecore-7726-32x-e2e /
Accton-AS7726-32X, role leaf) reuses the site/location/tenant/tag
objects created by 100-base.yml. It bonds Ethernet0 and Ethernet4
into PortChannel1: a NetBox LAG interface (type: lag) with the two
data ports referencing it via their lag field, the same way real
deployments model a port-channel.

This brings cumulative non-empty config_db table coverage across
the golden set from 32 to 35 of the 38 tables the generator emits
(only the VXLAN EVPN/tunnel tables remain uncovered). Verified with
a full down/regen/verify cycle against a freshly started NetBox
stack, so the goldens reflect a from-scratch database rather than
an UPDATE over a reused one.

Assisted-by: Claude:claude-sonnet-5
Signed-off-by: Roger Luethi <luethi@osism.tech>
The base fixtures assign only one table_id-only VRF (vrf99 from
200-fabric.yml), so the EVPN/VXLAN subsystem was emitted-but-empty:
VXLAN_TUNNEL, VXLAN_TUNNEL_MAP and VXLAN_EVPN_NVO had no golden
coverage.

Add tests/e2e/scenario/resources/700-evpn.yml: a leaf device (e2e-evpn,
rack E2E position 10) with a Loopback0 whose primary IP becomes the
VXLAN tunnel source, and two VRFs assigned to data ports:

  VrfStorage  rd 2001 (a pure number) -> VNI 2001, populating
              VXLAN_TUNNEL, VXLAN_TUNNEL_MAP, VXLAN_EVPN_NVO, the
              L2VPN-EVPN BGP_GLOBALS_AF/BGP_GLOBALS_ROUTE_ADVERTISE,
              and the synthesized Vlan2001 with its VLAN_INTERFACE.
  vrf42       no rd -> the table_id-only branch (VRF["Vrf42"] =
              {vrf_table_id: 42}), no EVPN/VXLAN.

Together with vrf99, VRF coverage goes from one to three VRFs across the
golden set. This does not introduce the VRF table itself (already
covered), only deepens it.

Ported from the retired testbed-derived scenario, dropping its
vars/rack/vlan stanzas (now provided by 100-base.yml) and its
managed-by-osism tag reference (that tag does not exist in the base
fixtures), and adding an explicit device_interface type on each new
port.

This is the last scenario in the series, so it is where the golden set
reaches every config_db table the generator can emit: `make
sonic-e2e-coverage` now reports 38 of 38 across 9 golden devices, up
from 30 of 38 on the base fixtures alone.

Verified from a fresh NetBox stack: regen is additive only (no existing
golden changed) and `make sonic-e2e` matches the goldens byte for byte.

Only the SNMP secrets decryption path stays unit-tested, because it
needs a vault and Redis, while the SNMP_SERVER_* tables themselves are
covered via the placeholder path.

Assisted-by: Claude:claude-sonnet-5
Signed-off-by: Roger Luethi <luethi@osism.tech>
tests/e2e/coverage.py reports which config_db tables the golden set
reaches, but nothing consumes it: it is wired into neither
sonic_golden_test.sh nor the Zuul job, so the number is only seen by
someone who runs `make sonic-e2e-coverage` by hand. That is how the
"38 of 38" claim came to exist as an unverifiable one-off in the first
place, and leaving it human-run lets it decay the same way again.

Add a unit test asserting the set of emitted-but-uncovered tables is
empty. It closes the one coverage failure nothing else catches: a newly
emitted table arriving with no golden. Coverage lost in the other
direction -- a table that had a golden becoming empty -- already fails
the golden comparison, because the golden file itself changes, and the
regeneration path is covered separately by the coverage guard in
compare.py.

It belongs in the unit suite rather than the E2E job because it needs
neither NetBox nor a generated config, only the generator source and the
committed goldens. It therefore costs milliseconds and runs on every
change, where the E2E job runs on a file matcher and a 2400s budget.
This needs no .zuul.yaml change: coverage.py already exposes
emitted_tables() and covered_tables().

The assertion message names the missing tables and points at
`make sonic-e2e-regen`, because the cost of this gate is that adding a
generator table now obliges the same change to add golden coverage, and
that is a full regeneration cycle rather than a two-line edit. If a
table genuinely cannot be reached by any fixture, the message says to
exclude it here with a stated reason -- one visible exception, not a
silently growing allowlist.

This lands after the last scenario because it can only pass once the
golden set is complete; the report itself lands with the first goldens,
where it is still useful at 30 of 38.

Assisted-by: Claude:claude-opus-5
Signed-off-by: Roger Luethi <luethi@osism.tech>
@ideaship

ideaship commented Aug 5, 2026

Copy link
Copy Markdown
Contributor Author

Served its purpose — closing.

The full series passed as one change: all ten jobs green, with python-osism-sonic-e2e at 800s against its 2400s timeout. That covered what a local harness run cannot — the job definition and files matcher, both playbooks including the accept_ra=2 sysctl the IPv6-only node needs, the required-projects pull of netbox-manager, and all nine goldens.

For context on the timeout figure: the job this replaces consumed 80-92% of the same budget as a matter of course, which is why a node-local 301s apt stall was enough to fail it. At 800s that stall would still leave well over half the budget unused.

The stack in #2562 now self-checks as it drains — each PR gets a real check once the one below it merges and its base becomes main.

@ideaship ideaship closed this Aug 5, 2026
@ideaship
ideaship deleted the ci/sonic-e2e-full-stack-check branch August 5, 2026 16:08
@github-project-automation github-project-automation Bot moved this from New to Done in Human Board Aug 5, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

2 participants