chore(fabric): switch over the fabric from the generated provider to … (#231)

* chore(fabric): switch over the fabric from the generated provider to a community provider

* cleanup
This commit is contained in:
Antoine Lecompte
2026-06-29 16:03:50 -04:00
committed by GitHub
parent f75cc90cf9
commit 0c1b7f765c
75 changed files with 723 additions and 19982 deletions
+50 -5
View File
@@ -36,6 +36,13 @@ name: Infra (Terraform)
# set (both default off). A pure-Terraform change to either stack thus applies
# without reconverging the nodes — and the ceph overlay join is skipped with it.
#
# The fabric (switch) apply is gated the same way: on push it applies only when the
# fabric surface changed (tf/deployment/prod/htz-fsn1/fabric/**, the fabric shared
# modules, tf/providers/**, .mise/tasks/{fabric,infra}/**), and on workflow_dispatch
# only when the run_fabric toggle is set (default off). So an unrelated prod change
# (or a bare dispatch) no longer re-applies the switches; its overlay join is skipped
# too unless the mgmt converge needs it.
#
# Environment gates rekey to <partition>-<region> (one per stack's region):
# staging-austin, staging-global, prod-global, prod-htz-fsn1. Each matrix apply
# entry references its own gate, so an unprovisioned Environment hangs the apply.
@@ -80,6 +87,10 @@ on:
description: 'Run the mgmt Ansible converge after the fabric apply'
type: boolean
default: false
run_fabric:
description: 'Apply the fabric (switch) stack'
type: boolean
default: false
# Serialize: the OVH S3 backend has no state locking (single-operator model),
# so never let two infra runs apply concurrently.
@@ -106,6 +117,9 @@ jobs:
# change to the ceph/fabric stack applies without reconverging the nodes.
ansible_ceph: ${{ steps.filter.outputs.ansible_ceph }}
ansible_mgmt: ${{ steps.filter.outputs.ansible_mgmt }}
# Fabric-apply gate: the fabric (switch) stack applies only when its own
# surface changed — not on every prod change.
fabric: ${{ steps.filter.outputs.fabric }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
@@ -145,6 +159,20 @@ jobs:
- 'ansible/mgmt/**'
- 'tf/render/ansible-mgmt/**'
- '.mise/tasks/mgmt/**'
# ── Fabric-apply gate (stack-step level) ──
# The fabric stack + the shared modules/providers/tasks it consumes.
# A hit means the switch config (or its plumbing) changed → apply it.
fabric:
- 'tf/deployment/prod/htz-fsn1/fabric/**'
- 'tf/shared/modules/core-fabric/**'
- 'tf/shared/modules/cluster-fabric/**'
- 'tf/shared/modules/fabric-login/**'
- 'tf/shared/modules/fabric-addressing/**'
- 'tf/shared/modules/fabric-netbox/**'
- 'tf/shared/modules/identity/**'
- 'tf/providers/**'
- '.mise/tasks/fabric/**'
- '.mise/tasks/infra/**'
# ── Discover the stack matrix from the deployment tree ───────────────────────
discover:
@@ -270,8 +298,8 @@ jobs:
setup-key-ref: ${{ env.NB_CI_KEY_REF }}
hostname: gha-plan-${{ matrix.partition }}-${{ matrix.region }}-${{ matrix.stack }}-${{ github.run_id }}
# Prod fabric goes through the mise task (builds the junos-qfx/hetzner
# providers, renders the NETCONF key, -parallelism=1). Every other stack is a
# Prod fabric goes through the mise task (builds the hetzner provider, renders
# the NETCONF key; junos comes from the registry). Every other stack is a
# plain registry-provider terragrunt plan.
- name: Terragrunt plan (fabric)
if: matrix.stack == 'fabric'
@@ -341,12 +369,28 @@ jobs:
echo "mgmt=${mgmt:-false}" >> "$GITHUB_OUTPUT"
echo "Ansible converge gates → ceph=${ceph:-false} mgmt=${mgmt:-false}"
# Decide whether the fabric (switch) apply runs. Same model as the Ansible
# gates: on push, gate on the paths-filter (did the fabric surface change?);
# on manual dispatch, gate on the run_fabric toggle (default off).
- name: Resolve fabric-apply gate
id: fabric_gate
if: matrix.stack == 'fabric'
env:
DISPATCH: ${{ github.event_name == 'workflow_dispatch' }}
DISPATCH_FABRIC: ${{ inputs.run_fabric }}
CHANGED_FABRIC: ${{ needs.changes.outputs.fabric }}
run: |
set -euo pipefail
if [ "$DISPATCH" = "true" ]; then run=$DISPATCH_FABRIC; else run=$CHANGED_FABRIC; fi
echo "run=${run:-false}" >> "$GITHUB_OUTPUT"
echo "fabric apply gate → ${run:-false}"
# Node-touching stacks join the overlay: talos (provisions over the LAN),
# ceph (the Ansible convergence below), fabric (the switch vme + the mgmt
# converge below). NetBird stacks themselves are pure api.netbird.io. The
# key was minted by an earlier (lower-order) netbird apply in this same run.
- name: Resolve NetBird CI setup-key ref
if: matrix.stack == 'talos' || (matrix.stack == 'ceph' && steps.ansible_gate.outputs.ceph == 'true') || matrix.stack == 'fabric'
if: matrix.stack == 'talos' || (matrix.stack == 'ceph' && steps.ansible_gate.outputs.ceph == 'true') || (matrix.stack == 'fabric' && (steps.fabric_gate.outputs.run == 'true' || steps.ansible_gate.outputs.mgmt == 'true'))
run: |
set -euo pipefail
if [ "$PARTITION" = "prod" ]; then
@@ -356,15 +400,16 @@ jobs:
echo "NB_CI_KEY_REF=op://yucca_tf_staging/NETBIRD_YUCCA_STAGING_CI_SETUP_KEY/password" >> "$GITHUB_ENV"
fi
- name: Connect to NetBird
if: matrix.stack == 'talos' || (matrix.stack == 'ceph' && steps.ansible_gate.outputs.ceph == 'true') || matrix.stack == 'fabric'
if: matrix.stack == 'talos' || (matrix.stack == 'ceph' && steps.ansible_gate.outputs.ceph == 'true') || (matrix.stack == 'fabric' && (steps.fabric_gate.outputs.run == 'true' || steps.ansible_gate.outputs.mgmt == 'true'))
uses: ./.github/actions/netbird-connect
with:
setup-key-ref: ${{ env.NB_CI_KEY_REF }}
hostname: gha-apply-${{ matrix.partition }}-${{ matrix.region }}-${{ matrix.stack }}-${{ github.run_id }}
# Prod fabric: mise task escalates to the write SA + builds providers.
# Gated: only when the fabric surface changed (push) or run_fabric (dispatch).
- name: Terragrunt apply (fabric)
if: matrix.stack == 'fabric'
if: matrix.stack == 'fabric' && steps.fabric_gate.outputs.run == 'true'
run: mise run infra:apply -- --non-interactive -auto-approve
# Everything else: direct terragrunt apply with the partition's write SA.
- name: Terragrunt apply
+1 -1
View File
@@ -66,7 +66,7 @@ tf/.terraformrc.local
.mise/.provider-bin/
.mise/.provider-mirror/
.mise/.hetzner-provider-src/
# self-built junos-qfx (mirror) makes this lock platform-specific; regenerated by init
# self-built hetzner (mirror) makes this lock platform-specific; regenerated by init
tf/deployment/prod/htz-fsn1/fabric/.terraform.lock.hcl
# ansible/mgmt inventory is TF-generated at run time (tf/render/ansible-mgmt) —
-20
View File
@@ -1,20 +0,0 @@
#!/usr/bin/env bash
#MISE description="Build the vendored JTAF junos-qfx provider binary into the shared provider mirror"
set -euo pipefail
ROOT=$(git rev-parse --show-toplevel)
SRC="$ROOT/tf/providers/terraform-provider-junos-qfx"
MIRROR="${PROVIDER_MIRROR:-$ROOT/.mise/.provider-mirror}"
VER="${JUNOS_PROVIDER_VERSION:-0.0.1}"
GOOS=$(go env GOOS); GOARCH=$(go env GOARCH)
BIN="terraform-provider-junos-qfx_v${VER}"
# Build into the unpacked filesystem-mirror layout for both registry hosts
# (OpenTofu defaults to registry.opentofu.org; Terraform to registry.terraform.io).
# The shared terraformrc that wires this mirror up is written by `infra:providers`.
for HOST in registry.opentofu.org registry.terraform.io; do
DEST="$MIRROR/$HOST/hashicorp/junos-qfx/$VER/${GOOS}_${GOARCH}"
mkdir -p "$DEST"
( cd "$SRC" && go build -o "$DEST/$BIN" . )
done
echo "built junos-qfx v$VER (${GOOS}_${GOARCH}) -> $MIRROR"
-48
View File
@@ -1,48 +0,0 @@
#!/usr/bin/env bash
#MISE description="Regenerate the vendored JTAF junos-qfx provider from device YANG + live config"
# Run this only when adding new config hierarchies (the provider's resource
# coverage is driven by the device XML). Then commit tf/providers/ and run
# `mise run fabric:provider-build`.
set -euo pipefail
ROOT=$(git rev-parse --show-toplevel)
CACHE="${FABRIC_JTAF_CACHE:-$HOME/.cache/yucca-jtaf}"
YREL="${FABRIC_JUNOS_YANG_RELEASE:-24.4/24.4R2}"
KEY="${FABRIC_NETCONF_KEY:-$HOME/.ssh/yucca-junos-tf}"
SPINE_IP="${FABRIC_SPINE_IP:-10.40.5.115}"
LEAF_IP="${FABRIC_LEAF_IP:-10.40.5.125}"
mkdir -p "$CACHE"
# 1. JTAF tooling
[ -d "$CACHE/junos-terraform" ] || git clone --depth 1 https://github.com/Juniper/junos-terraform "$CACHE/junos-terraform"
cd "$CACHE/junos-terraform"
[ -d venv ] || python3 -m venv venv
./venv/bin/pip -q install -e . >/dev/null
# 2. YANG (sparse: common + junos-qfx for the target release)
if [ ! -d "$CACHE/yang/$YREL" ]; then
[ -d "$CACHE/yang/.git" ] || git clone --no-checkout --depth 1 --filter=blob:none https://github.com/Juniper/yang "$CACHE/yang"
( cd "$CACHE/yang" && git sparse-checkout init --cone \
&& git sparse-checkout set "$YREL/native/conf-and-rpcs/common" "$YREL/native/conf-and-rpcs/junos-qfx" \
&& git checkout )
fi
Y="$CACHE/yang/$YREL/native/conf-and-rpcs"
# 3. live device config XML (spine + leaf) via the dedicated terraform key
SSHOPTS="-i $KEY -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=$CACHE/known_hosts -o ConnectTimeout=15"
ssh $SSHOPTS terraform@"$SPINE_IP" "show configuration | display xml | no-more" > "$CACHE/spine.xml"
ssh $SSHOPTS terraform@"$LEAF_IP" "show configuration | display xml | no-more" > "$CACHE/leaf.xml"
# 4. generate provider from both device types' config
source venv/bin/activate
jtaf-yang2go -p "$Y/common/models" "$Y"/junos-qfx/conf/models/*.yang -x "$CACHE/spine.xml" "$CACHE/leaf.xml" -t qfx
# 5. vendor into the repo
rm -rf "$ROOT/tf/providers/terraform-provider-junos-qfx"
cp -R terraform-provider-junos-qfx "$ROOT/tf/providers/terraform-provider-junos-qfx"
rm -f "$ROOT/tf/providers/terraform-provider-junos-qfx/__init__.py"
echo "regenerated + vendored -> tf/providers/terraform-provider-junos-qfx"
echo "next: mise run fabric:provider-build"
# Re-apply local provider patches (overwritten by regeneration).
python3 "$ROOT/tf/providers/apply_patches.py"
echo "next: mise run fabric:provider-build"
+28 -3
View File
@@ -33,6 +33,31 @@ SITE="${SITE:-htz-fsn1}"
# With a service-account token in the env, drop OP_ACCOUNT — the onepassword
# provider rejects having both set ("service_account_token and account are set").
unset OP_ACCOUNT
# -parallelism=1: the JTAF junos-qfx provider isn't concurrency-safe — parallel
# ApplyResourceChange calls across the VCs crash the plugin ("Plugin did not respond").
OP_ENV_FILE=tf/.env.prod "$ROOT/tf/op-run.sh" terragrunt --working-dir "tf/deployment/prod/$SITE/fabric" apply -parallelism=1 "$@"
STACK_DIR="tf/deployment/prod/$SITE/fabric"
# jeremmfr/junos is concurrency-safe (per-resource CRUD); reads/refresh run in
# parallel and commits serialize on the per-device config lock (handled by the
# provider). The device NETCONF connection-limit is raised to 250.
OP_ENV_FILE=tf/.env.prod "$ROOT/tf/op-run.sh" terragrunt --working-dir "$STACK_DIR" apply -parallelism=4 "$@"
# Confirm the dangling `commit confirmed` jeremmfr leaves on the last commit per
# device: its per-resource "confirm" is `commit check`, which does NOT cancel the
# rollback on our Junos, so without this the last change auto-reverts. A plain
# `commit` confirms it. This runs ONLY after a successful apply (set -e): a
# mgmt-breaking apply errors above and skips this, so the dangling commit rolls
# back and restores management. Nothing pending → `commit` is a harmless no-op.
echo "==> confirming commit-confirmed on managed switches"
IPS=$(OP_ENV_FILE=tf/.env.prod "$ROOT/tf/op-run.sh" terragrunt --working-dir "$STACK_DIR" output -json switch_mgmt_ips | tr -d '[]" ' | tr ',' '\n')
KH=$(mktemp); trap 'rm -f "$KEYF" "$KH"' EXIT
for ip in $IPS; do
[ -n "$ip" ] || continue
ok=
for attempt in 1 2 3; do
if ssh -i "$KEYF" -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new \
-o UserKnownHostsFile="$KH" -o ConnectTimeout=15 -o NumberOfPasswordPrompts=0 \
-o BatchMode=yes "terraform@$ip" "configure; commit; exit" >/dev/null 2>&1; then
ok=1; echo " confirmed $ip"; break
fi
echo " confirm attempt $attempt failed on $ip; retrying..."; sleep 5
done
[ -n "$ok" ] || { echo "ERROR: could not confirm commit on $ip — its last change will roll back (commit_confirmed). Investigate." >&2; exit 1; }
done
+2 -2
View File
@@ -33,5 +33,5 @@ SITE="${SITE:-htz-fsn1}"
# With a service-account token in the env, drop OP_ACCOUNT — the onepassword
# provider rejects having both set ("service_account_token and account are set").
unset OP_ACCOUNT
# -parallelism=1: the JTAF junos-qfx provider isn't concurrency-safe (see apply).
OP_ENV_FILE=tf/.env.prod "$ROOT/tf/op-run.sh" terragrunt --working-dir "tf/deployment/prod/$SITE/fabric" plan -parallelism=1 "$@"
# jeremmfr/junos is concurrency-safe; the device NETCONF connection-limit is 250.
OP_ENV_FILE=tf/.env.prod "$ROOT/tf/op-run.sh" terragrunt --working-dir "tf/deployment/prod/$SITE/fabric" plan -parallelism=4 "$@"
+8 -9
View File
@@ -1,29 +1,28 @@
#!/usr/bin/env bash
#MISE description="Build all of the deployment stack's local providers (junos-qfx + hetzner) into a filesystem mirror + write its terraformrc"
#MISE description="Build the deployment stack's locally-built providers (hetzner) into a filesystem mirror + write its terraformrc"
set -euo pipefail
ROOT=$(git rev-parse --show-toplevel)
MIRROR="${PROVIDER_MIRROR:-$ROOT/.mise/.provider-mirror}"
# Per-provider component builders (each builds one binary into the mirror).
mise run fabric:provider-build # junos-qfx (switch fabric)
mise run mgmt:provider-build # hetzner (mgmt-host reprovisioning)
# hetzner (mgmt-host reprovisioning) is the only provider not on a public registry,
# so it's built locally into the mirror. junos (switch fabric) now comes from the
# registry (jeremmfr/junos); everything else is a normal registry provider.
mise run mgmt:provider-build # hetzner
# A single terraformrc points TF/tofu at the mirror for every locally-built
# provider, for both registry hosts (OpenTofu -> registry.opentofu.org,
# Terraform -> registry.terraform.io). Consumed via TF_CLI_CONFIG_FILE.
# Point TF/tofu at the mirror for the locally-built provider only, for both registry
# hosts (OpenTofu -> registry.opentofu.org, Terraform -> registry.terraform.io).
# Everything else resolves `direct` from the registry. Consumed via TF_CLI_CONFIG_FILE.
cat > "$ROOT/tf/.terraformrc.local" <<'EOF'
# Generated by 'mise run infra:providers' — do not edit.
provider_installation {
filesystem_mirror {
path = "__MIRROR__"
include = [
"registry.opentofu.org/hashicorp/junos-qfx", "registry.terraform.io/hashicorp/junos-qfx",
"registry.opentofu.org/zack/hetzner", "registry.terraform.io/zack/hetzner",
]
}
direct {
exclude = [
"registry.opentofu.org/hashicorp/junos-qfx", "registry.terraform.io/hashicorp/junos-qfx",
"registry.opentofu.org/zack/hetzner", "registry.terraform.io/zack/hetzner",
]
}
-10
View File
@@ -1,10 +0,0 @@
# Generated by 'mise run fabric:provider-build' — do not edit.
provider_installation {
filesystem_mirror {
path = "/Users/leca/src/yucca/.mise/.fabric-provider-mirror"
include = ["registry.opentofu.org/hashicorp/junos-qfx", "registry.terraform.io/hashicorp/junos-qfx"]
}
direct {
exclude = ["registry.opentofu.org/hashicorp/junos-qfx", "registry.terraform.io/hashicorp/junos-qfx"]
}
}
+19 -18
View File
@@ -1,4 +1,4 @@
# htz-fsn1 — production switch fabric (Junos via JTAF + NetBox)
# htz-fsn1 — production switch fabric (Junos via jeremmfr/junos + NetBox)
Manages the Falkenstein (site 40) switch fabric as code:
@@ -24,9 +24,10 @@ VLAN id == the network's third octet; gateway = `.1` (IRB on the leaf).
## Layout
- `modules/fabric-addressing` — IDs → CIDRs/VLANs/gateways (single source of truth).
- `modules/core-fabric` / `modules/cluster-fabric` — the spine / leaf config (one
JTAF `junos-qfx` resource each), generated from the live config and parameterized
on the addressing. **Secrets (`root-authentication`) are stripped.**
- `modules/core-fabric` / `modules/cluster-fabric` — the spine / leaf config as typed
`junos_*` resources (interfaces, vlans, bgp, firewall, policy, …), parameterized on
the addressing. `system services` + `root-authentication` + `vme` are deliberately
NOT managed, so no apply can break the management path.
- `modules/identity` — the central user + group registry (single source of truth
for who has access and what they're a member of). Edit it to add people. Members
of fabric-mapped groups (e.g. `fabric-admins` → super-user) are synthesized into
@@ -37,16 +38,15 @@ VLAN id == the network's third octet; gateway = `.1` (IRB on the leaf).
## The providers
This stack builds two providers locally (neither is on a registry) into a shared
filesystem mirror via `mise run infra:providers`:
- `junos-qfx` — **JTAF-generated and vendored** in `tf/providers/` (the switch fabric).
- `junos` (`jeremmfr/junos`) — the switch fabric: a typed, per-resource provider from
the registry (pinned in `versions.tf`). It drives each VC over NETCONF (port 830) as
the `terraform` user; commits are `commit confirmed` (auto-rollback) and
`infra:apply` confirms each switch after a successful apply.
- `hetzner` (`zack/hetzner`) — the Hetzner Robot API, for mgmt-host reprovisioning
(`mgmt.tf`); cloned + built (pinned tag) by `mise run mgmt:provider-build`.
(`mgmt.tf`); the one provider not on a registry, cloned + built (pinned tag) into a
filesystem mirror by `mise run mgmt:provider-build`.
- `mise run fabric:provider-gen` — regenerate the junos-qfx provider from device YANG
+ live config (only when adding new config hierarchies), then commit `tf/providers/`.
- `mise run infra:providers` — build both providers into the mirror and write
- `mise run infra:providers` — build the hetzner provider into the mirror and write
`tf/.terraformrc.local` (consumed via `TF_CLI_CONFIG_FILE`).
## Running
@@ -61,10 +61,11 @@ mise run infra:apply # ... apply
CI: `.github/workflows/infra.yml` — plan on PR, gated apply on merge behind the
site-scoped `prod-htz-fsn1` GitHub Environment (required reviewers).
## Adoption caveat (first run)
## Adopting existing config
The JTAF provider has **no `terraform import`** and pushes config with `action="merge"`
(additive). NetBox objects + the existing direct switch config already exist (manually
seeded), so the first `apply` *asserts* matching config (idempotent on the switches)
and **NetBox prefixes/VLANs must be `import`ed** first or they'll clash. Run a `plan`
and review before the first `apply`.
`jeremmfr/junos` supports `terraform import`. To bring config that already exists on a
switch under management (a new resource, or a new VC against pre-seeded config), add an
`import {}` block — id = the config name, e.g. `et-0/0/0`, `lo0.0`, `PROTECT-RE_-_inet`,
`<dest>_-_<ri>` (static route), `<ip>_-_<ri>_-_<group>` (bgp neighbor) — run `plan`,
review (**expect 0 destroys**), `apply`, then remove the block. NetBox prefixes/VLANs
likewise import before first apply or they clash. The whole fabric was adopted this way.
+10 -12
View File
@@ -12,7 +12,7 @@ module "identity" {
module "core" {
source = "../../../../shared/modules/core-fabric"
providers = { junos-qfx = junos-qfx.spine }
providers = { junos = junos.spine }
public_vlan_id = module.addr_cls1.public_vlan_id
private_vlan_id = module.addr_cls1.private_vlan_id
@@ -42,7 +42,7 @@ module "core" {
module "cluster_cls1" {
source = "../../../../shared/modules/cluster-fabric"
providers = { junos-qfx = junos-qfx.leaf_cls1 }
providers = { junos = junos.leaf_cls1 }
public_cidr = module.addr_cls1.public_cidr
private_cidr = module.addr_cls1.private_cidr
@@ -65,20 +65,18 @@ locals {
module "login_spine" {
source = "../../../../shared/modules/fabric-login"
providers = { junos-qfx = junos-qfx.spine }
providers = { junos = junos.spine }
resource_name = "login"
users = module.identity.fabric_login.users
classes = module.identity.fabric_login.classes
name_servers = local.fabric_name_servers
users = module.identity.fabric_login.users
classes = module.identity.fabric_login.classes
name_servers = local.fabric_name_servers
}
module "login_leaf_cls1" {
source = "../../../../shared/modules/fabric-login"
providers = { junos-qfx = junos-qfx.leaf_cls1 }
providers = { junos = junos.leaf_cls1 }
resource_name = "login"
users = module.identity.fabric_login.users
classes = module.identity.fabric_login.classes
name_servers = local.fabric_name_servers
users = module.identity.fabric_login.users
classes = module.identity.fabric_login.classes
name_servers = local.fabric_name_servers
}
+1 -1
View File
@@ -6,7 +6,7 @@
# addressing.tf — IP plan (fabric-addressing: site/cluster IDs -> CIDRs/VLANs)
# fabric.tf — switch config: spine core + cluster leaves + login
# netbox.tf — NetBox IPAM mirrored from the addressing
# providers.tf — per-VC junos-qfx providers (+ netbox)
# providers.tf — per-VC junos providers (+ netbox)
# versions.tf / variables.tf / terraform.auto.tfvars / terragrunt.hcl
#
# Add a cluster: a leaf provider in providers.tf, addr_clsN in addressing.tf,
@@ -0,0 +1,7 @@
# Mgmt IPs of every switch VC this stack manages. Consumed by `infra:apply` to
# confirm the dangling `commit confirmed` jeremmfr leaves on each device after a
# successful apply (extend with each cluster leaf as clusters are added).
output "switch_mgmt_ips" {
description = "Switch VC management IPs (spine + cluster leaves) to confirm post-apply."
value = [module.addr_site.spine_mgmt_ip, module.addr_cls1.leaf_mgmt_ip]
}
+28 -13
View File
@@ -1,22 +1,37 @@
# One junos-qfx provider instance per switch VC, host derived from the addressing
# One junos provider instance per switch VC, host derived from the addressing
# module (spine = site .115; each cluster leaf = .125 + (n-1)*10). Auth is the
# dedicated `terraform` NETCONF user with an SSH key (path from var; rendered from
# 1Password by the mise/CI runner — never committed).
provider "junos-qfx" {
alias = "spine"
host = module.addr_site.spine_mgmt_ip # 10.40.5.115
port = 830
username = "terraform"
sshkey = var.netconf_ssh_key_path
# commit_confirmed = N: every commit is a `commit confirmed N`, auto-rolled-back
# after N minutes unless the provider re-confirms. If an apply severs the NETCONF
# session (bad filter, interface, services change), the switch reverts itself —
# the management lifeline can't be permanently broken.
#
# wait_percent = 0: confirm IMMEDIATELY (no dead-wait between commit + confirm).
# Protection still holds — the confirm runs over a fresh connection, so a
# mgmt-breaking commit fails the confirm and rolls back. A non-zero wait only
# guards the rare "break manifests a few seconds late" case, and isn't worth the
# serialized dead-time (Junos won't accept a new commit while a confirmed one is
# pending, so the wait multiplies across every committed resource).
provider "junos" {
alias = "spine"
ip = module.addr_site.spine_mgmt_ip # 10.40.5.115
port = 830
username = "terraform"
sshkeyfile = var.netconf_ssh_key_path
commit_confirmed = 3
commit_confirmed_wait_percent = 0
}
provider "junos-qfx" {
alias = "leaf_cls1"
host = module.addr_cls1.leaf_mgmt_ip # 10.40.5.125
port = 830
username = "terraform"
sshkey = var.netconf_ssh_key_path
provider "junos" {
alias = "leaf_cls1"
ip = module.addr_cls1.leaf_mgmt_ip # 10.40.5.125
port = 830
username = "terraform"
sshkeyfile = var.netconf_ssh_key_path
commit_confirmed = 3
commit_confirmed_wait_percent = 0
}
provider "netbox" {
@@ -3,6 +3,7 @@ include "root" {
}
# State key derives from the path: yucca/prod/htz-fsn1/fabric/terraform.tfstate
# Providers come from versions.tf; the junos-qfx provider is supplied via
# dev_overrides (TF_CLI_CONFIG_FILE), set by the `fabric:*` mise tasks and CI.
# Providers come from versions.tf (junos = jeremmfr/junos from the registry); the
# hetzner provider is supplied via a filesystem mirror (TF_CLI_CONFIG_FILE), set
# by the `infra:*` mise tasks and CI.
# terraform.auto.tfvars is loaded automatically.
@@ -1,18 +1,18 @@
terraform {
required_version = "~> 1.11"
required_providers {
# JTAF-generated, vendored in tf/providers/terraform-provider-junos-qfx and
# supplied via dev_overrides (built by `infra:providers`; supplied via TF_CLI_CONFIG_FILE).
junos-qfx = {
source = "hashicorp/junos-qfx"
# Community Junos provider (typed per-resource CRUD) from the registry.
junos = {
source = "jeremmfr/junos"
version = "~> 2.19"
}
netbox = {
source = "e-breuninger/netbox"
version = "~> 4.0"
}
# Hetzner Robot (dedicated-server) API — mgmt-host reprovisioning (mgmt.tf).
# Built locally + supplied via the same filesystem_mirror as junos-qfx
# (mise `mgmt:provider-build`, invoked by `infra:providers`).
# The only non-registry provider: built locally + supplied via a filesystem
# mirror (mise `mgmt:provider-build`, invoked by `infra:providers`).
hetzner = {
source = "zack/hetzner"
}
-214
View File
@@ -1,214 +0,0 @@
#!/usr/bin/env python3
"""Re-apply local patches to the JTAF-generated junos-qfx provider.
JTAF regenerates the provider from device YANG + config (mise fabric:provider-gen),
overwriting our edits. This re-applies them; it's idempotent and run automatically
at the end of fabric:provider-gen.
Patches:
1. readStateFromDevice — trust the apply: return the reference (plan/prior state)
instead of reading the WHOLE device back and reconciling. The provider manages
only a slice; the device always carries config outside it (the built-in
`default` VLAN, em0/vme, the `system` block, ...), so a full read-back trips
Terraform's "provider produced inconsistent result". Trade-off: out-of-band
drift isn't detected; every apply re-asserts the merge.
2. publicKeyFile / NewClient — fail with a clear error on an unreadable/unparseable
SSH key instead of returning a nil AuthMethod, which made the SSH handshake
panic ("Plugin did not respond").
3. execute / netconfReplyError — surface NETCONF <rpc-error> (at any depth, incl.
nested under <commit-results>) as a real error. The generated client ignored
reply errors, so a rejected load/commit silently no-op'd while reporting success.
4. patch/CreateDiffPatch — emit nc:operation="merge" instead of "create" for
ADDED nodes, so applies are idempotent over pre-existing device config
(brownfield / empty-state diffs, e.g. after a state-key change re-pushes the
whole config). replace/delete unchanged. Consistent with the edit-config
default-operation=merge envelope and patch #1 (trust-the-apply).
"""
import sys
import pathlib
root = pathlib.Path(__file__).parent / "terraform-provider-junos-qfx"
errors = []
def patch_readstate():
src = root / "resource_config_provider.go"
t = src.read_text()
sig = ("func (r *configResource) readStateFromDevice(ctx context.Context, "
"reference ConfigResourceModel, diags *diag.Diagnostics) (ConfigResourceModel, bool) {")
if sig not in t:
errors.append("readStateFromDevice signature not found")
return
i = t.index(sig)
j = t.index("\n}\n", i)
body = (sig +
"\n\t// PATCHED (fabric): trust the apply — return the plan/prior state as the"
"\n\t// resource state instead of reading the whole device back (avoids"
"\n\t// \"provider produced inconsistent result\"). See apply_patches.py.\n"
"\treturn reference, true\n")
new = t[:i] + body + t[j + 1:]
if new != t:
src.write_text(new)
print(" patched readStateFromDevice")
else:
print(" readStateFromDevice already patched")
def replace_once(src, old, new, label):
t = src.read_text()
if new in t:
print(f" {label} already patched")
return
if old not in t:
errors.append(f"{label}: target not found")
return
src.write_text(t.replace(old, new, 1))
print(f" patched {label}")
NETCONF_REPLY_ERROR_FN = '''
// netconfReplyError returns a non-nil error if the rpc-reply contains any
// error-severity <rpc-error> at ANY depth. Junos nests them under
// <commit-results> (optionally per <routing-engine>) and
// <load-configuration-results>, not just directly under <rpc-reply>.
func netconfReplyError(raw []byte) error {
dec := xml.NewDecoder(bytes.NewReader(raw))
var msgs []string
for {
tok, err := dec.Token()
if err != nil {
break
}
se, ok := tok.(xml.StartElement)
if !ok || se.Name.Local != "rpc-error" {
continue
}
var e struct {
Severity string `xml:"error-severity"`
Message string `xml:"error-message"`
Path string `xml:"error-path"`
}
if dec.DecodeElement(&e, &se) != nil {
continue
}
if strings.EqualFold(strings.TrimSpace(e.Severity), "warning") {
continue
}
m := strings.TrimSpace(e.Message)
if p := strings.TrimSpace(e.Path); p != "" {
m = strings.TrimSpace(p) + ": " + m
}
if m != "" {
msgs = append(msgs, m)
}
}
if len(msgs) > 0 {
return fmt.Errorf("device rejected the change: %s", strings.Join(msgs, "; "))
}
return nil
}
'''
def patch_client():
src = root / "netconf" / "client.go"
# 1. SSH key: clear error instead of nil AuthMethod -> panic.
replace_once(
src,
"func publicKeyFile(file string) ssh.AuthMethod {\n"
"\tbuffer, err := os.ReadFile(file)\n"
"\tif err != nil {\n\t\treturn nil\n\t}\n\n"
"\tkey, err := ssh.ParsePrivateKey(buffer)\n"
"\tif err != nil {\n\t\treturn nil\n\t}\n"
"\treturn ssh.PublicKeys(key)\n}",
"func publicKeyFile(file string) (ssh.AuthMethod, error) {\n"
"\tbuffer, err := os.ReadFile(file)\n"
"\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"reading SSH key %q: %w\", file, err)\n\t}\n\n"
"\tkey, err := ssh.ParsePrivateKey(buffer)\n"
"\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"parsing SSH key %q (is it a valid private key?): %w\", file, err)\n\t}\n"
"\treturn ssh.PublicKeys(key), nil\n}",
"publicKeyFile",
)
replace_once(
src,
"\tif sshKey != \"\" {\n"
"\t\tauthMethod := publicKeyFile(sshKey)\n"
"\t\tcfg.Auth = []ssh.AuthMethod{authMethod}\n"
"\t} else {",
"\tif sshKey != \"\" {\n"
"\t\tauthMethod, err := publicKeyFile(sshKey)\n"
"\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n"
"\t\tcfg.Auth = []ssh.AuthMethod{authMethod}\n"
"\t} else {",
"NewClient",
)
# 2. Surface NETCONF rpc-error: bytes import, execute() check, helper fn.
replace_once(
src,
"import (\n\t\"context\"",
"import (\n\t\"bytes\"\n\t\"context\"",
"bytes import",
)
replace_once(
src,
"\tdebugRPC(\"rpc reply\", string(rawReply))\n\n\treply := struct {",
"\tdebugRPC(\"rpc reply\", string(rawReply))\n\n"
"\tif rerr := netconfReplyError(rawReply); rerr != nil {\n\t\treturn \"\", rerr\n\t}\n\n"
"\treply := struct {",
"execute rpc-error check",
)
t = src.read_text()
if "func netconfReplyError(" not in t:
src.write_text(t.rstrip() + "\n" + NETCONF_REPLY_ERROR_FN)
print(" appended netconfReplyError")
else:
print(" netconfReplyError already present")
def patch_diff_merge():
src = root / "patch" / "patch.go"
# Positional leaf-list adds (ordered Create + Replace-reorder's new value).
replace_once(
src,
'\t\t\t\tleaf := &Node{Tag: p.tag, Parent: p.parent, Operation: "create", Text: p.change.NewVal}',
'\t\t\t\tleaf := &Node{Tag: p.tag, Parent: p.parent, Operation: "merge", Text: p.change.NewVal}',
"diff positional create (leaf)",
)
replace_once(
src,
'\t\t\t\tcre := &Node{Tag: p.tag, Parent: p.parent, Operation: "create", Text: p.change.NewVal}',
'\t\t\t\tcre := &Node{Tag: p.tag, Parent: p.parent, Operation: "merge", Text: p.change.NewVal}',
"diff positional create (cre)",
)
# Regular leaf add.
replace_once(
src,
'\t\tcase Create:\n\t\t\tleaf.Operation = "create"\n\t\t\tleaf.Text = p.change.NewVal',
'\t\tcase Create:\n'
'\t\t\t// merge, not create: idempotent over pre-existing device config\n'
'\t\t\t// (brownfield/empty-state applies), consistent with default-operation=merge.\n'
'\t\t\tleaf.Operation = "merge"\n\t\t\tleaf.Text = p.change.NewVal',
"diff leaf create->merge",
)
# Keyed-list entry parent add.
replace_once(
src,
'\tswitch change.Op {\n\tcase Create:\n\t\tparent.Operation = "create"',
'\tswitch change.Op {\n\tcase Create:\n'
'\t\t// merge (see Create case above): idempotent over existing config.\n'
'\t\tparent.Operation = "merge"',
"diff keyed-entry create->merge",
)
patch_readstate()
patch_client()
patch_diff_merge()
if errors:
print("apply_patches: FAILED:\n - " + "\n - ".join(errors), file=sys.stderr)
sys.exit(1)
print("apply_patches: done")
@@ -1 +0,0 @@
terraform_provider
@@ -1,6 +0,0 @@
## README
The files in here are automatically copied to the new provider.
DO NOT FIDDLE WITH THEM!
@@ -1,41 +0,0 @@
// Copyright (c) 2017-2022, Juniper Networks Inc. All rights reserved.
//
// License: Apache 2.0
//
// THIS SOFTWARE IS PROVIDED BY Juniper Networks, Inc. ''AS IS'' AND ANY
// EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
// WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
// DISCLAIMED. IN NO EVENT SHALL Juniper Networks, Inc. BE LIABLE FOR ANY
// DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
// (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
// LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
// ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
// SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
//
package main
import (
"terraform-provider-junos-qfx/netconf"
)
// Config is the configuration structure used to instantiate the Netconf provider.
type Config struct {
Host string
Port int
Username string
Password string
SSHKey string
}
// Client returns a new client for the provider to use
func (c *Config) Client() (netconf.Client, error) {
return newClient(c)
}
func newClient(c *Config) (netconf.Client, error) {
client, err := netconf.NewClient(c.Username, c.Password, c.SSHKey, c.Host, c.Port)
return client, err
}
@@ -1,187 +0,0 @@
package main
import (
"testing"
)
// TestConfigClientWithPassword tests creating a client with password authentication
func TestConfigClientWithPassword(t *testing.T) {
config := &Config{
Host: "localhost",
Port: 830,
Username: "testuser",
Password: "testpass",
SSHKey: "",
}
client, err := config.Client()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if client == nil {
t.Error("expected non-nil client, got nil")
}
}
// TestConfigClientWithSSHKey tests creating a client with SSH key authentication
func TestConfigClientWithSSHKey(t *testing.T) {
config := &Config{
Host: "localhost",
Port: 830,
Username: "testuser",
Password: "",
SSHKey: "/path/to/key",
}
client, err := config.Client()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if client == nil {
t.Error("expected non-nil client, got nil")
}
}
// TestConfigClientWithEmptyHost tests client creation with empty host
func TestConfigClientWithEmptyHost(t *testing.T) {
config := &Config{
Host: "",
Port: 830,
Username: "testuser",
Password: "testpass",
SSHKey: "",
}
client, err := config.Client()
if err != nil {
// Expected error when host is empty
t.Logf("got expected error: %v", err)
}
if client != nil {
// Client creation may still succeed, so we just validate
t.Logf("client created with empty host: %v", client)
}
}
// TestConfigClientWithValidParams tests client creation with all valid parameters
func TestConfigClientWithValidParams(t *testing.T) {
testCases := []struct {
name string
config *Config
wantErr bool
desc string
}{
{
name: "valid with password",
config: &Config{
Host: "192.168.1.1",
Port: 830,
Username: "admin",
Password: "admin123",
SSHKey: "",
},
wantErr: false,
desc: "Should create client with password authentication",
},
{
name: "valid with ssh key",
config: &Config{
Host: "192.168.1.1",
Port: 830,
Username: "admin",
Password: "",
SSHKey: "/home/user/.ssh/id_rsa",
},
wantErr: false,
desc: "Should create client with SSH key authentication",
},
{
name: "custom port",
config: &Config{
Host: "10.0.0.1",
Port: 2222,
Username: "operator",
Password: "pass",
SSHKey: "",
},
wantErr: false,
desc: "Should create client with custom SSH port",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
client, err := tc.config.Client()
if (err != nil) != tc.wantErr {
t.Errorf("unexpected error state: %v (expected error: %v)", err, tc.wantErr)
}
if !tc.wantErr && client == nil {
t.Error("expected non-nil client")
}
t.Logf("%s", tc.desc)
})
}
}
// TestNewClientDirectly tests the newClient helper function
func TestNewClientDirectly(t *testing.T) {
config := &Config{
Host: "localhost",
Port: 830,
Username: "user",
Password: "pass",
SSHKey: "",
}
client, err := newClient(config)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if client == nil {
t.Error("expected non-nil client")
}
}
// TestConfigValues tests that config values are properly set
func TestConfigValues(t *testing.T) {
expectedHost := "example.com"
expectedPort := 2222
expectedUsername := "admin"
expectedPassword := "secret"
expectedSSHKey := "/path/to/key"
config := &Config{
Host: expectedHost,
Port: expectedPort,
Username: expectedUsername,
Password: expectedPassword,
SSHKey: expectedSSHKey,
}
if config.Host != expectedHost {
t.Errorf("host mismatch: expected %s, got %s", expectedHost, config.Host)
}
if config.Port != expectedPort {
t.Errorf("port mismatch: expected %d, got %d", expectedPort, config.Port)
}
if config.Username != expectedUsername {
t.Errorf("username mismatch: expected %s, got %s", expectedUsername, config.Username)
}
if config.Password != expectedPassword {
t.Errorf("password mismatch: expected %s, got %s", expectedPassword, config.Password)
}
if config.SSHKey != expectedSSHKey {
t.Errorf("ssh key mismatch: expected %s, got %s", expectedSSHKey, config.SSHKey)
}
}
@@ -1,35 +0,0 @@
module terraform-provider-junos-qfx
go 1.25.6
require (
github.com/hashicorp/terraform-plugin-framework v1.18.0
github.com/hashicorp/terraform-plugin-go v0.30.0
golang.org/x/crypto v0.48.0
nemith.io/netconf v0.0.4
)
require (
github.com/fatih/color v1.18.0 // indirect
github.com/golang/protobuf v1.5.4 // indirect
github.com/hashicorp/go-hclog v1.6.3 // indirect
github.com/hashicorp/go-plugin v1.7.0 // indirect
github.com/hashicorp/go-uuid v1.0.3 // indirect
github.com/hashicorp/terraform-plugin-log v0.10.0 // indirect
github.com/hashicorp/terraform-registry-address v0.4.0 // indirect
github.com/hashicorp/terraform-svchost v0.2.0 // indirect
github.com/hashicorp/yamux v0.1.2 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mitchellh/go-testing-interface v1.14.1 // indirect
github.com/oklog/run v1.2.0 // indirect
github.com/stretchr/testify v1.11.1 // indirect
github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
golang.org/x/net v0.51.0 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/text v0.34.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect
google.golang.org/grpc v1.81.1 // indirect
google.golang.org/protobuf v1.36.11 // indirect
)
@@ -1,119 +0,0 @@
github.com/bufbuild/protocompile v0.14.1 h1:iA73zAf/fyljNjQKwYzUHD6AD4R8KMasmwa/FBatYVw=
github.com/bufbuild/protocompile v0.14.1/go.mod h1:ppVdAIhbr2H8asPk6k4pY7t9zB1OU5DoEw9xY/FUi1c=
github.com/carlmjohnson/be v0.25.2 h1:EPTT7qCF5xJjcgrV5yX/muP5HTqSJR2VOjO6O4l9cYE=
github.com/carlmjohnson/be v0.25.2/go.mod h1:2P+bH/INocW7e411OYCCIwT3nnJneZyveVav0WBBM1U=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk=
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k=
github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M=
github.com/hashicorp/go-plugin v1.7.0 h1:YghfQH/0QmPNc/AZMTFE3ac8fipZyZECHdDPshfk+mA=
github.com/hashicorp/go-plugin v1.7.0/go.mod h1:BExt6KEaIYx804z8k4gRzRLEvxKVb+kn0NMcihqOqb8=
github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/terraform-plugin-framework v1.18.0 h1:Xy6OfqSTZfAAKXSlJ810lYvuQvYkOpSUoNMQ9l2L1RA=
github.com/hashicorp/terraform-plugin-framework v1.18.0/go.mod h1:eeFIf68PME+kenJeqSrIcpHhYQK0TOyv7ocKdN4Z35E=
github.com/hashicorp/terraform-plugin-framework v1.19.0 h1:q0bwyhxAOR3vfdgbk9iplv3MlTv/dhBHTXjQOtQDoBA=
github.com/hashicorp/terraform-plugin-framework v1.19.0/go.mod h1:YRXOBu0jvs7xp4AThBbX4mAzYaMJ1JgtFH//oGKxwLc=
github.com/hashicorp/terraform-plugin-go v0.30.0 h1:VmEiD0n/ewxbvV5VI/bYwNtlSEAXtHaZlSnyUUuQK6k=
github.com/hashicorp/terraform-plugin-go v0.30.0/go.mod h1:8d523ORAW8OHgA9e8JKg0ezL3XUO84H0A25o4NY/jRo=
github.com/hashicorp/terraform-plugin-go v0.31.0 h1:0Fz2r9DQ+kNNl6bx8HRxFd1TfMKUvnrOtvJPmp3Z0q8=
github.com/hashicorp/terraform-plugin-go v0.31.0/go.mod h1:A88bDhd/cW7FnwqxQRz3slT+QY6yzbHKc6AOTtmdeS8=
github.com/hashicorp/terraform-plugin-log v0.10.0 h1:eu2kW6/QBVdN4P3Ju2WiB2W3ObjkAsyfBsL3Wh1fj3g=
github.com/hashicorp/terraform-plugin-log v0.10.0/go.mod h1:/9RR5Cv2aAbrqcTSdNmY1NRHP4E3ekrXRGjqORpXyB0=
github.com/hashicorp/terraform-registry-address v0.4.0 h1:S1yCGomj30Sao4l5BMPjTGZmCNzuv7/GDTDX99E9gTk=
github.com/hashicorp/terraform-registry-address v0.4.0/go.mod h1:LRS1Ay0+mAiRkUyltGT+UHWkIqTFvigGn/LbMshfflE=
github.com/hashicorp/terraform-svchost v0.2.0 h1:wVc2vMiodOHvNZcQw/3y9af1XSomgjGSv+rv3BMCk7I=
github.com/hashicorp/terraform-svchost v0.2.0/go.mod h1:/98rrS2yZsbppi4VGVCjwYmh8dqsKzISqK7Hli+0rcQ=
github.com/hashicorp/yamux v0.1.2 h1:XtB8kyFOyHXYVFnwT5C3+Bdo8gArse7j2AQ0DA0Uey8=
github.com/hashicorp/yamux v0.1.2/go.mod h1:C+zze2n6e/7wshOZep2A70/aQU6QBRWJO/G6FT1wIns=
github.com/jhump/protoreflect v1.17.0 h1:qOEr613fac2lOuTgWN4tPAtLL7fUSbuJL5X5XumQh94=
github.com/jhump/protoreflect v1.17.0/go.mod h1:h9+vUUL38jiBzck8ck+6G/aeMX8Z4QUY/NiJPwPNi+8=
github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mitchellh/go-testing-interface v1.14.1 h1:jrgshOhYAUVNMAJiKbEu7EqAwgJJ2JqpQmpLJOu07cU=
github.com/mitchellh/go-testing-interface v1.14.1/go.mod h1:gfgS7OtZj6MA4U1UrDRp04twqAjfvlZyCfX3sDjEym8=
github.com/oklog/run v1.2.0 h1:O8x3yXwah4A73hJdlrwo/2X6J62gE5qTMusH0dvz60E=
github.com/oklog/run v1.2.0/go.mod h1:mgDbKRSwPhJfesJ4PntqFUbKQRZ50NgmZTSPlFA0YFk=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8=
github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok=
github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g=
github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ=
google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
nemith.io/netconf v0.0.4 h1:v1i05GAypUTYRrA1gwt6bZCWhDeDkB7sL7BO8JwkMWY=
nemith.io/netconf v0.0.4/go.mod h1:VisEiVJJ+W4NgTZ4QPKJb70RttJN2Ky6vvxzs8X5Dpg=
@@ -1,57 +0,0 @@
// Copyright (c) 2017-2022, Juniper Networks Inc. All rights reserved.
//
// License: Apache 2.0
//
// THIS SOFTWARE IS PROVIDED BY Juniper Networks, Inc. ''AS IS'' AND ANY
// EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
// WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
// DISCLAIMED. IN NO EVENT SHALL Juniper Networks, Inc. BE LIABLE FOR ANY
// DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
// (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
// LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
// ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
// SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
//
package main
import (
"context"
"flag"
"log"
"github.com/hashicorp/terraform-plugin-framework/providerserver"
)
var (
parseFlags = func(debug *bool) {
flag.BoolVar(debug, "debug", false, "set to true to run the provider with support for debuggers like delve")
flag.Parse()
}
serveProvider = providerserver.Serve
fatalLogger = func(v ...interface{}) {
log.Fatal(v...)
}
)
// run parses runtime flags and starts the provider server.
func run() error {
var debug bool
parseFlags(&debug)
ctx := context.Background()
opts := providerserver.ServeOpts{
Address: "tf-registry.click/juniper/jtaf670ffa332c26b46b",
Debug: debug,
}
return serveProvider(ctx, newProvider, opts)
}
// main executes the provider process and exits on startup errors.
func main() {
if err := run(); err != nil {
fatalLogger(err)
}
}
@@ -1,78 +0,0 @@
package main
import (
"context"
"errors"
"testing"
"github.com/hashicorp/terraform-plugin-framework/provider"
"github.com/hashicorp/terraform-plugin-framework/providerserver"
)
// TestRunPassesDebugToServe verifies run forwards parsed debug state to Serve.
func TestRunPassesDebugToServe(t *testing.T) {
originalParseFlags := parseFlags
originalServeProvider := serveProvider
t.Cleanup(func() {
parseFlags = originalParseFlags
serveProvider = originalServeProvider
})
parseFlags = func(debug *bool) {
*debug = true
}
called := false
serveProvider = func(_ context.Context, providerFunc func() provider.Provider, opts providerserver.ServeOpts) error {
called = true
if !opts.Debug {
t.Fatalf("expected debug=true in serve options")
}
if opts.Address == "" {
t.Fatalf("expected non-empty provider address")
}
if providerFunc() == nil {
t.Fatalf("expected provider constructor to return non-nil provider")
}
return nil
}
if err := run(); err != nil {
t.Fatalf("run() returned unexpected error: %v", err)
}
if !called {
t.Fatalf("expected serveProvider to be called")
}
}
// TestMainLogsFatalOnRunError verifies main logs fatally when startup fails.
func TestMainLogsFatalOnRunError(t *testing.T) {
originalParseFlags := parseFlags
originalServeProvider := serveProvider
originalFatalLogger := fatalLogger
t.Cleanup(func() {
parseFlags = originalParseFlags
serveProvider = originalServeProvider
fatalLogger = originalFatalLogger
})
parseFlags = func(_ *bool) {}
serveErr := errors.New("serve failed")
serveProvider = func(_ context.Context, _ func() provider.Provider, _ providerserver.ServeOpts) error {
return serveErr
}
called := false
fatalLogger = func(v ...interface{}) {
called = true
if len(v) != 1 || v[0] != serveErr {
t.Fatalf("fatalLogger called with unexpected args: %#v", v)
}
}
main()
if !called {
t.Fatalf("expected fatalLogger to be called")
}
}
@@ -1,11 +0,0 @@
Copyright © 2013-2018 Juniper Networks, Inc. All rights reserved.
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
(1) Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
(2) Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS “AS IS” AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
The views and conclusions contained in the software and documentation are those of the authors and should not be interpreted as representing official policies, either expressed or implied, of Juniper Networks.
@@ -1,538 +0,0 @@
package netconf
import (
"bytes"
"context"
"encoding/xml"
"fmt"
"io"
"os"
"sort"
"strings"
"sync"
"golang.org/x/crypto/ssh"
netconf "nemith.io/netconf"
netconfssh "nemith.io/netconf/transport/ssh"
)
const groupStrXML = `<load-configuration action="merge" format="xml">
%s
</load-configuration>
`
const deleteStr = `<edit-config>
<target>
<candidate/>
</target>
<default-operation>none</default-operation>
<config>
<configuration>
<groups operation="delete">
<name>%s</name>
</groups>
<apply-groups operation="delete">%s</apply-groups>
</configuration>
</config>
</edit-config>`
const commitStr = `<commit/>`
const getGroupXMLStr = `<get-configuration>
<configuration>
<groups><name>%s</name></groups>
</configuration>
</get-configuration>
`
const getConfigXMLStr = `<get-configuration>
<configuration>
</configuration>
</get-configuration>
`
const applyGroupXML = `<load-configuration action="merge" format="xml">
%s
</load-configuration>
`
const discardChanges = `<discard-changes/>`
const patchEditConfigStr = `<edit-config>
<target><candidate/></target>
<default-operation>merge</default-operation>
<config xmlns:nc="urn:ietf:params:xml:ns:netconf:base:1.0">
%s
</config>
</edit-config>`
// defaultPort is the NETCONF-over-SSH default.
const defaultPort = 830
type configuration struct {
ApplyGroup []string `xml:"apply-groups"`
}
func debugRPC(label string, payload string) {
if os.Getenv("JUNOS_TF_DEBUG_RPC") == "" {
return
}
fmt.Printf("\n=== %s ===\n%s\n", label, payload)
}
func marshalRPCRequest(operation string, messageID string) (string, error) {
rpc := netconf.NewRPC([]byte(operation))
if messageID != "" {
rpc.MessageID = messageID
}
rpcXML, err := xml.Marshal(rpc)
if err != nil {
return "", err
}
return string(rpcXML), nil
}
func isMissingDeleteError(err error) bool {
if err == nil {
return false
}
errText := strings.ToLower(err.Error())
return strings.Contains(errText, "data-missing") && strings.Contains(errText, "statement not found")
}
type operationExecutor func(ctx context.Context, operation string) (string, error)
// GoNCClient implements the provider-facing NETCONF client API on top of nemith/netconf.
type GoNCClient struct {
host string
port int
sshConfig *ssh.ClientConfig
Lock sync.RWMutex
exec operationExecutor
}
// Close keeps existing behavior contract for provider lifecycle hooks.
func (g *GoNCClient) Close() error {
return nil
}
// execute sends a single NETCONF RPC and returns its inner XML payload.
func (g *GoNCClient) execute(ctx context.Context, operation string) (string, error) {
if g.exec != nil {
return g.exec(ctx, operation)
}
target := fmt.Sprintf("%s:%d", g.host, g.port)
transport, err := netconfssh.Dial(ctx, "tcp", target, g.sshConfig)
if err != nil {
return "", err
}
session, err := netconf.NewSession(transport)
if err != nil {
_ = transport.Close()
return "", err
}
defer func() {
_ = session.Close(context.Background())
}()
rpc := session.Prepare(netconf.NewRPC([]byte(operation)))
rpcXML, err := xml.Marshal(rpc)
if err != nil {
return "", fmt.Errorf("failed to marshal rpc request: %w", err)
}
debugRPC("rpc request", string(rpcXML))
msg, err := session.Do(ctx, rpc)
if err != nil {
return "", err
}
defer func() {
_ = msg.Close()
}()
rawReply, err := io.ReadAll(msg)
if err != nil {
return "", fmt.Errorf("failed to read rpc-reply: %w", err)
}
debugRPC("rpc reply", string(rawReply))
if rerr := netconfReplyError(rawReply); rerr != nil {
return "", rerr
}
reply := struct {
XMLName xml.Name `xml:"rpc-reply"`
Data string `xml:",innerxml"`
}{}
if err := xml.Unmarshal(rawReply, &reply); err != nil {
return "", fmt.Errorf("failed to decode rpc-reply: %w", err)
}
return reply.Data, nil
}
// updateRawConfig replaces an existing apply-group payload and optionally commits.
func (g *GoNCClient) updateRawConfig(applyGroup string, netconfCall string, commit bool) (string, error) {
g.Lock.Lock()
defer g.Lock.Unlock()
ctx := context.Background()
deleteString := fmt.Sprintf(deleteStr, applyGroup, applyGroup)
if _, err := g.execute(ctx, deleteString); err != nil {
if !isMissingDeleteError(err) {
return "", err
}
}
nameStart := strings.Index(netconfCall, "<name>")
nameEnd := strings.Index(netconfCall, "</name>")
if nameStart == -1 || nameEnd == -1 {
return "", fmt.Errorf("failed to extract the group name from the netconfcall")
}
groupName := netconfCall[nameStart+6 : nameEnd]
addToApplyGroupsList(groupName)
groupString := fmt.Sprintf(groupStrXML, netconfCall)
reply, err := g.execute(ctx, groupString)
if err != nil {
return "", err
}
if commit {
if _, err := g.execute(ctx, commitStr); err != nil {
return "", err
}
}
return reply, nil
}
// DeleteConfig deletes the target apply-group and optionally commits.
func (g *GoNCClient) DeleteConfig(applyGroup string, commit bool) (string, error) {
g.Lock.Lock()
defer g.Lock.Unlock()
ctx := context.Background()
deleteString := fmt.Sprintf(deleteStr, applyGroup, applyGroup)
reply, err := g.execute(ctx, deleteString)
if err != nil {
if !isMissingDeleteError(err) {
return "", err
}
reply = "<ok/>"
}
if commit {
if _, err := g.execute(ctx, commitStr); err != nil {
return "", err
}
}
return strings.ReplaceAll(reply, "\n", ""), nil
}
// SendCommit emits apply-groups in deterministic order and commits candidate config.
func (g *GoNCClient) SendCommit() error {
g.Lock.Lock()
defer g.Lock.Unlock()
hasApplyGroups := false
applyGroupsMutex.Lock()
for _, group := range applyGroupsList {
if group != "" {
hasApplyGroups = true
break
}
}
applyGroupsMutex.Unlock()
if hasApplyGroups {
sortApplyGroupsList()
if err := g.sendApplyGroupsLocked(context.Background()); err != nil {
return err
}
}
if _, err := g.execute(context.Background(), commitStr); err != nil {
_, _ = g.execute(context.Background(), discardChanges)
return err
}
return nil
}
// sendApplyGroupsLocked emits the current apply-groups list as load-configuration XML.
func (g *GoNCClient) sendApplyGroupsLocked(ctx context.Context) error {
applyGroupsMutex.Lock()
applyGroupsCopy := make([]string, len(applyGroupsList))
copy(applyGroupsCopy, applyGroupsList)
applyGroupsMutex.Unlock()
var applyG configuration
applyG.ApplyGroup = applyGroupsCopy
cfg, err := xml.Marshal(applyG)
if err != nil {
return err
}
_, err = g.execute(ctx, fmt.Sprintf(applyGroupXML, string(cfg)))
return err
}
// MarshalGroup fetches a group and unmarshals XML into obj.
func (g *GoNCClient) MarshalGroup(id string, obj interface{}) error {
reply, err := g.readRawGroup(id)
if err != nil {
return err
}
if err = xml.Unmarshal([]byte(reply), &obj); err != nil {
return err
}
return nil
}
// MarshalConfig fetches the full configuration and unmarshals XML into obj.
func (g *GoNCClient) MarshalConfig(obj interface{}) error {
reply, err := g.readRawConfig()
if err != nil {
return err
}
if err = xml.Unmarshal([]byte(reply), &obj); err != nil {
return err
}
return nil
}
var applyGroupsList []string
var applyGroupsMutex sync.Mutex
// SendTransaction updates or creates a config payload and optionally commits it.
func (g *GoNCClient) SendTransaction(id string, obj interface{}, commit bool) error {
cfg, err := xml.Marshal(obj)
if err != nil {
return err
}
if id != "" {
if _, err = g.updateRawConfig(id, string(cfg), commit); err != nil {
return err
}
return nil
}
if _, err = g.sendRawConfig(string(cfg), commit); err != nil {
return err
}
return nil
}
// SendDirectTransaction loads raw XML config directly without apply-groups wrapping.
func (g *GoNCClient) SendDirectTransaction(obj interface{}, commit bool) error {
cfg, err := xml.Marshal(obj)
if err != nil {
return err
}
if _, err = g.sendDirectRawConfig(string(cfg), commit); err != nil {
return err
}
return nil
}
// addToApplyGroupsList records a group ID for deferred apply-groups emission.
func addToApplyGroupsList(id string) {
applyGroupsMutex.Lock()
defer applyGroupsMutex.Unlock()
applyGroupsList = append(applyGroupsList, id)
}
// sortApplyGroupsList removes empty values and keeps group ordering deterministic.
func sortApplyGroupsList() {
applyGroupsMutex.Lock()
defer applyGroupsMutex.Unlock()
filteredGroups := make([]string, 0, len(applyGroupsList))
for _, group := range applyGroupsList {
if group != "" {
filteredGroups = append(filteredGroups, group)
}
}
sort.Strings(filteredGroups)
applyGroupsList = filteredGroups
}
// SendUpdate applies a prepared XML diff payload and optionally commits it.
func (g *GoNCClient) SendUpdate(id string, diff string, commit bool) error {
g.Lock.Lock()
defer g.Lock.Unlock()
_ = id
patchPayload := fmt.Sprintf(patchEditConfigStr, diff)
if _, err := g.execute(context.Background(), patchPayload); err != nil {
return err
}
if commit {
if _, err := g.execute(context.Background(), commitStr); err != nil {
return err
}
}
return nil
}
// sendRawConfig loads raw XML config and optionally commits it.
func (g *GoNCClient) sendRawConfig(netconfCall string, commit bool) (string, error) {
g.Lock.Lock()
defer g.Lock.Unlock()
nameStart := strings.Index(netconfCall, "<name>")
nameEnd := strings.Index(netconfCall, "</name>")
if nameStart == -1 || nameEnd == -1 {
return "", fmt.Errorf("failed to extract the group name from the netconfCall")
}
groupName := netconfCall[nameStart+6 : nameEnd]
addToApplyGroupsList(groupName)
reply, err := g.execute(context.Background(), fmt.Sprintf(groupStrXML, netconfCall))
if err != nil {
return "", err
}
if commit {
if _, err = g.execute(context.Background(), commitStr); err != nil {
return "", err
}
}
return reply, nil
}
// sendDirectRawConfig loads raw XML configuration without group bookkeeping.
func (g *GoNCClient) sendDirectRawConfig(netconfCall string, commit bool) (string, error) {
g.Lock.Lock()
defer g.Lock.Unlock()
reply, err := g.execute(context.Background(), fmt.Sprintf(groupStrXML, netconfCall))
if err != nil {
return "", err
}
if commit {
if _, err = g.execute(context.Background(), commitStr); err != nil {
return "", err
}
}
return reply, nil
}
// readRawGroup fetches a single apply-group configuration payload.
func (g *GoNCClient) readRawGroup(applyGroup string) (string, error) {
g.Lock.Lock()
defer g.Lock.Unlock()
return g.execute(context.Background(), fmt.Sprintf(getGroupXMLStr, applyGroup))
}
// readRawConfig fetches the full configuration payload.
func (g *GoNCClient) readRawConfig() (string, error) {
g.Lock.Lock()
defer g.Lock.Unlock()
return g.execute(context.Background(), getConfigXMLStr)
}
// publicKeyFile parses an SSH private key file into an auth method.
func publicKeyFile(file string) (ssh.AuthMethod, error) {
buffer, err := os.ReadFile(file)
if err != nil {
return nil, fmt.Errorf("reading SSH key %q: %w", file, err)
}
key, err := ssh.ParsePrivateKey(buffer)
if err != nil {
return nil, fmt.Errorf("parsing SSH key %q (is it a valid private key?): %w", file, err)
}
return ssh.PublicKeys(key), nil
}
// NewClient returns a NETCONF client backed by nemith/netconf.
func NewClient(username, password, sshKey, address string, port int) (Client, error) {
if port == 0 {
port = defaultPort
}
cfg := &ssh.ClientConfig{
User: username,
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
}
if sshKey != "" {
authMethod, err := publicKeyFile(sshKey)
if err != nil {
return nil, err
}
cfg.Auth = []ssh.AuthMethod{authMethod}
} else {
cfg.Auth = []ssh.AuthMethod{ssh.Password(password)}
}
return &GoNCClient{
host: address,
port: port,
sshConfig: cfg,
}, nil
}
// netconfReplyError returns a non-nil error if the rpc-reply contains any
// error-severity <rpc-error> at ANY depth. Junos nests them under
// <commit-results> (optionally per <routing-engine>) and
// <load-configuration-results>, not just directly under <rpc-reply>.
func netconfReplyError(raw []byte) error {
dec := xml.NewDecoder(bytes.NewReader(raw))
var msgs []string
for {
tok, err := dec.Token()
if err != nil {
break
}
se, ok := tok.(xml.StartElement)
if !ok || se.Name.Local != "rpc-error" {
continue
}
var e struct {
Severity string `xml:"error-severity"`
Message string `xml:"error-message"`
Path string `xml:"error-path"`
}
if dec.DecodeElement(&e, &se) != nil {
continue
}
if strings.EqualFold(strings.TrimSpace(e.Severity), "warning") {
continue
}
m := strings.TrimSpace(e.Message)
if p := strings.TrimSpace(e.Path); p != "" {
m = strings.TrimSpace(p) + ": " + m
}
if m != "" {
msgs = append(msgs, m)
}
}
if len(msgs) > 0 {
return fmt.Errorf("device rejected the change: %s", strings.Join(msgs, "; "))
}
return nil
}
@@ -1,560 +0,0 @@
package netconf
import (
"context"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/pem"
"encoding/xml"
"errors"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"golang.org/x/crypto/ssh"
)
// newMockClient creates a client with an injected RPC executor for deterministic tests.
func newMockClient(calls *[]string, ret string, err error) *GoNCClient {
return &GoNCClient{
Lock: sync.RWMutex{},
exec: func(_ context.Context, op string) (string, error) {
*calls = append(*calls, op)
return ret, err
},
}
}
// TestDeleteConfigCallsExpectedOperations verifies delete then commit RPC sequencing.
func TestDeleteConfigCallsExpectedOperations(t *testing.T) {
calls := []string{}
client := newMockClient(&calls, "<ok/>", nil)
_, err := client.DeleteConfig("base-config", true)
if err != nil {
t.Fatalf("DeleteConfig returned error: %v", err)
}
if len(calls) != 2 {
t.Fatalf("expected 2 operations, got %d", len(calls))
}
if !strings.Contains(calls[0], "<edit-config>") {
t.Fatalf("first call should be edit-config, got %q", calls[0])
}
if strings.TrimSpace(calls[1]) != commitStr {
t.Fatalf("second call should be commit, got %q", calls[1])
}
}
// TestSendUpdateBaseConfigPayload verifies patch updates do not require group name tags.
func TestSendUpdateBaseConfigPayload(t *testing.T) {
calls := []string{}
client := newMockClient(&calls, "<ok/>", nil)
diff := `<configuration><system><host-name nc:operation="replace">leaf1</host-name></system></configuration>`
if err := client.SendUpdate("base-config", diff, false); err != nil {
t.Fatalf("SendUpdate returned error: %v", err)
}
if len(calls) != 1 {
t.Fatalf("expected single edit-config operation, got %d", len(calls))
}
if !strings.Contains(calls[0], "<edit-config>") || !strings.Contains(calls[0], "<default-operation>merge</default-operation>") {
t.Fatalf("expected patch edit-config envelope, got %q", calls[0])
}
}
// TestMarshalRPCRequestUsesInnerXML verifies patch requests are wrapped in
// <rpc> while the operation body remains raw XML, not wrapper fields.
func TestMarshalRPCRequestUsesInnerXML(t *testing.T) {
rpcXML, err := marshalRPCRequest("<edit-config><target><candidate/></target></edit-config>", "1")
if err != nil {
t.Fatalf("marshalRPCRequest() returned error: %v", err)
}
if !strings.Contains(rpcXML, `<rpc xmlns="urn:ietf:params:xml:ns:netconf:base:1.0" message-id="1">`) {
t.Fatalf("expected rpc envelope, got %q", rpcXML)
}
if !strings.Contains(rpcXML, "<edit-config>") {
t.Fatalf("expected edit-config payload in rpc, got %q", rpcXML)
}
if strings.Contains(rpcXML, "<Operation>") || strings.Contains(rpcXML, "<RawXML>") {
t.Fatalf("expected raw payload without wrapper element, got %q", rpcXML)
}
}
// TestSendTransactionWithIDReplacesGroup verifies ID-based transactions use update flow.
func TestSendTransactionWithIDReplacesGroup(t *testing.T) {
calls := []string{}
client := newMockClient(&calls, "<ok/>", nil)
obj := struct {
XMLName struct{} `xml:"configuration"`
Groups struct {
Name string `xml:"name"`
} `xml:"groups"`
}{}
obj.Groups.Name = "base-config"
if err := client.SendTransaction("base-config", obj, false); err != nil {
t.Fatalf("SendTransaction returned error: %v", err)
}
if len(calls) != 2 {
t.Fatalf("expected 2 operations for update flow, got %d", len(calls))
}
if !strings.Contains(calls[0], "operation=\"delete\"") {
t.Fatalf("expected delete operation first, got %q", calls[0])
}
if !strings.Contains(calls[1], "<load-configuration") {
t.Fatalf("expected load-configuration second, got %q", calls[1])
}
}
// TestSendCommitDiscardsOnCommitError verifies discard-changes is sent after commit failure.
func TestSendCommitDiscardsOnCommitError(t *testing.T) {
calls := []string{}
client := &GoNCClient{
Lock: sync.RWMutex{},
exec: func(_ context.Context, op string) (string, error) {
calls = append(calls, op)
if strings.TrimSpace(op) == commitStr {
return "", errors.New("commit failed")
}
return "<ok/>", nil
},
}
applyGroupsList = []string{"b", "a"}
err := client.SendCommit()
if err == nil {
t.Fatal("expected commit error")
}
if len(calls) < 3 {
t.Fatalf("expected apply-groups, commit, discard operations, got %d", len(calls))
}
if strings.TrimSpace(calls[len(calls)-1]) != discardChanges {
t.Fatalf("expected discard-changes after commit failure, got %q", calls[len(calls)-1])
}
}
// TestNewClientAllowsMissingSSHKeyPath verifies client creation tolerates unreadable key paths.
func TestNewClientAllowsMissingSSHKeyPath(t *testing.T) {
client, err := NewClient("user", "", "/does/not/exist", "127.0.0.1", 830)
if err != nil {
t.Fatalf("expected no error for invalid ssh key path, got: %v", err)
}
if client == nil {
t.Fatal("expected non-nil client")
}
}
// TestGoNCClientCloseNoop verifies Close preserves no-op behavior.
func TestGoNCClientCloseNoop(t *testing.T) {
client := &GoNCClient{}
if err := client.Close(); err != nil {
t.Fatalf("expected nil close error, got: %v", err)
}
}
// TestUpdateRawConfigMissingName verifies group name extraction failures are surfaced.
func TestUpdateRawConfigMissingName(t *testing.T) {
client := newMockClient(&[]string{}, "<ok/>", nil)
_, err := client.updateRawConfig("group", "<configuration></configuration>", false)
if err == nil || !strings.Contains(err.Error(), "failed to extract") {
t.Fatalf("expected extract error, got: %v", err)
}
}
// TestUpdateRawConfigIgnoresMissingDelete verifies initial group creation tolerates missing-group deletes.
func TestUpdateRawConfigIgnoresMissingDelete(t *testing.T) {
calls := []string{}
client := &GoNCClient{
Lock: sync.RWMutex{},
exec: func(_ context.Context, op string) (string, error) {
calls = append(calls, op)
if strings.Contains(op, "operation=\"delete\"") {
return "", errors.New("multiple netconf errors: netconf error: application data-missing: statement not found")
}
return "<ok/>", nil
},
}
_, err := client.updateRawConfig("group", "<configuration><groups><name>group</name></groups></configuration>", false)
if err != nil {
t.Fatalf("expected missing delete to be ignored, got: %v", err)
}
if len(calls) != 2 {
t.Fatalf("expected delete then load calls, got %d", len(calls))
}
}
// TestSendRawConfigCommitFlow verifies raw config load followed by commit.
func TestSendRawConfigCommitFlow(t *testing.T) {
calls := []string{}
client := newMockClient(&calls, "<ok/>", nil)
applyGroupsList = nil
reply, err := client.sendRawConfig("<configuration><groups><name>z-group</name></groups></configuration>", true)
if err != nil {
t.Fatalf("sendRawConfig() returned error: %v", err)
}
if reply != "<ok/>" {
t.Fatalf("unexpected reply: %q", reply)
}
if len(calls) != 2 {
t.Fatalf("expected load and commit calls, got: %d", len(calls))
}
if strings.TrimSpace(calls[1]) != commitStr {
t.Fatalf("expected commit as second call, got %q", calls[1])
}
}
// TestSendRawConfigMissingName verifies missing group names return an error.
func TestSendRawConfigMissingName(t *testing.T) {
client := newMockClient(&[]string{}, "<ok/>", nil)
_, err := client.sendRawConfig("<configuration></configuration>", false)
if err == nil || !strings.Contains(err.Error(), "failed to extract") {
t.Fatalf("expected extract error, got: %v", err)
}
}
// TestReadRawGroupUsesGetConfigRPC verifies group reads use get-configuration RPC.
func TestReadRawGroupUsesGetConfigRPC(t *testing.T) {
calls := []string{}
client := newMockClient(&calls, "<group/>", nil)
reply, err := client.readRawGroup("base-config")
if err != nil {
t.Fatalf("readRawGroup() returned error: %v", err)
}
if reply != "<group/>" {
t.Fatalf("unexpected reply: %q", reply)
}
if len(calls) != 1 || !strings.Contains(calls[0], "<get-configuration>") {
t.Fatalf("expected get-configuration call, got %#v", calls)
}
}
// TestMarshalGroupSuccessAndError verifies XML unmarshalling success and failure paths.
func TestMarshalGroupSuccessAndError(t *testing.T) {
t.Run("success", func(t *testing.T) {
calls := []string{}
reply := `<configuration><groups><name>g1</name></groups></configuration>`
client := newMockClient(&calls, reply, nil)
var out struct {
XMLName xml.Name `xml:"configuration"`
Groups struct {
Name string `xml:"name"`
} `xml:"groups"`
}
if err := client.MarshalGroup("g1", &out); err != nil {
t.Fatalf("MarshalGroup() returned error: %v", err)
}
if out.Groups.Name != "g1" {
t.Fatalf("unexpected group name: %q", out.Groups.Name)
}
})
t.Run("invalid xml", func(t *testing.T) {
calls := []string{}
client := newMockClient(&calls, `<bad`, nil)
var out struct{}
if err := client.MarshalGroup("g1", &out); err == nil {
t.Fatalf("expected unmarshal error")
}
})
}
// TestSendTransactionEmptyIDPathAndMarshalError verifies empty-ID path and marshal failures.
func TestSendTransactionEmptyIDPathAndMarshalError(t *testing.T) {
t.Run("empty id uses raw path", func(t *testing.T) {
calls := []string{}
client := newMockClient(&calls, "<ok/>", nil)
applyGroupsList = nil
obj := struct {
XMLName xml.Name `xml:"configuration"`
Groups struct {
Name string `xml:"name"`
} `xml:"groups"`
}{}
obj.Groups.Name = "group-a"
if err := client.SendTransaction("", obj, false); err != nil {
t.Fatalf("SendTransaction() error: %v", err)
}
if len(calls) != 1 || !strings.Contains(calls[0], "<load-configuration") {
t.Fatalf("expected single load-configuration call, got %#v", calls)
}
})
t.Run("marshal error", func(t *testing.T) {
client := newMockClient(&[]string{}, "", nil)
obj := map[string]interface{}{"bad": make(chan int)}
if err := client.SendTransaction("", obj, false); err == nil {
t.Fatalf("expected marshal error")
}
})
}
// TestApplyGroupsHelpersSortAndFilter verifies helper list sanitization and sorting.
func TestApplyGroupsHelpersSortAndFilter(t *testing.T) {
applyGroupsList = nil
addToApplyGroupsList("b")
addToApplyGroupsList("")
addToApplyGroupsList("a")
sortApplyGroupsList()
if len(applyGroupsList) != 2 {
t.Fatalf("expected empty value to be filtered out, got %#v", applyGroupsList)
}
if applyGroupsList[0] != "a" || applyGroupsList[1] != "b" {
t.Fatalf("unexpected sorted list: %#v", applyGroupsList)
}
}
// TestPublicKeyFileErrorPaths verifies key loader failure paths.
func TestPublicKeyFileErrorPaths(t *testing.T) {
if method := publicKeyFile("/does/not/exist"); method != nil {
t.Fatalf("expected nil auth method for missing file")
}
dir := t.TempDir()
keyPath := filepath.Join(dir, "invalid.key")
if err := os.WriteFile(keyPath, []byte("not-a-key"), 0600); err != nil {
t.Fatalf("failed to write key file: %v", err)
}
if method := publicKeyFile(keyPath); method != nil {
t.Fatalf("expected nil auth method for invalid key")
}
}
// TestNewClientDefaultPort verifies zero port maps to NETCONF default port.
func TestNewClientDefaultPort(t *testing.T) {
client, err := NewClient("user", "pass", "", "127.0.0.1", 0)
if err != nil {
t.Fatalf("NewClient() error: %v", err)
}
gonc, ok := client.(*GoNCClient)
if !ok {
t.Fatalf("expected *GoNCClient")
}
if gonc.port != defaultPort {
t.Fatalf("expected default port %d, got %d", defaultPort, gonc.port)
}
}
// TestExecuteWithoutMockReturnsDialError verifies network execution returns dial errors.
func TestExecuteWithoutMockReturnsDialError(t *testing.T) {
client := &GoNCClient{
host: "invalid-hostname-for-test",
port: 830,
sshConfig: &ssh.ClientConfig{HostKeyCallback: ssh.InsecureIgnoreHostKey()},
}
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
_, err := client.execute(ctx, "<rpc/>")
if err == nil {
t.Fatalf("expected network execute to fail")
}
}
// TestUpdateRawConfigCommitAndErrorBranches verifies update commit and error branches.
func TestUpdateRawConfigCommitAndErrorBranches(t *testing.T) {
t.Run("commit true success", func(t *testing.T) {
calls := []string{}
client := newMockClient(&calls, "<ok/>", nil)
applyGroupsList = nil
reply, err := client.updateRawConfig("grp", "<configuration><groups><name>grp</name></groups></configuration>", true)
if err != nil {
t.Fatalf("updateRawConfig() error: %v", err)
}
if reply != "<ok/>" {
t.Fatalf("unexpected reply: %q", reply)
}
if len(calls) != 3 {
t.Fatalf("expected 3 calls (delete, load, commit), got %d", len(calls))
}
})
t.Run("delete error", func(t *testing.T) {
client := &GoNCClient{
Lock: sync.RWMutex{},
exec: func(_ context.Context, op string) (string, error) {
if strings.Contains(op, "operation=\"delete\"") {
return "", errors.New("delete failed")
}
return "<ok/>", nil
},
}
_, err := client.updateRawConfig("grp", "<configuration><groups><name>grp</name></groups></configuration>", false)
if err == nil {
t.Fatalf("expected delete error")
}
})
t.Run("commit error", func(t *testing.T) {
client := &GoNCClient{
Lock: sync.RWMutex{},
exec: func(_ context.Context, op string) (string, error) {
if strings.TrimSpace(op) == commitStr {
return "", errors.New("commit failed")
}
return "<ok/>", nil
},
}
_, err := client.updateRawConfig("grp", "<configuration><groups><name>grp</name></groups></configuration>", true)
if err == nil {
t.Fatalf("expected commit error")
}
})
}
// TestDeleteConfigBranches verifies non-commit and commit-error delete behavior.
func TestDeleteConfigBranches(t *testing.T) {
t.Run("without commit", func(t *testing.T) {
calls := []string{}
client := newMockClient(&calls, "<ok/>\n", nil)
reply, err := client.DeleteConfig("grp", false)
if err != nil {
t.Fatalf("DeleteConfig() error: %v", err)
}
if reply != "<ok/>" {
t.Fatalf("expected newline-stripped reply, got %q", reply)
}
if len(calls) != 1 {
t.Fatalf("expected single delete call, got %d", len(calls))
}
})
t.Run("commit error", func(t *testing.T) {
client := &GoNCClient{
Lock: sync.RWMutex{},
exec: func(_ context.Context, op string) (string, error) {
if strings.TrimSpace(op) == commitStr {
return "", errors.New("commit failed")
}
return "<ok/>", nil
},
}
_, err := client.DeleteConfig("grp", true)
if err == nil {
t.Fatalf("expected commit error")
}
})
t.Run("missing group delete", func(t *testing.T) {
client := &GoNCClient{
Lock: sync.RWMutex{},
exec: func(_ context.Context, _ string) (string, error) {
return "", errors.New("netconf error: application data-missing: statement not found")
},
}
reply, err := client.DeleteConfig("grp", false)
if err != nil {
t.Fatalf("expected missing-group delete to be ignored, got: %v", err)
}
if reply != "<ok/>" {
t.Fatalf("expected synthetic ok reply, got %q", reply)
}
})
}
// TestSendCommitSuccessAndApplyGroupError verifies commit success and apply-group RPC failure behavior.
func TestSendCommitSuccessAndApplyGroupError(t *testing.T) {
t.Run("success", func(t *testing.T) {
calls := []string{}
client := newMockClient(&calls, "<ok/>", nil)
applyGroupsList = []string{"z", "", "a"}
if err := client.SendCommit(); err != nil {
t.Fatalf("SendCommit() error: %v", err)
}
if len(calls) < 2 {
t.Fatalf("expected apply-group load then commit calls, got %d", len(calls))
}
if !strings.Contains(calls[0], "<apply-groups>a</apply-groups>") || !strings.Contains(calls[0], "<apply-groups>z</apply-groups>") {
t.Fatalf("expected sorted apply groups in RPC, got %q", calls[0])
}
})
t.Run("apply-group load error", func(t *testing.T) {
client := &GoNCClient{
Lock: sync.RWMutex{},
exec: func(_ context.Context, op string) (string, error) {
if strings.Contains(op, "<load-configuration") {
return "", errors.New("load failed")
}
return "<ok/>", nil
},
}
applyGroupsList = []string{"x"}
if err := client.SendCommit(); err == nil {
t.Fatalf("expected sendApplyGroupsLocked error")
}
})
}
// TestMarshalGroupReadError verifies read errors are propagated by MarshalGroup.
func TestMarshalGroupReadError(t *testing.T) {
client := &GoNCClient{
Lock: sync.RWMutex{},
exec: func(_ context.Context, _ string) (string, error) {
return "", errors.New("read failed")
},
}
var out struct{}
if err := client.MarshalGroup("x", &out); err == nil {
t.Fatalf("expected read error")
}
}
// TestSendRawConfigExecuteError verifies RPC execution errors are returned.
func TestSendRawConfigExecuteError(t *testing.T) {
client := &GoNCClient{
Lock: sync.RWMutex{},
exec: func(_ context.Context, _ string) (string, error) {
return "", errors.New("rpc failed")
},
}
_, err := client.sendRawConfig("<configuration><groups><name>g</name></groups></configuration>", false)
if err == nil {
t.Fatalf("expected rpc error")
}
}
// TestNewClientWithValidSSHKey verifies SSH key auth path setup with a valid key.
func TestNewClientWithValidSSHKey(t *testing.T) {
key, err := rsa.GenerateKey(rand.Reader, 1024)
if err != nil {
t.Fatalf("failed generating rsa key: %v", err)
}
keyDER := x509.MarshalPKCS1PrivateKey(key)
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: keyDER})
keyPath := filepath.Join(t.TempDir(), "id_rsa")
if err := os.WriteFile(keyPath, keyPEM, 0600); err != nil {
t.Fatalf("failed writing key: %v", err)
}
client, err := NewClient("user", "", keyPath, "127.0.0.1", 830)
if err != nil {
t.Fatalf("NewClient() error: %v", err)
}
gonc := client.(*GoNCClient)
if len(gonc.sshConfig.Auth) != 1 {
t.Fatalf("expected one auth method")
}
}
@@ -1,12 +0,0 @@
package netconf
type Client interface {
Close() error
DeleteConfig(applyGroup string, commit bool) (string, error)
SendCommit() error
MarshalGroup(id string, obj interface{}) error
MarshalConfig(obj interface{}) error
SendTransaction(id string, obj interface{}, commit bool) error
SendDirectTransaction(obj interface{}, commit bool) error
SendUpdate(id string, diff string, commit bool) error
}
@@ -1,810 +0,0 @@
package patch
import (
"strings"
"testing"
)
// ---------------------------------------------------------------------------
// CC-10: Special characters in key values
// ---------------------------------------------------------------------------
func TestCC10_ParseSegment_KeyWithSlashes(t *testing.T) {
// Interface names like ge-0/0/0 are the common case — already inside brackets
tag, keyName, keyValue := parseSegment("interface[name=ge-0/0/0]")
if tag != "interface" || keyName != "name" || keyValue != "ge-0/0/0" {
t.Errorf("got tag=%q key=%q val=%q", tag, keyName, keyValue)
}
}
func TestCC10_ParseSegment_KeyWithNestedBrackets(t *testing.T) {
// Policy name containing brackets: "ALLOW[ALL]"
tag, keyName, keyValue := parseSegment("policy[name=ALLOW[ALL]]")
if tag != "policy" {
t.Errorf("expected tag=policy, got %q", tag)
}
if keyName != "name" {
t.Errorf("expected keyName=name, got %q", keyName)
}
if keyValue != "ALLOW[ALL]" {
t.Errorf("expected keyValue=ALLOW[ALL], got %q", keyValue)
}
}
func TestCC10_ParseSegment_KeyWithEquals(t *testing.T) {
// Route key containing equals: "prefix=10.0.0.0/8"
tag, keyName, keyValue := parseSegment("route[prefix=10.0.0.0/8]")
if tag != "route" || keyName != "prefix" || keyValue != "10.0.0.0/8" {
t.Errorf("got tag=%q key=%q val=%q", tag, keyName, keyValue)
}
}
func TestCC10_SplitPath_KeyWithSlashes(t *testing.T) {
// Verify path splitting handles keys with slashes correctly
path := "interfaces/interface[name=ge-0/0/0]/unit[name=0]/description"
segments := splitPathRespectingQuotes(path)
expected := []string{"interfaces", "interface[name=ge-0/0/0]", "unit[name=0]", "description"}
if len(segments) != len(expected) {
t.Fatalf("expected %d segments, got %d: %v", len(expected), len(segments), segments)
}
for i, seg := range segments {
if seg != expected[i] {
t.Errorf("segment[%d]: expected %q got %q", i, expected[i], seg)
}
}
}
// ---------------------------------------------------------------------------
// CC-5: Container delete coalescing
// ---------------------------------------------------------------------------
// CC-5 schema covers system/ntp with two list-entries and a leaf-list
const cc5Schema = `{
"path": "",
"root": {
"children": [
{
"name": "configuration",
"type": "container",
"path": "",
"children": [
{
"name": "system",
"type": "container",
"path": "",
"children": [
{
"name": "host-name",
"type": "leaf",
"path": "system",
"leaf-type": "string"
},
{
"name": "ntp",
"type": "container",
"path": "system",
"children": [
{
"name": "server",
"type": "list",
"path": "system/ntp",
"key": "name",
"children": [
{
"name": "name",
"type": "leaf",
"path": "system/ntp/server",
"leaf-type": "string"
},
{
"name": "routing-instance",
"type": "leaf",
"path": "system/ntp/server",
"leaf-type": "string"
}
]
},
{
"name": "trusted-key",
"type": "leaf-list",
"path": "system/ntp",
"leaf-type": "string"
}
]
}
]
}
]
}
]
}
}`
func TestCC5_ContainerDeleteCoalescing(t *testing.T) {
// When ALL children of a container are deleted, the patch should ideally
// emit a single container-level delete. Currently, the engine emits
// individual per-leaf deletes.
stateXML := `<configuration>
<system>
<host-name>router1</host-name>
<ntp>
<server><name>10.0.0.1</name><routing-instance>mgmt</routing-instance></server>
<server><name>10.0.0.2</name><routing-instance>mgmt</routing-instance></server>
<trusted-key>1</trusted-key>
<trusted-key>2</trusted-key>
</ntp>
</system>
</configuration>`
// Plan: ntp entirely removed, host-name stays
planXML := `<configuration>
<system>
<host-name>router1</host-name>
</system>
</configuration>`
idx := mustIdxFromSchema(t, cc5Schema)
stateTree, err := BuildTree([]byte(stateXML))
if err != nil {
t.Fatal(err)
}
planTree, err := BuildTree([]byte(planXML))
if err != nil {
t.Fatal(err)
}
stateMap := LeafMapWithSchema(stateTree, idx)
planMap := LeafMapWithSchema(planTree, idx)
diffMap := ComputeDiff(stateMap, planMap)
if len(diffMap) == 0 {
t.Fatal("expected non-empty diff")
}
// Use schema-aware patch (with container coalescing)
patchBytes, err := CreateDiffPatchWithSchema(diffMap, "", idx)
if err != nil {
t.Fatal(err)
}
output := string(patchBytes)
t.Logf("CC-5 patch output:\n%s", output)
// Verify: the patch should contain a single container-level delete for ntp
if !strings.Contains(output, "delete") {
t.Error("expected delete operations in patch output")
}
// Check that host-name is NOT deleted (it's in both state and plan)
if strings.Contains(output, "host-name") {
t.Error("host-name should not appear in patch (unchanged)")
}
// Track whether coalescing is happening
if strings.Contains(output, `<ntp nc:operation="delete"`) {
t.Log("CC-5 PASSED: Container-level delete coalescing IS implemented")
} else {
t.Error("CC-5 FAILED: Expected container-level delete coalescing")
// Verify that at least all ntp entries are being deleted
if !strings.Contains(output, "server") && !strings.Contains(output, "trusted-key") {
t.Error("expected server or trusted-key deletes")
}
}
}
// ---------------------------------------------------------------------------
// CC-2: Empty leaf toggle (disable / vlan-tagging)
// ---------------------------------------------------------------------------
const cc2Schema = `{
"path": "",
"root": {
"children": [
{
"name": "configuration",
"type": "container",
"path": "",
"children": [
{
"name": "interfaces",
"type": "container",
"path": "",
"children": [
{
"name": "interface",
"type": "list",
"path": "interfaces",
"key": "name",
"children": [
{
"name": "name",
"type": "leaf",
"path": "interfaces/interface",
"leaf-type": "string"
},
{
"name": "disable",
"type": "leaf",
"path": "interfaces/interface",
"leaf-type": "empty"
},
{
"name": "vlan-tagging",
"type": "leaf",
"path": "interfaces/interface",
"leaf-type": "empty"
},
{
"name": "description",
"type": "leaf",
"path": "interfaces/interface",
"leaf-type": "string"
}
]
}
]
}
]
}
]
}
}`
func TestCC2_EmptyLeafCreate(t *testing.T) {
// Add disable to an interface that doesn't have it
stateXML := `<configuration>
<interfaces>
<interface>
<name>ge-0/0/0</name>
<description>uplink</description>
</interface>
</interfaces>
</configuration>`
planXML := `<configuration>
<interfaces>
<interface>
<name>ge-0/0/0</name>
<description>uplink</description>
<disable/>
</interface>
</interfaces>
</configuration>`
idx := mustIdxFromSchema(t, cc2Schema)
stateTree, _ := BuildTree([]byte(stateXML))
planTree, _ := BuildTree([]byte(planXML))
stateMap := LeafMapWithSchema(stateTree, idx)
planMap := LeafMapWithSchema(planTree, idx)
// Verify the leaf map correctly represents the empty leaf
disableKey := ""
for k, v := range planMap {
if strings.HasSuffix(k, "/disable") {
disableKey = k
t.Logf("planMap disable: %q = %q", k, v)
}
}
if disableKey == "" {
t.Fatal("disable leaf not found in plan map")
}
// State should NOT have disable
for k := range stateMap {
if strings.HasSuffix(k, "/disable") {
t.Fatal("disable should not be in state map")
}
}
diffMap := ComputeDiff(stateMap, planMap)
patchBytes, err := CreateDiffPatch(diffMap, "")
if err != nil {
t.Fatal(err)
}
output := string(patchBytes)
t.Logf("CC-2 create output:\n%s", output)
if !strings.Contains(output, "disable") {
t.Error("expected disable in patch output")
}
if !strings.Contains(output, `nc:operation="merge"`) {
t.Error("expected create operation for disable")
}
}
func TestCC2_EmptyLeafDelete(t *testing.T) {
// Remove disable from an interface
stateXML := `<configuration>
<interfaces>
<interface>
<name>ge-0/0/0</name>
<description>uplink</description>
<disable/>
</interface>
</interfaces>
</configuration>`
planXML := `<configuration>
<interfaces>
<interface>
<name>ge-0/0/0</name>
<description>uplink</description>
</interface>
</interfaces>
</configuration>`
idx := mustIdxFromSchema(t, cc2Schema)
stateTree, _ := BuildTree([]byte(stateXML))
planTree, _ := BuildTree([]byte(planXML))
stateMap := LeafMapWithSchema(stateTree, idx)
planMap := LeafMapWithSchema(planTree, idx)
// State SHOULD have disable
found := false
for k := range stateMap {
if strings.HasSuffix(k, "/disable") {
found = true
break
}
}
if !found {
t.Fatal("disable should be in state map")
}
diffMap := ComputeDiff(stateMap, planMap)
patchBytes, err := CreateDiffPatch(diffMap, "")
if err != nil {
t.Fatal(err)
}
output := string(patchBytes)
t.Logf("CC-2 delete output:\n%s", output)
if !strings.Contains(output, "disable") {
t.Error("expected disable in patch output")
}
if !strings.Contains(output, `nc:operation="delete"`) {
t.Error("expected delete operation for disable")
}
// Empty leaf delete should NOT have text content
if strings.Contains(output, `<disable nc:operation="delete">`) {
// Check if there's text between tags
if strings.Contains(output, `<disable nc:operation="delete"></disable>`) {
t.Log("CC-2 NOTE: empty tags (OK)")
}
}
}
// ---------------------------------------------------------------------------
// CC-3: Leaf-list full replacement vs incremental
// ---------------------------------------------------------------------------
func TestCC3_LeafListBulkChange(t *testing.T) {
// Community members change from [A, B, C] to [A, D, E]
stateXML := `<configuration>
<policy-options>
<community>
<name>OC-STD</name>
<members>65000:100</members>
<members>65000:200</members>
<members>65000:300</members>
</community>
</policy-options>
</configuration>`
planXML := `<configuration>
<policy-options>
<community>
<name>OC-STD</name>
<members>65000:100</members>
<members>65000:400</members>
<members>65000:500</members>
</community>
</policy-options>
</configuration>`
idx := mustIdxFromSchema(t, matrixSchema)
stateTree, _ := BuildTree([]byte(stateXML))
planTree, _ := BuildTree([]byte(planXML))
stateMap := LeafMapWithSchema(stateTree, idx)
planMap := LeafMapWithSchema(planTree, idx)
diffMap := ComputeDiff(stateMap, planMap)
patchBytes, err := CreateDiffPatch(diffMap, "")
if err != nil {
t.Fatal(err)
}
output := string(patchBytes)
t.Logf("CC-3 leaf-list bulk change output:\n%s", output)
// Verify: 65000:200 and 65000:300 should be deleted, 65000:400 and 65000:500 created
if !strings.Contains(output, "65000:200") {
t.Error("expected 65000:200 delete")
}
if !strings.Contains(output, "65000:300") {
t.Error("expected 65000:300 delete")
}
if !strings.Contains(output, "65000:400") {
t.Error("expected 65000:400 create")
}
if !strings.Contains(output, "65000:500") {
t.Error("expected 65000:500 create")
}
// 65000:100 should NOT appear (unchanged)
if strings.Contains(output, "65000:100") {
t.Error("65000:100 should not be in patch (unchanged)")
}
}
// ---------------------------------------------------------------------------
// CC-1: Ordered leaf-list reorder detection
// ---------------------------------------------------------------------------
const cc1Schema = `{
"path": "",
"root": {
"children": [
{
"name": "configuration",
"type": "container",
"path": "",
"children": [
{
"name": "interfaces",
"type": "container",
"path": "",
"children": [
{
"name": "interface",
"type": "list",
"path": "interfaces",
"key": "name",
"children": [
{
"name": "name",
"type": "leaf",
"path": "interfaces/interface",
"leaf-type": "string"
},
{
"name": "unit",
"type": "list",
"path": "interfaces/interface",
"key": "name",
"children": [
{
"name": "name",
"type": "leaf",
"path": "interfaces/interface/unit",
"leaf-type": "string"
},
{
"name": "family",
"type": "container",
"path": "interfaces/interface/unit",
"children": [
{
"name": "inet",
"type": "container",
"path": "interfaces/interface/unit/family",
"children": [
{
"name": "address",
"type": "list",
"path": "interfaces/interface/unit/family/inet",
"key": "name",
"children": [
{
"name": "name",
"type": "leaf",
"path": "interfaces/interface/unit/family/inet/address",
"leaf-type": "string"
},
{
"name": "vrrp-group",
"type": "list",
"path": "interfaces/interface/unit/family/inet/address",
"key": "name",
"children": [
{
"name": "name",
"type": "leaf",
"path": "interfaces/interface/unit/family/inet/address/vrrp-group",
"leaf-type": "string"
},
{
"name": "virtual-address",
"type": "leaf-list",
"path": "interfaces/interface/unit/family/inet/address/vrrp-group",
"leaf-type": "string",
"ordered-by": "user"
},
{
"name": "priority",
"type": "leaf",
"path": "interfaces/interface/unit/family/inet/address/vrrp-group",
"leaf-type": "string"
}
]
}
]
}
]
}
]
}
]
}
]
}
]
}
]
}
]
}
}`
func TestCC1_OrderedLeafListReorder(t *testing.T) {
// VRRP virtual-address is ordered-by user — reorder should be detected
stateXML := `<configuration>
<interfaces>
<interface>
<name>ge-0/0/0</name>
<unit>
<name>0</name>
<family>
<inet>
<address>
<name>10.0.0.1/24</name>
<vrrp-group>
<name>1</name>
<virtual-address>10.0.0.10</virtual-address>
<virtual-address>10.0.0.20</virtual-address>
<priority>200</priority>
</vrrp-group>
</address>
</inet>
</family>
</unit>
</interface>
</interfaces>
</configuration>`
// Reorder: swap virtual-address order
planXML := `<configuration>
<interfaces>
<interface>
<name>ge-0/0/0</name>
<unit>
<name>0</name>
<family>
<inet>
<address>
<name>10.0.0.1/24</name>
<vrrp-group>
<name>1</name>
<virtual-address>10.0.0.20</virtual-address>
<virtual-address>10.0.0.10</virtual-address>
<priority>200</priority>
</vrrp-group>
</address>
</inet>
</family>
</unit>
</interface>
</interfaces>
</configuration>`
idx := mustIdxFromSchema(t, cc1Schema)
stateTree, _ := BuildTree([]byte(stateXML))
planTree, _ := BuildTree([]byte(planXML))
stateMap := LeafMapWithSchema(stateTree, idx)
planMap := LeafMapWithSchema(planTree, idx)
t.Logf("State map entries:")
for k, v := range stateMap {
if strings.Contains(k, "virtual") {
t.Logf(" %s = %q", k, v)
}
}
t.Logf("Plan map entries:")
for k, v := range planMap {
if strings.Contains(k, "virtual") {
t.Logf(" %s = %q", k, v)
}
}
diffMap := ComputeDiff(stateMap, planMap)
if len(diffMap) == 0 {
t.Log("CC-1 CONFIRMED: Reorder produces EMPTY diff (order not tracked)")
t.Log("CC-1 STATUS: NOT COVERED — ordered leaf-lists need position-aware diff")
} else {
t.Logf("CC-1 diff has %d entries — reorder IS detected", len(diffMap))
for k, v := range diffMap {
t.Logf(" %s: op=%d old=%q new=%q", k, v.Op, v.OldVal, v.NewVal)
}
}
}
// ---------------------------------------------------------------------------
// CC-4: Nested list entry addition with mandatory leaves
// ---------------------------------------------------------------------------
func TestCC4_NestedListEntryCreation(t *testing.T) {
// Add a completely new interface with nested unit/family/address
stateXML := `<configuration>
<interfaces>
<interface>
<name>ge-0/0/0</name>
<description>existing</description>
</interface>
</interfaces>
</configuration>`
planXML := `<configuration>
<interfaces>
<interface>
<name>ge-0/0/0</name>
<description>existing</description>
</interface>
<interface>
<name>ge-0/0/1</name>
<description>new-link</description>
</interface>
</interfaces>
</configuration>`
idx := mustIdxFromSchema(t, matrixSchema)
stateTree, _ := BuildTree([]byte(stateXML))
planTree, _ := BuildTree([]byte(planXML))
stateMap := LeafMapWithSchema(stateTree, idx)
planMap := LeafMapWithSchema(planTree, idx)
diffMap := ComputeDiff(stateMap, planMap)
patchBytes, err := CreateDiffPatch(diffMap, "")
if err != nil {
t.Fatal(err)
}
output := string(patchBytes)
t.Logf("CC-4 nested list entry output:\n%s", output)
// New interface should have create operation on parent
if !strings.Contains(output, "ge-0/0/1") {
t.Error("expected ge-0/0/1 in output")
}
if !strings.Contains(output, "new-link") {
t.Error("expected description new-link in output")
}
// Verify the new entry has the create operation
if !strings.Contains(output, "merge") {
t.Error("expected create operation for new list entry")
}
// Existing interface should NOT appear (unchanged)
if strings.Contains(output, "ge-0/0/0") {
t.Error("ge-0/0/0 should not be in patch (unchanged)")
}
}
// ---------------------------------------------------------------------------
// CC-6: UTF-8 normalization
// ---------------------------------------------------------------------------
func TestCC6_UTF8DiffNoFalsePositive(t *testing.T) {
// State and plan both have em-dash — should produce no diff
stateXML := `<configuration>
<interfaces>
<interface>
<name>ge-0/0/0</name>
<description>Uplink — Core Router</description>
</interface>
</interfaces>
</configuration>`
planXML := `<configuration>
<interfaces>
<interface>
<name>ge-0/0/0</name>
<description>Uplink — Core Router</description>
</interface>
</interfaces>
</configuration>`
idx := mustIdxFromSchema(t, matrixSchema)
stateTree, _ := BuildTree([]byte(stateXML))
planTree, _ := BuildTree([]byte(planXML))
stateMap := LeafMapWithSchema(stateTree, idx)
planMap := LeafMapWithSchema(planTree, idx)
diffMap := ComputeDiff(stateMap, planMap)
if len(diffMap) != 0 {
t.Errorf("CC-6: Expected empty diff for identical UTF-8 content, got %d entries", len(diffMap))
for k, v := range diffMap {
t.Logf(" %s: op=%d old=%q new=%q", k, v.Op, v.OldVal, v.NewVal)
}
} else {
t.Log("CC-6 PASSED: Identical UTF-8 strings produce no diff")
}
}
func TestCC6_UTF8DiffWithEncodingVariation(t *testing.T) {
// Simulate encoding variation: em-dash as &#x2014; vs UTF-8 literal
stateXML := `<configuration>
<interfaces>
<interface>
<name>ge-0/0/0</name>
<description>Uplink &#x2014; Core</description>
</interface>
</interfaces>
</configuration>`
planXML := `<configuration>
<interfaces>
<interface>
<name>ge-0/0/0</name>
<description>Uplink — Core</description>
</interface>
</interfaces>
</configuration>`
idx := mustIdxFromSchema(t, matrixSchema)
stateTree, _ := BuildTree([]byte(stateXML))
planTree, _ := BuildTree([]byte(planXML))
stateMap := LeafMapWithSchema(stateTree, idx)
planMap := LeafMapWithSchema(planTree, idx)
diffMap := ComputeDiff(stateMap, planMap)
if len(diffMap) == 0 {
t.Log("CC-6 PASSED: XML entity reference (&#x2014;) and literal em-dash produce same string after XML decode — no false diff")
} else {
t.Log("CC-6 NOTE: XML entity vs literal character produces diff (may need normalization)")
for k, v := range diffMap {
t.Logf(" %s: op=%d old=%q new=%q", k, v.Op, v.OldVal, v.NewVal)
}
}
}
func TestCC6_DoubleEncodedUTF8Repair(t *testing.T) {
// Simulate the actual double-encoding seen on vpaa: em-dash bytes
// misinterpreted as Latin-1 and re-encoded to UTF-8
// Original: "Uplink — Core" (em-dash U+2014 = E2 80 94 in UTF-8)
// Double-encoded: each byte treated as Latin-1 code point:
// E2 → U+00E2 (â) → C3 A2
// 80 → U+0080 → C2 80
// 94 → U+0094 → C2 94
doubleEncoded := "Uplink \u00e2\u0080\u0094 Core" // This is what double-encoding produces
original := "Uplink \xe2\x80\x94 Core" // Em-dash as UTF-8 bytes (but stored as string — same as —)
// Using NormalizeLeafMapUTF8 to repair
m := map[string]string{"test": doubleEncoded}
normalized := NormalizeLeafMapUTF8(m)
if normalized["test"] == original {
t.Log("CC-6 PASSED: Double-encoded UTF-8 repaired to original")
} else {
t.Logf("CC-6 FAILED: normalized=%q expected=%q", normalized["test"], original)
t.Logf(" double-encoded bytes: %x", []byte(doubleEncoded))
t.Logf(" original bytes: %x", []byte(original))
t.Logf(" normalized bytes: %x", []byte(normalized["test"]))
}
}
@@ -1,113 +0,0 @@
package patch
import (
"unicode/utf8"
)
// NormalizeLeafMapUTF8 creates a copy of the leaf map with all string values
// sanitized: double-encoded UTF-8 sequences (where UTF-8 bytes were
// misinterpreted as Latin-1 and re-encoded) are repaired back to their
// original form. This prevents false diffs caused by encoding round-trip
// issues between Junos NETCONF responses and Go's xml.Marshal/Unmarshal.
func NormalizeLeafMapUTF8(m map[string]string) map[string]string {
result := make(map[string]string, len(m))
for k, v := range m {
result[k] = repairDoubleEncodedUTF8(v)
}
return result
}
// repairDoubleEncodedUTF8 detects and repairs strings where UTF-8 bytes were
// misinterpreted as Latin-1 (ISO-8859-1) and then re-encoded to UTF-8.
// For example, em-dash U+2014 (UTF-8: E2 80 94) becomes "â\x80\x94"
// when double-encoded. This function reverses that transformation.
func repairDoubleEncodedUTF8(s string) string {
// Quick check: if the string contains any rune in the C2-F4 range
// (UTF-8 lead bytes when misread as Latin-1 code points), it might
// be double-encoded. Also check for control chars (0x80-0x9F) which
// appear as raw runes when UTF-8 continuation bytes are misread.
hasDoubleEncodeSignal := false
for _, r := range s {
if (r >= 0x80 && r <= 0x9F) || (r >= 0xC0 && r <= 0xF4) {
hasDoubleEncodeSignal = true
break
}
}
if !hasDoubleEncodeSignal {
return s
}
// Try to decode: treat each rune as a byte value (Latin-1 → byte)
// and see if the resulting byte sequence is valid UTF-8
bytes := make([]byte, 0, len(s))
for _, r := range s {
if r > 0xFF {
// Rune above Latin-1 range — not double-encoded
return s
}
bytes = append(bytes, byte(r))
}
if utf8.Valid(bytes) {
repaired := string(bytes)
// Sanity check: repaired string should be shorter (fewer bytes)
if len(repaired) < len(s) {
return repaired
}
}
return s
}
// ComputeDiff compares stateMap (what is currently on the device) with
// planMap (what Terraform wants it to be) and returns a map of leaf paths
// to their required CRUD operation.
//
// Rules:
// - Path in state only → Delete (remove it from the device)
// - Path in both, values differ → Replace (update the existing value)
// - Path in plan only → Create (add new leaf to the device)
// - Path in both, values identical → omitted (no change needed)
func ComputeDiff(stateMap, planMap map[string]string) map[string]Change {
diff := make(map[string]Change)
// First pass: iterate state — find deletions and replacements
for path, stateVal := range stateMap {
if planVal, exists := planMap[path]; !exists {
diff[path] = Change{Op: Delete, OldVal: stateVal, NewVal: ""}
} else if planVal != stateVal {
diff[path] = Change{Op: Replace, OldVal: stateVal, NewVal: planVal}
}
// Values match — no change, do not add to diff
}
// Second pass: iterate plan — find creations
for path, planVal := range planMap {
if _, exists := stateMap[path]; !exists {
diff[path] = Change{Op: Create, OldVal: "", NewVal: planVal}
}
}
return diff
}
type DebugChange struct {
Path string
Op ChangeType
OldVal string
NewVal string
}
func DebugSortedChanges(diffMap map[string]Change) []DebugChange {
ordered := orderedChanges(diffMap)
result := make([]DebugChange, 0, len(ordered))
for _, entry := range ordered {
result = append(result, DebugChange{
Path: entry.path,
Op: entry.change.Op,
OldVal: entry.change.OldVal,
NewVal: entry.change.NewVal,
})
}
return result
}
@@ -1,280 +0,0 @@
package patch
import (
"fmt"
"strings"
)
// junosListKeys contains the YANG list key element names common in Junos.
// "name" covers ~95% of cases (interfaces, units, BGP groups, policies, etc.).
// "id" and "type" handle a small number of edge-case list definitions.
var junosListKeys = map[string]bool{
"name": true,
"id": true,
"type": true,
}
// LeafMapWithSchema flattens an XML tree using schema-derived list keys and
// node kinds from trimmed_schema metadata.
//
// Behavior:
// - list identity is derived from schema list key (not hardcoded key names)
// - leaf-list entries are represented as distinct set elements by appending
// [value=<text>] to the path segment, enabling add/remove diff semantics
// - key leaf children are excluded from emitted leaves
func LeafMapWithSchema(root *Node, idx map[string]*NodeInfo) map[string]string {
result := make(map[string]string)
leafMapRecurseWithSchema(root, "", result, idx)
return result
}
func leafMapRecurseWithSchema(node *Node, parentPath string, result map[string]string, idx map[string]*NodeInfo) {
schemaPath := outputPathToSchemaPath(parentPath)
segment := buildSegmentWithSchema(node, schemaPath, idx)
currentPath := segment
if parentPath != "" {
currentPath = parentPath + "/" + segment
}
if len(node.Children) == 0 {
// Skip empty containers/lists — they have no leaf content to diff.
// Only emit actual leaves (YANG "empty" type like <any/>, <notice/>
// or regular text leaves).
leafSchemaPath := outputPathToSchemaPath(currentPath)
if info, ok := idx[leafSchemaPath]; ok {
if info.Kind == KindContainer || info.Kind == KindList {
return
}
if info.Kind == KindLeafList {
currentPath = currentPath + fmt.Sprintf("[value=%s]", node.Text)
}
} else if node.Text == "" {
// Element not in schema and has no text — likely an empty
// container or unrecognised element; skip it.
return
}
result[currentPath] = node.Text
return
}
if keyPath, keyValue, ok := structuralKeyedListLeaf(node, currentPath, idx); ok {
result[keyPath] = keyValue
return
}
// Process ALL children including key children. Key leaves must appear
// in the leaf map so ComputeDiff can detect new/removed list entries
// (where the key leaf is the diff signal for entry-level operations).
//
// For ordered-by-user leaf-lists, track position per leaf-list tag so that
// reordering produces Replace diffs rather than being invisible.
orderedCounters := make(map[string]int) // tag -> next position
for _, child := range node.Children {
childSchemaPath := outputPathToSchemaPath(currentPath + "/" + child.Tag)
if info, ok := idx[childSchemaPath]; ok && info.Kind == KindLeafList && info.OrderedByUser {
pos := orderedCounters[child.Tag]
orderedCounters[child.Tag] = pos + 1
// Emit positional key: path[pos=N] = value
posPath := currentPath + "/" + child.Tag + fmt.Sprintf("[pos=%d]", pos)
result[posPath] = child.Text
continue
}
leafMapRecurseWithSchema(child, currentPath, result, idx)
}
}
func structuralKeyedListLeaf(node *Node, currentPath string, idx map[string]*NodeInfo) (string, string, bool) {
schemaPath := outputPathToSchemaPath(currentPath)
info, ok := idx[schemaPath]
if !ok || info.Kind != KindList || info.ListKey == "" {
return "", "", false
}
// Compound keys (e.g. "choice-ident choice-value community-name") have
// non-key structural children that must be preserved in the leaf map;
// the structural shortcut cannot be used.
if strings.Contains(info.ListKey, " ") {
return "", "", false
}
keyValue := keyedListValue(node, info.ListKey)
if keyValue == "" || subtreeHasMaterialLeaves(node, currentPath, idx) {
return "", "", false
}
return currentPath + "/" + info.ListKey, keyValue, true
}
func keyedListValue(node *Node, keyName string) string {
for _, keyPart := range strings.Fields(keyName) {
for _, child := range node.Children {
if child.Tag == keyPart && child.Text != "" {
return child.Text
}
}
}
return ""
}
func subtreeHasMaterialLeaves(node *Node, currentPath string, idx map[string]*NodeInfo) bool {
for _, child := range node.Children {
if isKeyChildWithSchema(child, node, currentPath, idx) {
continue
}
schemaPath := outputPathToSchemaPath(currentPath)
segment := buildSegmentWithSchema(child, schemaPath, idx)
childPath := segment
if currentPath != "" {
childPath = currentPath + "/" + segment
}
// A non-key child that is a list entry is material even if its only
// descendant is its own key — nested list entries are real content.
childSchemaPath := outputPathToSchemaPath(childPath)
if info, ok := idx[childSchemaPath]; ok && info.Kind == KindList {
return true
}
if len(child.Children) == 0 {
// Even empty elements (YANG type "empty") represent material
// config knobs; their presence prevents key-only early return.
return true
}
if subtreeHasMaterialLeaves(child, childPath, idx) {
return true
}
}
return false
}
func buildSegmentWithSchema(node *Node, parentSchemaPath string, idx map[string]*NodeInfo) string {
currentSchemaPath := joinPath(parentSchemaPath, node.Tag)
if info, ok := idx[currentSchemaPath]; ok && info.Kind == KindList && info.ListKey != "" {
// Handle compound keys (space-separated) — try each part.
for _, keyPart := range strings.Fields(info.ListKey) {
for _, child := range node.Children {
if child.Tag == keyPart && child.Text != "" {
return fmt.Sprintf("%s[%s=%s]", node.Tag, keyPart, child.Text)
}
}
}
}
return buildSegment(node)
}
func isKeyChildWithSchema(child, parent *Node, parentOutputPath string, idx map[string]*NodeInfo) bool {
parentSchemaPath := outputPathToSchemaPath(parentOutputPath)
if info, ok := idx[parentSchemaPath]; ok && info.Kind == KindList && info.ListKey != "" {
for _, keyPart := range strings.Fields(info.ListKey) {
if child.Tag == keyPart {
return true
}
}
}
return false
}
func outputPathToSchemaPath(p string) string {
if p == "" {
return ""
}
segs := splitPathRespectingQuotes(p)
out := make([]string, 0, len(segs))
for i, seg := range segs {
tag, _, _ := parseSegment(seg)
if i == 0 && tag == "configuration" {
continue
}
if len(out) == 0 && tag == "groups" {
continue
}
if len(out) == 0 && tag == "name" {
continue
}
if tag != "" {
out = append(out, tag)
}
}
return strings.Join(out, "/")
}
// LeafMap flattens a *Node tree into a map of XPath-style paths to leaf text
// values. Only nodes with no children (true leaves) and non-empty text are
// included. Keyed list entries encode the key in the path segment so siblings
// with different keys are kept distinct:
//
// interfaces/interface[name=ge-0/0/0]/unit[name=0]/description → "uplink"
//
// Key elements themselves (e.g. <name>ge-0/0/0</name>) are NOT emitted as
// separate entries — they are encoded in the parent segment and cannot be
// independently patched (changing a key requires delete + create).
func LeafMap(root *Node) map[string]string {
result := make(map[string]string)
leafMapRecurse(root, "", result)
return result
}
func leafMapRecurse(node *Node, parentPath string, result map[string]string) {
segment := buildSegment(node)
var currentPath string
if parentPath == "" {
currentPath = segment
} else {
currentPath = parentPath + "/" + segment
}
// Leaf node — record it and stop recursing
if len(node.Children) == 0 {
if node.Text != "" {
result[currentPath] = node.Text
}
return
}
for _, child := range node.Children {
// Skip the key child — it is already encoded in the current segment.
// Emitting it separately would create spurious "delete key" operations
// whenever an ancestor list entry is modified.
if isKeyChild(child, node) {
continue
}
leafMapRecurse(child, currentPath, result)
}
}
// buildSegment returns "tag" for plain elements and "tag[keyName=keyValue]"
// for Junos YANG list entries whose first child is a recognised key element.
func buildSegment(node *Node) string {
if len(node.Children) > 0 {
first := node.Children[0]
if junosListKeys[first.Tag] && first.Text != "" {
return fmt.Sprintf("%s[%s=%s]", node.Tag, first.Tag, first.Text)
}
}
return node.Tag
}
// isKeyChild returns true when child is the key element of a YANG list entry.
// The convention in Junos-generated XML is that the key is always the first
// child of the list element, so we check both position and tag name.
func isKeyChild(child, parent *Node) bool {
if len(parent.Children) == 0 {
return false
}
return junosListKeys[child.Tag] &&
parent.Children[0] == child &&
child.Text != ""
}
@@ -1,914 +0,0 @@
package patch
import (
"strings"
"testing"
)
// matrixSchema covers all four YANG node types needed by the test matrix:
// - container: system, interfaces, policy-options, chassis, services
// - list: interface (key=name), unit (key=name), host (key=name), contents (key=name)
// - leaf: host-name, description, device-count, etc.
// - leaf-list: members
const matrixSchema = `{
"path": "",
"root": {
"children": [
{
"name": "configuration",
"type": "container",
"path": "",
"children": [
{
"name": "system",
"type": "container",
"path": "",
"children": [
{
"name": "host-name",
"type": "leaf",
"path": "system",
"leaf-type": "string"
},
{
"name": "syslog",
"type": "container",
"path": "system",
"children": [
{
"name": "host",
"type": "list",
"path": "system/syslog",
"key": "name",
"children": [
{
"name": "name",
"type": "leaf",
"path": "system/syslog/host",
"leaf-type": "string"
},
{
"name": "contents",
"type": "list",
"path": "system/syslog/host",
"key": "name",
"children": [
{
"name": "name",
"type": "leaf",
"path": "system/syslog/host/contents",
"leaf-type": "string"
},
{
"name": "any",
"type": "leaf",
"path": "system/syslog/host/contents",
"leaf-type": "empty"
},
{
"name": "notice",
"type": "leaf",
"path": "system/syslog/host/contents",
"leaf-type": "empty"
}
]
}
]
}
]
},
{
"name": "services",
"type": "container",
"path": "system",
"children": [
{
"name": "ssh",
"type": "container",
"path": "system/services",
"children": []
}
]
}
]
},
{
"name": "interfaces",
"type": "container",
"path": "",
"children": [
{
"name": "interface",
"type": "list",
"path": "interfaces",
"key": "name",
"children": [
{
"name": "name",
"type": "leaf",
"path": "interfaces/interface",
"leaf-type": "string"
},
{
"name": "description",
"type": "leaf",
"path": "interfaces/interface",
"leaf-type": "string"
},
{
"name": "unit",
"type": "list",
"path": "interfaces/interface",
"key": "name",
"children": [
{
"name": "name",
"type": "leaf",
"path": "interfaces/interface/unit",
"leaf-type": "string"
},
{
"name": "description",
"type": "leaf",
"path": "interfaces/interface/unit",
"leaf-type": "string"
}
]
}
]
}
]
},
{
"name": "policy-options",
"type": "container",
"path": "",
"children": [
{
"name": "community",
"type": "list",
"path": "policy-options",
"key": "name",
"children": [
{
"name": "name",
"type": "leaf",
"path": "policy-options/community",
"leaf-type": "string"
},
{
"name": "members",
"type": "leaf-list",
"path": "policy-options/community",
"leaf-type": "string"
}
]
}
]
},
{
"name": "chassis",
"type": "container",
"path": "",
"children": [
{
"name": "aggregated-devices",
"type": "container",
"path": "chassis",
"children": [
{
"name": "ethernet",
"type": "container",
"path": "chassis/aggregated-devices",
"children": [
{
"name": "device-count",
"type": "leaf",
"path": "chassis/aggregated-devices/ethernet",
"leaf-type": "string"
}
]
}
]
}
]
}
]
}
]
}
}`
func matrixIdx(t *testing.T) map[string]*NodeInfo {
t.Helper()
return mustIdxFromSchema(t, matrixSchema)
}
// ---------------------------------------------------------------------------
// 2.1 Leaf Operations
// ---------------------------------------------------------------------------
// L1 — Create leaf: add host-name to empty system container
func TestL1_CreateLeaf(t *testing.T) {
idx := matrixIdx(t)
stateXML := `<configuration><system></system></configuration>`
planXML := `<configuration><system><host-name>router1</host-name></system></configuration>`
stateMap := LeafMapWithSchema(mustTree(t, stateXML), idx)
planMap := LeafMapWithSchema(mustTree(t, planXML), idx)
diff := ComputeDiff(stateMap, planMap)
if len(diff) != 1 {
t.Fatalf("expected 1 diff entry, got %d: %v", len(diff), diff)
}
key := "configuration/system/host-name"
ch, ok := diff[key]
if !ok {
t.Fatalf("expected diff key %s, got %v", key, diff)
}
if ch.Op != Create {
t.Fatalf("expected Create, got %v", ch.Op)
}
if ch.NewVal != "router1" {
t.Fatalf("expected NewVal=router1, got %q", ch.NewVal)
}
patch, err := CreateDiffPatch(diff, "")
if err != nil {
t.Fatal(err)
}
patchStr := string(patch)
if !strings.Contains(patchStr, `nc:operation="merge"`) {
t.Fatalf("patch missing create operation:\n%s", patchStr)
}
if !strings.Contains(patchStr, ">router1<") {
t.Fatalf("patch missing value router1:\n%s", patchStr)
}
}
// L2 — Replace leaf: change host-name value
func TestL2_ReplaceLeaf(t *testing.T) {
idx := matrixIdx(t)
stateXML := `<configuration><system><host-name>router1</host-name></system></configuration>`
planXML := `<configuration><system><host-name>router2</host-name></system></configuration>`
stateMap := LeafMapWithSchema(mustTree(t, stateXML), idx)
planMap := LeafMapWithSchema(mustTree(t, planXML), idx)
diff := ComputeDiff(stateMap, planMap)
if len(diff) != 1 {
t.Fatalf("expected 1 diff entry, got %d", len(diff))
}
key := "configuration/system/host-name"
ch := diff[key]
if ch.Op != Replace {
t.Fatalf("expected Replace, got %v", ch.Op)
}
if ch.OldVal != "router1" || ch.NewVal != "router2" {
t.Fatalf("expected router1->router2, got %q->%q", ch.OldVal, ch.NewVal)
}
patch, err := CreateDiffPatch(diff, "")
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(patch), `nc:operation="replace"`) {
t.Fatalf("patch missing replace operation:\n%s", string(patch))
}
}
// L3 — Delete leaf: remove description from interface
func TestL3_DeleteLeaf(t *testing.T) {
idx := matrixIdx(t)
stateXML := `<configuration><interfaces><interface><name>ge-0/0/0</name><description>uplink</description></interface></interfaces></configuration>`
planXML := `<configuration><interfaces><interface><name>ge-0/0/0</name></interface></interfaces></configuration>`
stateMap := LeafMapWithSchema(mustTree(t, stateXML), idx)
planMap := LeafMapWithSchema(mustTree(t, planXML), idx)
diff := ComputeDiff(stateMap, planMap)
if len(diff) != 1 {
t.Fatalf("expected 1 diff entry, got %d: %v", len(diff), diff)
}
key := "configuration/interfaces/interface[name=ge-0/0/0]/description"
ch, ok := diff[key]
if !ok {
// Dump all keys for debugging
for k := range diff {
t.Logf("diff key: %s", k)
}
t.Fatalf("expected diff key %s", key)
}
if ch.Op != Delete {
t.Fatalf("expected Delete, got %v", ch.Op)
}
patch, err := CreateDiffPatch(diff, "")
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(patch), `nc:operation="delete"`) {
t.Fatalf("patch missing delete operation:\n%s", string(patch))
}
}
// L4 — Replace leaf with XML special characters
func TestL4_ReplaceLeafSpecialChars(t *testing.T) {
idx := matrixIdx(t)
stateXML := `<configuration><interfaces><interface><name>ge-0/0/0</name><description>old</description></interface></interfaces></configuration>`
planXML := `<configuration><interfaces><interface><name>ge-0/0/0</name><description>x&amp;y&lt;z&gt;w</description></interface></interfaces></configuration>`
stateMap := LeafMapWithSchema(mustTree(t, stateXML), idx)
planMap := LeafMapWithSchema(mustTree(t, planXML), idx)
diff := ComputeDiff(stateMap, planMap)
if len(diff) != 1 {
t.Fatalf("expected 1 diff entry, got %d", len(diff))
}
ch := diff["configuration/interfaces/interface[name=ge-0/0/0]/description"]
if ch.Op != Replace {
t.Fatalf("expected Replace, got %v", ch.Op)
}
// After XML parsing, the value should be the un-escaped form
if ch.NewVal != "x&y<z>w" {
t.Fatalf("expected un-escaped value x&y<z>w, got %q", ch.NewVal)
}
patch, err := CreateDiffPatch(diff, "")
if err != nil {
t.Fatal(err)
}
patchStr := string(patch)
// The output XML must re-escape the special chars
if !strings.Contains(patchStr, "&amp;") {
t.Fatalf("patch missing &amp; escape:\n%s", patchStr)
}
if !strings.Contains(patchStr, "&lt;") {
t.Fatalf("patch missing &lt; escape:\n%s", patchStr)
}
if !strings.Contains(patchStr, "&gt;") {
t.Fatalf("patch missing &gt; escape:\n%s", patchStr)
}
}
// L6 — No-op: same value produces empty diff
func TestL6_ReplaceLeafNoOp(t *testing.T) {
idx := matrixIdx(t)
xml := `<configuration><system><host-name>r1</host-name></system></configuration>`
stateMap := LeafMapWithSchema(mustTree(t, xml), idx)
planMap := LeafMapWithSchema(mustTree(t, xml), idx)
diff := ComputeDiff(stateMap, planMap)
if len(diff) != 0 {
t.Fatalf("expected empty diff for identical config, got %d entries: %v", len(diff), diff)
}
}
// ---------------------------------------------------------------------------
// 2.2 Leaf-List Operations
// ---------------------------------------------------------------------------
// LL1 — Add entry to leaf-list
func TestLL1_AddLeafListEntry(t *testing.T) {
idx := matrixIdx(t)
stateXML := `<configuration><policy-options><community><name>my-comm</name><members>target:65000:100</members></community></policy-options></configuration>`
planXML := `<configuration><policy-options><community><name>my-comm</name><members>target:65000:100</members><members>target:65000:200</members></community></policy-options></configuration>`
stateMap := LeafMapWithSchema(mustTree(t, stateXML), idx)
planMap := LeafMapWithSchema(mustTree(t, planXML), idx)
diff := ComputeDiff(stateMap, planMap)
if len(diff) != 1 {
t.Fatalf("expected 1 diff entry, got %d: %v", len(diff), diff)
}
for path, ch := range diff {
if ch.Op != Create {
t.Errorf("expected Create, got %v for %s", ch.Op, path)
}
if !strings.Contains(path, "members[value=target:65000:200]") {
t.Errorf("unexpected path: %s", path)
}
}
patch, err := CreateDiffPatch(diff, "")
if err != nil {
t.Fatal(err)
}
patchStr := string(patch)
if !strings.Contains(patchStr, `nc:operation="merge"`) {
t.Errorf("patch missing create operation:\n%s", patchStr)
}
if !strings.Contains(patchStr, "target:65000:200") {
t.Errorf("patch missing new member value:\n%s", patchStr)
}
}
// LL2 — Remove entry from leaf-list
func TestLL2_RemoveLeafListEntry(t *testing.T) {
idx := matrixIdx(t)
stateXML := `<configuration><policy-options><community><name>my-comm</name><members>target:65000:100</members><members>target:65000:200</members></community></policy-options></configuration>`
planXML := `<configuration><policy-options><community><name>my-comm</name><members>target:65000:100</members></community></policy-options></configuration>`
stateMap := LeafMapWithSchema(mustTree(t, stateXML), idx)
planMap := LeafMapWithSchema(mustTree(t, planXML), idx)
diff := ComputeDiff(stateMap, planMap)
if len(diff) != 1 {
t.Fatalf("expected 1 diff, got %d: %v", len(diff), diff)
}
for path, ch := range diff {
if ch.Op != Delete {
t.Errorf("expected Delete, got %v for %s", ch.Op, path)
}
if !strings.Contains(path, "members[value=target:65000:200]") {
t.Errorf("unexpected path: %s", path)
}
}
}
// LL3 — Replace entry in leaf-list (delete old + create new)
func TestLL3_ReplaceLeafListEntry(t *testing.T) {
idx := matrixIdx(t)
stateXML := `<configuration><policy-options><community><name>my-comm</name><members>a</members><members>b</members></community></policy-options></configuration>`
planXML := `<configuration><policy-options><community><name>my-comm</name><members>a</members><members>c</members></community></policy-options></configuration>`
stateMap := LeafMapWithSchema(mustTree(t, stateXML), idx)
planMap := LeafMapWithSchema(mustTree(t, planXML), idx)
diff := ComputeDiff(stateMap, planMap)
if len(diff) != 2 {
t.Fatalf("expected 2 diff entries (delete b + create c), got %d: %v", len(diff), diff)
}
deletePath := "configuration/policy-options/community[name=my-comm]/members[value=b]"
createPath := "configuration/policy-options/community[name=my-comm]/members[value=c]"
if ch, ok := diff[deletePath]; !ok || ch.Op != Delete {
t.Fatalf("expected Delete for %s, got %v", deletePath, diff)
}
if ch, ok := diff[createPath]; !ok || ch.Op != Create {
t.Fatalf("expected Create for %s, got %v", createPath, diff)
}
}
// LL4 — Reorder leaf-list produces no diff (set semantics)
func TestLL4_ReorderLeafListNoOp(t *testing.T) {
idx := matrixIdx(t)
stateXML := `<configuration><policy-options><community><name>my-comm</name><members>b</members><members>a</members></community></policy-options></configuration>`
planXML := `<configuration><policy-options><community><name>my-comm</name><members>a</members><members>b</members></community></policy-options></configuration>`
stateMap := LeafMapWithSchema(mustTree(t, stateXML), idx)
planMap := LeafMapWithSchema(mustTree(t, planXML), idx)
diff := ComputeDiff(stateMap, planMap)
if len(diff) != 0 {
t.Fatalf("expected empty diff for reordered leaf-list, got %d: %v", len(diff), diff)
}
}
// LL5 — Delete all leaf-list entries
func TestLL5_DeleteAllLeafListEntries(t *testing.T) {
idx := matrixIdx(t)
stateXML := `<configuration><policy-options><community><name>my-comm</name><members>a</members><members>b</members></community></policy-options></configuration>`
planXML := `<configuration><policy-options><community><name>my-comm</name></community></policy-options></configuration>`
stateMap := LeafMapWithSchema(mustTree(t, stateXML), idx)
planMap := LeafMapWithSchema(mustTree(t, planXML), idx)
diff := ComputeDiff(stateMap, planMap)
if len(diff) != 2 {
t.Fatalf("expected 2 deletes, got %d: %v", len(diff), diff)
}
for _, ch := range diff {
if ch.Op != Delete {
t.Errorf("expected Delete, got %v", ch.Op)
}
}
}
// LL6 — Create leaf-list from scratch
func TestLL6_CreateLeafListFromScratch(t *testing.T) {
idx := matrixIdx(t)
stateXML := `<configuration><policy-options><community><name>my-comm</name></community></policy-options></configuration>`
planXML := `<configuration><policy-options><community><name>my-comm</name><members>x</members><members>y</members></community></policy-options></configuration>`
stateMap := LeafMapWithSchema(mustTree(t, stateXML), idx)
planMap := LeafMapWithSchema(mustTree(t, planXML), idx)
diff := ComputeDiff(stateMap, planMap)
if len(diff) != 2 {
t.Fatalf("expected 2 creates, got %d: %v", len(diff), diff)
}
for _, ch := range diff {
if ch.Op != Create {
t.Errorf("expected Create, got %v", ch.Op)
}
}
}
// ---------------------------------------------------------------------------
// 2.3 List Operations
// ---------------------------------------------------------------------------
// K1 — Add new list entry
func TestK1_AddListEntry(t *testing.T) {
idx := matrixIdx(t)
stateXML := `<configuration><interfaces><interface><name>ge-0/0/0</name><description>uplink</description></interface></interfaces></configuration>`
planXML := `<configuration><interfaces><interface><name>ge-0/0/0</name><description>uplink</description></interface><interface><name>ge-0/0/1</name><description>downlink</description></interface></interfaces></configuration>`
stateMap := LeafMapWithSchema(mustTree(t, stateXML), idx)
planMap := LeafMapWithSchema(mustTree(t, planXML), idx)
diff := ComputeDiff(stateMap, planMap)
// New entry creates: name (promoted to entry operation) + description
if len(diff) != 2 {
t.Fatalf("expected 2 diff entries for new list entry, got %d: %v", len(diff), diff)
}
for _, ch := range diff {
if ch.Op != Create {
t.Errorf("expected Create, got %v", ch.Op)
}
}
patch, err := CreateDiffPatch(diff, "")
if err != nil {
t.Fatal(err)
}
patchStr := string(patch)
// The key leaf create should promote nc:operation to the parent <interface>
if !strings.Contains(patchStr, `interface nc:operation="merge"`) {
t.Fatalf("expected nc:operation on interface entry, got:\n%s", patchStr)
}
}
// K2 — Delete list entry
func TestK2_DeleteListEntry(t *testing.T) {
idx := matrixIdx(t)
stateXML := `<configuration><interfaces><interface><name>ge-0/0/0</name><description>uplink</description></interface><interface><name>ge-0/0/1</name><description>downlink</description></interface></interfaces></configuration>`
planXML := `<configuration><interfaces><interface><name>ge-0/0/0</name><description>uplink</description></interface></interfaces></configuration>`
stateMap := LeafMapWithSchema(mustTree(t, stateXML), idx)
planMap := LeafMapWithSchema(mustTree(t, planXML), idx)
diff := ComputeDiff(stateMap, planMap)
for _, ch := range diff {
if ch.Op != Delete {
t.Errorf("expected Delete, got %v", ch.Op)
}
}
patch, err := CreateDiffPatch(diff, "")
if err != nil {
t.Fatal(err)
}
patchStr := string(patch)
if !strings.Contains(patchStr, `interface nc:operation="delete"`) {
t.Fatalf("expected nc:operation on interface entry, got:\n%s", patchStr)
}
}
// K3 — Rename list key (delete old + create new)
func TestK3_RenameListKey(t *testing.T) {
idx := matrixIdx(t)
stateXML := `<configuration><interfaces><interface><name>ge-0/0/0</name><description>link</description></interface></interfaces></configuration>`
planXML := `<configuration><interfaces><interface><name>ge-0/0/1</name><description>link</description></interface></interfaces></configuration>`
stateMap := LeafMapWithSchema(mustTree(t, stateXML), idx)
planMap := LeafMapWithSchema(mustTree(t, planXML), idx)
diff := ComputeDiff(stateMap, planMap)
// Should produce deletes for old entry + creates for new entry
hasDelete := false
hasCreate := false
for _, ch := range diff {
if ch.Op == Delete {
hasDelete = true
}
if ch.Op == Create {
hasCreate = true
}
}
if !hasDelete || !hasCreate {
t.Fatalf("expected both Delete and Create for key rename, got: %v", diff)
}
}
// K4 — Modify leaf inside list entry
func TestK4_ModifyLeafInListEntry(t *testing.T) {
idx := matrixIdx(t)
stateXML := `<configuration><interfaces><interface><name>ge-0/0/0</name><unit><name>0</name><description>old</description></unit></interface></interfaces></configuration>`
planXML := `<configuration><interfaces><interface><name>ge-0/0/0</name><unit><name>0</name><description>new</description></unit></interface></interfaces></configuration>`
stateMap := LeafMapWithSchema(mustTree(t, stateXML), idx)
planMap := LeafMapWithSchema(mustTree(t, planXML), idx)
diff := ComputeDiff(stateMap, planMap)
if len(diff) != 1 {
t.Fatalf("expected 1 diff, got %d: %v", len(diff), diff)
}
key := "configuration/interfaces/interface[name=ge-0/0/0]/unit[name=0]/description"
ch, ok := diff[key]
if !ok {
for k := range diff {
t.Logf("diff key: %s", k)
}
t.Fatalf("expected diff key %s", key)
}
if ch.Op != Replace {
t.Fatalf("expected Replace, got %v", ch.Op)
}
}
// K5 — Add leaf inside existing list entry
func TestK5_AddLeafInListEntry(t *testing.T) {
idx := matrixIdx(t)
stateXML := `<configuration><interfaces><interface><name>ge-0/0/0</name><unit><name>0</name></unit></interface></interfaces></configuration>`
planXML := `<configuration><interfaces><interface><name>ge-0/0/0</name><unit><name>0</name><description>new</description></unit></interface></interfaces></configuration>`
stateMap := LeafMapWithSchema(mustTree(t, stateXML), idx)
planMap := LeafMapWithSchema(mustTree(t, planXML), idx)
diff := ComputeDiff(stateMap, planMap)
if len(diff) != 1 {
t.Fatalf("expected 1 diff, got %d: %v", len(diff), diff)
}
for _, ch := range diff {
if ch.Op != Create {
t.Errorf("expected Create, got %v", ch.Op)
}
}
}
// K6 — Delete leaf inside list entry
func TestK6_DeleteLeafInListEntry(t *testing.T) {
idx := matrixIdx(t)
stateXML := `<configuration><interfaces><interface><name>ge-0/0/0</name><unit><name>0</name><description>old</description></unit></interface></interfaces></configuration>`
planXML := `<configuration><interfaces><interface><name>ge-0/0/0</name><unit><name>0</name></unit></interface></interfaces></configuration>`
stateMap := LeafMapWithSchema(mustTree(t, stateXML), idx)
planMap := LeafMapWithSchema(mustTree(t, planXML), idx)
diff := ComputeDiff(stateMap, planMap)
if len(diff) != 1 {
t.Fatalf("expected 1 diff, got %d: %v", len(diff), diff)
}
for _, ch := range diff {
if ch.Op != Delete {
t.Errorf("expected Delete, got %v", ch.Op)
}
}
}
// K8 — Add entry in nested list (host/contents)
func TestK8_AddNestedListEntry(t *testing.T) {
idx := matrixIdx(t)
stateXML := `<configuration><system><syslog><host><name>log</name><contents><name>any</name><notice/></contents></host></syslog></system></configuration>`
planXML := `<configuration><system><syslog><host><name>log</name><contents><name>any</name><notice/></contents><contents><name>kernel</name><any/></contents></host></syslog></system></configuration>`
stateMap := LeafMapWithSchema(mustTree(t, stateXML), idx)
planMap := LeafMapWithSchema(mustTree(t, planXML), idx)
diff := ComputeDiff(stateMap, planMap)
// New contents entry: name (key, promoted) + any (empty leaf)
hasCreate := false
for _, ch := range diff {
if ch.Op == Create {
hasCreate = true
}
}
if !hasCreate {
t.Fatalf("expected Create operations for new nested list entry, got: %v", diff)
}
}
// K10 — Key-only list entry (structural)
func TestK10_KeyOnlyListEntry(t *testing.T) {
idx := matrixIdx(t)
stateXML := `<configuration><system><syslog></syslog></system></configuration>`
planXML := `<configuration><system><syslog><host><name>log</name></host></syslog></system></configuration>`
stateMap := LeafMapWithSchema(mustTree(t, stateXML), idx)
planMap := LeafMapWithSchema(mustTree(t, planXML), idx)
diff := ComputeDiff(stateMap, planMap)
if len(diff) != 1 {
t.Fatalf("expected 1 diff for key-only entry, got %d: %v", len(diff), diff)
}
key := "configuration/system/syslog/host[name=log]/name"
ch, ok := diff[key]
if !ok {
for k := range diff {
t.Logf("diff key: %s", k)
}
t.Fatalf("expected diff key %s", key)
}
if ch.Op != Create {
t.Fatalf("expected Create, got %v", ch.Op)
}
}
// ---------------------------------------------------------------------------
// 2.4 Container Operations
// ---------------------------------------------------------------------------
// C1 — Create container with children
func TestC1_CreateContainer(t *testing.T) {
idx := matrixIdx(t)
stateXML := `<configuration></configuration>`
planXML := `<configuration><chassis><aggregated-devices><ethernet><device-count>24</device-count></ethernet></aggregated-devices></chassis></configuration>`
stateMap := LeafMapWithSchema(mustTree(t, stateXML), idx)
planMap := LeafMapWithSchema(mustTree(t, planXML), idx)
diff := ComputeDiff(stateMap, planMap)
if len(diff) != 1 {
t.Fatalf("expected 1 Create for device-count leaf, got %d: %v", len(diff), diff)
}
for _, ch := range diff {
if ch.Op != Create {
t.Errorf("expected Create, got %v", ch.Op)
}
}
}
// C3 — Empty presence container (e.g., <ssh/>)
func TestC3_EmptyContainer(t *testing.T) {
idx := matrixIdx(t)
stateXML := `<configuration><system><services></services></system></configuration>`
planXML := `<configuration><system><services><ssh/></services></system></configuration>`
stateMap := LeafMapWithSchema(mustTree(t, stateXML), idx)
planMap := LeafMapWithSchema(mustTree(t, planXML), idx)
diff := ComputeDiff(stateMap, planMap)
// An empty container like <ssh/> has no text and no children, so it
// produces no leaf map entries. The diff should be empty because
// LeafMapWithSchema only tracks leaf values.
// This is a known limitation: presence containers need special handling.
// For now, verify the leaf maps are consistent.
t.Logf("state map: %v", stateMap)
t.Logf("plan map: %v", planMap)
t.Logf("diff: %v", diff)
// Both maps should be empty (no leaves under services or ssh)
// This documents the current behavior — empty containers don't produce diffs.
// The provider handles this via the full-config SendDirectTransaction path.
}
// C5 — Modify children within container
func TestC5_ModifyChildrenInContainer(t *testing.T) {
idx := matrixIdx(t)
stateXML := `<configuration><system><host-name>old</host-name></system></configuration>`
planXML := `<configuration><system><host-name>new</host-name></system></configuration>`
stateMap := LeafMapWithSchema(mustTree(t, stateXML), idx)
planMap := LeafMapWithSchema(mustTree(t, planXML), idx)
diff := ComputeDiff(stateMap, planMap)
if len(diff) != 1 {
t.Fatalf("expected 1 Replace, got %d: %v", len(diff), diff)
}
ch := diff["configuration/system/host-name"]
if ch.Op != Replace {
t.Fatalf("expected Replace, got %v", ch.Op)
}
}
// ---------------------------------------------------------------------------
// 2.5 Compound / Cross-Type Operations
// ---------------------------------------------------------------------------
// M1 — Mixed operations: replace + create + delete in one diff
func TestM1_MixedOperations(t *testing.T) {
idx := matrixIdx(t)
stateXML := `<configuration>
<system><host-name>r1</host-name></system>
<interfaces>
<interface><name>ge-0/0/0</name><description>old-desc</description></interface>
</interfaces>
<policy-options>
<community><name>comm1</name><members>target:65000:100</members><members>target:65000:300</members></community>
</policy-options>
</configuration>`
planXML := `<configuration>
<system><host-name>r2</host-name></system>
<interfaces>
<interface><name>ge-0/0/0</name><description>old-desc</description></interface>
<interface><name>ge-0/0/2</name><description>new-link</description></interface>
</interfaces>
<policy-options>
<community><name>comm1</name><members>target:65000:100</members></community>
</policy-options>
</configuration>`
stateMap := LeafMapWithSchema(mustTree(t, stateXML), idx)
planMap := LeafMapWithSchema(mustTree(t, planXML), idx)
diff := ComputeDiff(stateMap, planMap)
// Expected changes:
// 1. Replace system/host-name r1 -> r2
// 2. Delete members[value=target:65000:300]
// 3. Create interface[name=ge-0/0/2]/name (promoted)
// 4. Create interface[name=ge-0/0/2]/description
hasReplace := false
hasDelete := false
hasCreate := false
for _, ch := range diff {
switch ch.Op {
case Replace:
hasReplace = true
case Delete:
hasDelete = true
case Create:
hasCreate = true
}
}
if !hasReplace || !hasDelete || !hasCreate {
t.Fatalf("expected Replace+Delete+Create, got: %v", diff)
}
// Verify ordering: deletes first, then replacements, then creates
patch, err := CreateDiffPatch(diff, "")
if err != nil {
t.Fatal(err)
}
patchStr := string(patch)
deleteIdx := strings.Index(patchStr, `"delete"`)
replaceIdx := strings.Index(patchStr, `"replace"`)
createIdx := strings.Index(patchStr, `"merge"`)
if deleteIdx < 0 || replaceIdx < 0 || createIdx < 0 {
t.Fatalf("patch missing expected operations:\n%s", patchStr)
}
if deleteIdx > replaceIdx {
t.Errorf("delete should come before replace in patch output")
}
if replaceIdx > createIdx {
t.Errorf("replace should come before create in patch output")
}
}
// M2 — Deep nesting: 4+ levels (system/syslog/host/contents/notice)
func TestM2_DeepNesting(t *testing.T) {
idx := matrixIdx(t)
stateXML := `<configuration><system><syslog><host><name>log</name><contents><name>any</name></contents></host></syslog></system></configuration>`
planXML := `<configuration><system><syslog><host><name>log</name><contents><name>any</name><notice/></contents></host></syslog></system></configuration>`
stateMap := LeafMapWithSchema(mustTree(t, stateXML), idx)
planMap := LeafMapWithSchema(mustTree(t, planXML), idx)
diff := ComputeDiff(stateMap, planMap)
// The <notice/> is an empty leaf, so in LeafMapWithSchema it may or may
// not produce a leaf map entry (depends on how empty leaves are handled).
t.Logf("state map: %v", stateMap)
t.Logf("plan map: %v", planMap)
t.Logf("diff: %v", diff)
// At minimum, the key-only entries should be consistent
stateKey := "configuration/system/syslog/host[name=log]/contents[name=any]/name"
if _, ok := stateMap[stateKey]; ok {
// If contents has children beyond key, key should not be in map
t.Logf("contents key is in state map (structural key behavior)")
}
}
@@ -1,192 +0,0 @@
package patch
import "sort"
// AlignXMLOrderToReference reorders current XML siblings to follow the order in
// the reference XML where possible, while keeping a deterministic fallback
// ordering for entries absent from the reference.
func AlignXMLOrderToReference(currentXML []byte, referenceXML []byte, idx map[string]*NodeInfo) ([]byte, error) {
currentTree, err := BuildTree(currentXML)
if err != nil {
return nil, err
}
var referenceTree *Node
if len(referenceXML) > 0 {
referenceTree, err = BuildTree(referenceXML)
if err != nil {
return nil, err
}
}
alignNodeOrder(currentTree, referenceSiblingOrders(referenceTree, idx), idx, currentTree.Tag)
return marshalNodeTree(currentTree)
}
func alignNodeOrder(node *Node, ref map[string]map[string]int, idx map[string]*NodeInfo, instancePath string) {
if len(node.Children) == 0 {
return
}
referenceOrder := ref[instancePath]
sort.SliceStable(node.Children, func(i, j int) bool {
left := childSortKey(node.Children[i], instancePath, referenceOrder, idx)
right := childSortKey(node.Children[j], instancePath, referenceOrder, idx)
if left.hasReference != right.hasReference {
return left.hasReference
}
if left.referenceRank != right.referenceRank {
return left.referenceRank < right.referenceRank
}
if left.tag != right.tag {
return left.tag < right.tag
}
if left.identity != right.identity {
return left.identity < right.identity
}
return left.text < right.text
})
for _, child := range node.Children {
childInstance := instanceIdentity(child, instancePath, idx)
alignNodeOrder(child, ref, idx, childInstance)
}
}
type sortKey struct {
hasReference bool
referenceRank int
tag string
identity string
text string
}
func childSortKey(child *Node, parentInstancePath string, referenceOrder map[string]int, idx map[string]*NodeInfo) sortKey {
identity := nodeIdentity(child, parentInstancePath, idx)
rank, ok := referenceOrder[identity]
if !ok {
rank = 1 << 30
}
return sortKey{
hasReference: ok,
referenceRank: rank,
tag: child.Tag,
identity: identity,
text: child.Text,
}
}
func referenceSiblingOrders(root *Node, idx map[string]*NodeInfo) map[string]map[string]int {
orders := make(map[string]map[string]int)
if root == nil {
return orders
}
var walk func(node *Node, instancePath string)
walk = func(node *Node, instancePath string) {
if _, ok := orders[instancePath]; !ok {
orders[instancePath] = make(map[string]int)
}
for i, child := range node.Children {
identity := nodeIdentity(child, instancePath, idx)
if _, seen := orders[instancePath][identity]; !seen {
orders[instancePath][identity] = i
}
childInstance := instanceIdentity(child, instancePath, idx)
walk(child, childInstance)
}
}
walk(root, root.Tag)
return orders
}
// nodeIdentity returns a short identity string for sorting siblings under the
// same parent. It does NOT include the parent path.
func nodeIdentity(node *Node, parentInstancePath string, idx map[string]*NodeInfo) string {
schPath := schemaPathFromInstance(parentInstancePath, node.Tag)
info := idx[schPath]
if info == nil {
if node.Text != "" {
return node.Tag + "=" + node.Text
}
return node.Tag
}
switch info.Kind {
case KindList:
keyVal := findKeyChild(node, info.ListKey)
if keyVal != "" {
return node.Tag + "[" + info.ListKey + "=" + keyVal + "]"
}
case KindLeafList:
return node.Tag + "[value=" + node.Text + "]"
}
if node.Text != "" {
return node.Tag + "=" + node.Text
}
return node.Tag
}
// instanceIdentity returns a full instance-aware path for a child node,
// including keyed-list identity so that different list entries get distinct
// ordering buckets.
func instanceIdentity(child *Node, parentInstancePath string, idx map[string]*NodeInfo) string {
base := parentInstancePath + "/" + child.Tag
schPath := schemaPathFromInstance(parentInstancePath, child.Tag)
info := idx[schPath]
if info != nil && info.Kind == KindList && info.ListKey != "" {
keyVal := findKeyChild(child, info.ListKey)
if keyVal != "" {
return base + "[" + info.ListKey + "=" + keyVal + "]"
}
}
return base
}
// schemaPathFromInstance extracts the schema path for a child tag given an
// instance-aware parent path. It strips keyed-list predicates like
// "host[name=log]" back to "host" so the result matches schema index keys.
func schemaPathFromInstance(parentInstancePath string, childTag string) string {
return schemaPath(joinXMLPath(stripInstancePredicates(parentInstancePath), childTag))
}
// stripInstancePredicates removes [key=value] predicates from every segment
// of an instance path, returning the plain XML element path.
func stripInstancePredicates(path string) string {
var out []byte
inBracket := false
for i := 0; i < len(path); i++ {
if path[i] == '[' {
inBracket = true
continue
}
if path[i] == ']' {
inBracket = false
continue
}
if !inBracket {
out = append(out, path[i])
}
}
return string(out)
}
func joinXMLPath(parentPath string, tag string) string {
if parentPath == "" {
return tag
}
return parentPath + "/" + tag
}
func schemaPath(path string) string {
path = normalizePath(path)
if path == "configuration" {
return ""
}
return normalizePath(path)
}
@@ -1,265 +0,0 @@
package patch
import (
"strings"
"testing"
)
func TestAlignXMLOrderToReference_ReordersKeyedListsAndLeafLists(t *testing.T) {
idx := mustIdxFromSchema(t, testTrimmedSchema)
current := []byte(`<configuration>
<foo>
<members>c</members>
<members>a</members>
<item><address>10.0.0.2</address><value>beta</value></item>
<item><address>10.0.0.1</address><value>alpha</value></item>
</foo>
</configuration>`)
reference := []byte(`<configuration>
<foo>
<item><address>10.0.0.1</address><value>alpha</value></item>
<item><address>10.0.0.2</address><value>beta</value></item>
<members>a</members>
<members>c</members>
</foo>
</configuration>`)
aligned, err := AlignXMLOrderToReference(current, reference, idx)
if err != nil {
t.Fatalf("AlignXMLOrderToReference() error: %v", err)
}
got := string(aligned)
if strings.Index(got, "10.0.0.1") > strings.Index(got, "10.0.0.2") {
t.Fatalf("expected keyed list entries to follow reference order, got %s", got)
}
if strings.Index(got, ">a</members>") > strings.Index(got, ">c</members>") {
t.Fatalf("expected leaf-list values to follow reference order, got %s", got)
}
}
func TestAlignXMLOrderToReference_UsesDeterministicFallbackWhenReferenceEmpty(t *testing.T) {
idx := mustIdxFromSchema(t, testTrimmedSchema)
current := []byte(`<configuration>
<foo>
<item><address>10.0.0.2</address><value>beta</value></item>
<item><address>10.0.0.1</address><value>alpha</value></item>
</foo>
</configuration>`)
aligned, err := AlignXMLOrderToReference(current, nil, idx)
if err != nil {
t.Fatalf("AlignXMLOrderToReference() error: %v", err)
}
got := string(aligned)
if strings.Index(got, "10.0.0.1") > strings.Index(got, "10.0.0.2") {
t.Fatalf("expected deterministic fallback ordering by keyed identity, got %s", got)
}
}
// ---------------------------------------------------------------------------
// Matrix Order Tests — O1-O6
// ---------------------------------------------------------------------------
// O1 — Top-level list reorder: [B,A,C] → reference [A,B,C]
func TestAlignOrder_TopLevelListReorder(t *testing.T) {
idx := matrixIdx(t)
current := []byte(`<configuration><interfaces>
<interface><name>ge-0/0/2</name><description>c</description></interface>
<interface><name>ge-0/0/0</name><description>a</description></interface>
<interface><name>ge-0/0/1</name><description>b</description></interface>
</interfaces></configuration>`)
reference := []byte(`<configuration><interfaces>
<interface><name>ge-0/0/0</name><description>a</description></interface>
<interface><name>ge-0/0/1</name><description>b</description></interface>
<interface><name>ge-0/0/2</name><description>c</description></interface>
</interfaces></configuration>`)
aligned, err := AlignXMLOrderToReference(current, reference, idx)
if err != nil {
t.Fatal(err)
}
got := string(aligned)
idx0 := strings.Index(got, "ge-0/0/0")
idx1 := strings.Index(got, "ge-0/0/1")
idx2 := strings.Index(got, "ge-0/0/2")
if idx0 > idx1 || idx1 > idx2 {
t.Fatalf("expected order ge-0/0/0, ge-0/0/1, ge-0/0/2, got:\n%s", got)
}
}
// O2 — Nested list reorder: each parent entry has independent child ordering
func TestAlignOrder_NestedListPerInstanceReorder(t *testing.T) {
idx := mustIdxFromSchema(t, testStructuralKeyTrimmedSchema)
current := []byte(`<configuration><system><syslog>
<file><name>security</name>
<contents><name>kernel</name><any/></contents>
<contents><name>interactive-commands</name><any/></contents>
</file>
<file><name>messages</name>
<contents><name>interactive-commands</name><any/></contents>
<contents><name>kernel</name><any/></contents>
</file>
</syslog></system></configuration>`)
reference := []byte(`<configuration><system><syslog>
<file><name>security</name>
<contents><name>interactive-commands</name><any/></contents>
<contents><name>kernel</name><any/></contents>
</file>
<file><name>messages</name>
<contents><name>kernel</name><any/></contents>
<contents><name>interactive-commands</name><any/></contents>
</file>
</syslog></system></configuration>`)
aligned, err := AlignXMLOrderToReference(current, reference, idx)
if err != nil {
t.Fatal(err)
}
got := string(aligned)
// Split output at "messages" to get the two file sections
msgIdx := strings.Index(got, "<name>messages</name>")
if msgIdx < 0 {
t.Fatalf("expected messages file in output:\n%s", got)
}
secSection := got[:msgIdx]
msgSection := got[msgIdx:]
// In file[security], interactive-commands should come before kernel
icIdx := strings.Index(secSection, "interactive-commands")
kerIdx := strings.Index(secSection, "kernel")
if icIdx < 0 || kerIdx < 0 {
t.Fatalf("file[security]: missing expected contents entries in:\n%s", secSection)
}
if icIdx > kerIdx {
t.Errorf("file[security]: expected interactive-commands before kernel, got:\n%s", secSection)
}
// In file[messages], kernel should come before interactive-commands
icIdx2 := strings.Index(msgSection, "interactive-commands")
kerIdx2 := strings.Index(msgSection, "kernel")
if icIdx2 < 0 || kerIdx2 < 0 {
t.Fatalf("file[messages]: missing expected contents entries in:\n%s", msgSection)
}
if kerIdx2 > icIdx2 {
t.Errorf("file[messages]: expected kernel before interactive-commands, got:\n%s", msgSection)
}
}
// O3 — Extra entries in current (not in reference) sort after reference entries
func TestAlignOrder_ExtraEntriesInCurrent(t *testing.T) {
idx := matrixIdx(t)
current := []byte(`<configuration><interfaces>
<interface><name>ge-0/0/3</name><description>extra-d</description></interface>
<interface><name>ge-0/0/1</name><description>b</description></interface>
<interface><name>ge-0/0/0</name><description>a</description></interface>
<interface><name>ge-0/0/2</name><description>extra-c</description></interface>
</interfaces></configuration>`)
reference := []byte(`<configuration><interfaces>
<interface><name>ge-0/0/1</name><description>b</description></interface>
<interface><name>ge-0/0/0</name><description>a</description></interface>
</interfaces></configuration>`)
aligned, err := AlignXMLOrderToReference(current, reference, idx)
if err != nil {
t.Fatal(err)
}
got := string(aligned)
// Reference entries first in ref order: ge-0/0/1, ge-0/0/0
// Then extras sorted deterministically: ge-0/0/2, ge-0/0/3
idx1 := strings.Index(got, "ge-0/0/1")
idx0 := strings.Index(got, "ge-0/0/0")
idx2 := strings.Index(got, "ge-0/0/2")
idx3 := strings.Index(got, "ge-0/0/3")
if idx1 > idx0 {
t.Errorf("expected ge-0/0/1 before ge-0/0/0 (reference order), got:\n%s", got)
}
if idx0 > idx2 || idx0 > idx3 {
t.Errorf("expected reference entries before extras, got:\n%s", got)
}
}
// O4 — Missing entry in current (reference has entry current lacks)
func TestAlignOrder_MissingEntryInCurrent(t *testing.T) {
idx := matrixIdx(t)
current := []byte(`<configuration><interfaces>
<interface><name>ge-0/0/2</name><description>c</description></interface>
<interface><name>ge-0/0/0</name><description>a</description></interface>
</interfaces></configuration>`)
reference := []byte(`<configuration><interfaces>
<interface><name>ge-0/0/0</name><description>a</description></interface>
<interface><name>ge-0/0/1</name><description>b</description></interface>
<interface><name>ge-0/0/2</name><description>c</description></interface>
</interfaces></configuration>`)
aligned, err := AlignXMLOrderToReference(current, reference, idx)
if err != nil {
t.Fatal(err)
}
got := string(aligned)
// ge-0/0/0 should come before ge-0/0/2 (respecting reference order)
idx0 := strings.Index(got, "ge-0/0/0")
idx2 := strings.Index(got, "ge-0/0/2")
if idx0 > idx2 {
t.Errorf("expected ge-0/0/0 before ge-0/0/2, got:\n%s", got)
}
// Missing ge-0/0/1 should not crash or appear
if strings.Contains(got, "ge-0/0/1") {
t.Errorf("missing reference entry ge-0/0/1 should not appear in output")
}
}
// O5 — Leaf-list reorder produces no diff (set semantics via LeafMapWithSchema)
func TestAlignOrder_LeafListSetSemantics(t *testing.T) {
idx := matrixIdx(t)
stateXML := `<configuration><policy-options><community><name>comm</name><members>b</members><members>a</members></community></policy-options></configuration>`
planXML := `<configuration><policy-options><community><name>comm</name><members>a</members><members>b</members></community></policy-options></configuration>`
stateMap := LeafMapWithSchema(mustTree(t, stateXML), idx)
planMap := LeafMapWithSchema(mustTree(t, planXML), idx)
diff := ComputeDiff(stateMap, planMap)
if len(diff) != 0 {
t.Fatalf("leaf-list reorder should produce empty diff (set semantics), got %d: %v", len(diff), diff)
}
}
// O6 — Empty reference: deterministic fallback by identity
func TestAlignOrder_EmptyReference(t *testing.T) {
idx := matrixIdx(t)
current := []byte(`<configuration><interfaces>
<interface><name>ge-0/0/2</name><description>c</description></interface>
<interface><name>ge-0/0/0</name><description>a</description></interface>
<interface><name>ge-0/0/1</name><description>b</description></interface>
</interfaces></configuration>`)
aligned, err := AlignXMLOrderToReference(current, nil, idx)
if err != nil {
t.Fatal(err)
}
got := string(aligned)
// With empty reference, should sort by key identity: ge-0/0/0, ge-0/0/1, ge-0/0/2
idx0 := strings.Index(got, "ge-0/0/0")
idx1 := strings.Index(got, "ge-0/0/1")
idx2 := strings.Index(got, "ge-0/0/2")
if idx0 > idx1 || idx1 > idx2 {
t.Fatalf("expected deterministic fallback order ge-0/0/0, ge-0/0/1, ge-0/0/2, got:\n%s", got)
}
}
@@ -1,414 +0,0 @@
package patch
import (
"bytes"
"fmt"
"sort"
"strings"
)
// CreateDiffPatch builds the Junos NETCONF <configuration> XML body from the
// diff map, targeting base configuration paths directly.
//
// The nc:operation attributes written here reference the xmlns:nc declaration
// that sendNetconfPatch places on the enclosing <config> element — no
// additional namespace declaration is required in this output.
//
// Example output for a single Replace:
//
// <configuration>
// <interfaces>
// <interface>
// <name>ge-0/0/0</name>
// <unit>
// <name>0</name>
// <description nc:operation="replace">new-desc</description>
// </unit>
// </interface>
// </interfaces>
// </configuration>
func CreateDiffPatch(diffMap map[string]Change, groupName string) ([]byte, error) {
return CreateDiffPatchWithSchema(diffMap, groupName, nil)
}
// CreateDiffPatchWithSchema is like CreateDiffPatch but accepts a schema index
// for container delete coalescing. When idx is non-nil and ALL leaves under a
// schema container are being deleted (with no creates or replaces), they are
// coalesced into a single container-level nc:operation="delete".
func CreateDiffPatchWithSchema(diffMap map[string]Change, groupName string, idx map[string]*NodeInfo) ([]byte, error) {
_ = groupName
// Pre-pass: coalesce container deletes when schema is available.
if idx != nil {
diffMap = coalesceContainerDeletes(diffMap, idx)
}
// Root of the output tree
root := &Node{Tag: "configuration"}
type pendingLeaf struct {
parent *Node
tag string
keyName string
change Change
}
ordered := orderedChanges(diffMap)
// Two-pass strategy:
// Pass 1 — process key-entry operations and collect pending leaf ops.
// This ensures parent nodes get nc:operation="delete" BEFORE we decide
// whether a child leaf needs its own operation attribute.
var pending []pendingLeaf
for _, entry := range ordered {
path := entry.path
change := entry.change
segments := splitPathRespectingQuotes(path)
if len(segments) == 0 {
continue
}
if segments[0] == "configuration" {
segments = segments[1:]
}
if len(segments) > 0 {
firstTag, _, _ := parseSegment(segments[0])
if firstTag == "groups" {
segments = segments[1:]
}
}
if len(segments) == 0 {
continue
}
parentSegments := segments[:len(segments)-1]
leafSegment := segments[len(segments)-1]
parent := ensurePath(root, parentSegments)
if applyKeyedListEntryOperation(parent, parentSegments, leafSegment, change) {
continue
}
leafTag, keyName, _ := parseSegment(leafSegment)
pending = append(pending, pendingLeaf{
parent: parent, tag: leafTag, keyName: keyName, change: change,
})
}
// Pass 2 — create leaf nodes, inheriting context from pass-1 parent ops.
for _, p := range pending {
// Positional leaf-list entries (path ends with [pos=N]) represent
// ordered-by-user leaf-lists. A Replace means the value at that
// position changed — emit delete of old + create of new.
if p.keyName == "pos" {
switch p.change.Op {
case Create:
leaf := &Node{Tag: p.tag, Parent: p.parent, Operation: "merge", Text: p.change.NewVal}
p.parent.Children = append(p.parent.Children, leaf)
case Delete:
leaf := &Node{Tag: p.tag, Parent: p.parent, Operation: "delete", Text: p.change.OldVal}
p.parent.Children = append(p.parent.Children, leaf)
case Replace:
// Reorder: delete old value, create new value
del := &Node{Tag: p.tag, Parent: p.parent, Operation: "delete", Text: p.change.OldVal}
p.parent.Children = append(p.parent.Children, del)
cre := &Node{Tag: p.tag, Parent: p.parent, Operation: "merge", Text: p.change.NewVal}
p.parent.Children = append(p.parent.Children, cre)
}
continue
}
leaf := &Node{
Tag: p.tag,
Parent: p.parent,
}
switch p.change.Op {
case Create:
// merge, not create: idempotent over pre-existing device config
// (brownfield/empty-state applies), consistent with default-operation=merge.
leaf.Operation = "merge"
leaf.Text = p.change.NewVal
case Replace:
leaf.Operation = "replace"
leaf.Text = p.change.NewVal
case Delete:
// Leaf-list entries (paths with [value=xxx]) need the old value
// so Junos knows which instance to remove.
// Scalar leaves must NOT include text — Junos rejects
// <leaf nc:operation="delete">value</leaf> for scalar leaves.
if p.keyName == "value" {
leaf.Text = p.change.OldVal
}
// If the parent already has nc:operation="delete" (set by
// applyKeyedListEntryOperation in pass 1), this leaf is just
// a structural sibling — do NOT add an operation. This is
// critical for Junos compound-key lists where choice-ident
// elements (e.g. <add/>) must appear WITHOUT an operation.
if p.parent.Operation == "delete" {
// structural child — no operation
} else {
leaf.Operation = "delete"
}
}
p.parent.Children = append(p.parent.Children, leaf)
}
return marshalNodeTree(root)
}
func applyKeyedListEntryOperation(parent *Node, parentSegments []string, leafSegment string, change Change) bool {
if len(parentSegments) == 0 {
return false
}
_, parentKeyName, parentKeyValue := parseSegment(parentSegments[len(parentSegments)-1])
leafTag, _, _ := parseSegment(leafSegment)
if parentKeyName == "" || leafTag != parentKeyName {
return false
}
keyValue := change.NewVal
if change.Op == Delete {
keyValue = change.OldVal
}
if keyValue == "" || keyValue != parentKeyValue {
return false
}
switch change.Op {
case Create:
// merge (see Create case above): idempotent over existing config.
parent.Operation = "merge"
case Replace:
parent.Operation = "replace"
case Delete:
parent.Operation = "delete"
default:
return false
}
return true
}
type orderedChange struct {
path string
change Change
}
func orderedChanges(diffMap map[string]Change) []orderedChange {
result := make([]orderedChange, 0, len(diffMap))
for path, change := range diffMap {
result = append(result, orderedChange{path: path, change: change})
}
sort.SliceStable(result, func(i, j int) bool {
a := result[i]
b := result[j]
pa := opPriority(a.change.Op)
pb := opPriority(b.change.Op)
if pa != pb {
return pa < pb
}
da := pathDepth(a.path)
db := pathDepth(b.path)
if a.change.Op == Delete {
if da != db {
return da > db
}
} else {
if da != db {
return da < db
}
}
return a.path < b.path
})
return result
}
func opPriority(op ChangeType) int {
switch op {
case Delete:
return 0
case Replace:
return 1
case Create:
return 2
default:
return 3
}
}
func pathDepth(path string) int {
if path == "" {
return 0
}
return len(splitPathRespectingQuotes(path))
}
// marshalNodeTree serializes a *Node tree to indented XML bytes.
func marshalNodeTree(root *Node) ([]byte, error) {
var buf bytes.Buffer
if err := encodeNode(&buf, root, 0); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
// encodeNode recursively writes a node and all its descendants to buf.
func encodeNode(buf *bytes.Buffer, n *Node, depth int) error {
indent := strings.Repeat(" ", depth)
buf.WriteString(indent + "<" + n.Tag)
// Standard XML attributes
for k, v := range n.Attrs {
if _, err := fmt.Fprintf(buf, ` %s="%s"`, k, xmlEscape(v)); err != nil {
return err
}
}
// nc:operation attribute — references xmlns:nc on the <config> ancestor
if n.Operation != "" {
if _, err := fmt.Fprintf(buf, ` nc:operation="%s"`, n.Operation); err != nil {
return err
}
}
// Self-closing for delete and empty nodes
if len(n.Children) == 0 && n.Text == "" {
buf.WriteString("/>\n")
return nil
}
buf.WriteString(">")
if len(n.Children) > 0 {
buf.WriteString("\n")
for _, child := range n.Children {
if err := encodeNode(buf, child, depth+1); err != nil {
return err
}
}
buf.WriteString(indent + "</" + n.Tag + ">\n")
} else {
// Inline text with XML escaping
buf.WriteString(xmlEscape(n.Text) + "</" + n.Tag + ">\n")
}
return nil
}
// xmlEscape escapes the five XML special characters in text content and
// attribute values.
func xmlEscape(s string) string {
s = strings.ReplaceAll(s, "&", "&amp;") // must be first
s = strings.ReplaceAll(s, "<", "&lt;")
s = strings.ReplaceAll(s, ">", "&gt;")
s = strings.ReplaceAll(s, "\"", "&quot;")
s = strings.ReplaceAll(s, "'", "&apos;")
return s
}
// coalesceContainerDeletes detects when ALL leaves under a schema container are
// Delete operations (no Creates or Replaces share that prefix), and replaces
// them with a single synthetic Delete entry for the container path itself.
// This produces a compact <container nc:operation="delete"/> instead of N
// individual leaf deletes, which is both more efficient and avoids ordering
// issues on Junos.
func coalesceContainerDeletes(diffMap map[string]Change, idx map[string]*NodeInfo) map[string]Change {
// Build a set of all leaf paths grouped by their deepest container ancestor.
// A "container" here means a schema node of KindContainer (not KindList).
type containerStats struct {
allDelete bool
count int
paths []string
}
containers := make(map[string]*containerStats)
for path, change := range diffMap {
segments := splitPathRespectingQuotes(path)
// Strip configuration prefix
if len(segments) > 0 && segments[0] == "configuration" {
segments = segments[1:]
}
// Find the deepest container ancestor in the schema
for depth := len(segments) - 1; depth >= 1; depth-- {
ancestorSegments := segments[:depth]
// Build schema path from segments (strip key predicates)
schemaPath := ""
for _, seg := range ancestorSegments {
tag, _, _ := parseSegment(seg)
if schemaPath == "" {
schemaPath = tag
} else {
schemaPath = schemaPath + "/" + tag
}
}
info, ok := idx[schemaPath]
if !ok || info.Kind != KindContainer {
continue
}
// Found a container ancestor — record this path
if _, exists := containers[schemaPath]; !exists {
containers[schemaPath] = &containerStats{allDelete: true}
}
stat := containers[schemaPath]
stat.count++
stat.paths = append(stat.paths, path)
if change.Op != Delete {
stat.allDelete = false
}
break // only use the deepest container
}
}
// Identify containers where ALL children are Delete
coalesced := make(map[string]bool)
for _, stat := range containers {
if !stat.allDelete || stat.count < 2 {
continue
}
// Mark all child paths for removal
for _, p := range stat.paths {
coalesced[p] = true
}
}
if len(coalesced) == 0 {
return diffMap
}
// Build new diffMap: remove coalesced leaves, add container-level deletes
result := make(map[string]Change, len(diffMap))
for path, change := range diffMap {
if !coalesced[path] {
result[path] = change
}
}
// Add synthetic container deletes
added := make(map[string]bool)
for containerPath, stat := range containers {
if !stat.allDelete || stat.count < 2 {
continue
}
if added[containerPath] {
continue
}
added[containerPath] = true
// The path needs "configuration/" prefix for CreateDiffPatch processing
result["configuration/"+containerPath] = Change{Op: Delete, OldVal: "", NewVal: ""}
}
return result
}
File diff suppressed because it is too large Load Diff
@@ -1,116 +0,0 @@
package patch
import "strings"
// splitPathRespectingQuotes splits a path string on '/' while treating
// bracket predicates as opaque — a '/' inside "[name=ge-0/0/0]" is not
// treated as a separator.
//
// Example:
//
// "interfaces/interface[name=ge-0/0/0]/unit[name=0]/description"
// → ["interfaces", "interface[name=ge-0/0/0]", "unit[name=0]", "description"]
func splitPathRespectingQuotes(path string) []string {
var segments []string
var current strings.Builder
inBracket := false
for _, ch := range path {
switch {
case !inBracket && ch == '[':
inBracket = true
current.WriteRune(ch)
case inBracket && ch == ']':
inBracket = false
current.WriteRune(ch)
case !inBracket && ch == '/':
if current.Len() > 0 {
segments = append(segments, current.String())
current.Reset()
}
default:
current.WriteRune(ch)
}
}
if current.Len() > 0 {
segments = append(segments, current.String())
}
return segments
}
// parseSegment splits a path segment into its tag and optional key predicate.
//
// "interface[name=ge-0/0/0]" → ("interface", "name", "ge-0/0/0")
// "description" → ("description", "", "")
func parseSegment(seg string) (tag, keyName, keyValue string) {
idx := strings.Index(seg, "[")
if idx == -1 {
return seg, "", ""
}
tag = seg[:idx]
predicate := seg[idx+1 : len(seg)-1] // strip outer [ and ]
eqIdx := strings.Index(predicate, "=")
if eqIdx == -1 {
return tag, "", ""
}
keyName = predicate[:eqIdx]
keyValue = strings.Trim(predicate[eqIdx+1:], "'\"") // strip optional quotes
return
}
// ensurePath walks the node tree starting at current, creating intermediate
// nodes as needed for each segment, and returns the node at the end of the
// path.
//
// For keyed segments (e.g. "interface[name=ge-0/0/0]") it:
// 1. Looks for an existing child with matching tag AND key child value.
// 2. If not found, creates the element and injects a <name>ge-0/0/0</name>
// child immediately so subsequent sibling leaf writes land in the right
// list entry.
func ensurePath(current *Node, segments []string) *Node {
for _, seg := range segments {
tag, keyName, keyValue := parseSegment(seg)
// Search for an existing child that matches this segment
var found *Node
for _, child := range current.Children {
if child.Tag != tag {
continue
}
// Plain element — first match wins
if keyName == "" {
found = child
break
}
// Keyed element — must also match the key value
if findKeyChild(child, keyName) == keyValue {
found = child
break
}
}
if found == nil {
found = &Node{Tag: tag, Parent: current}
if keyName != "" {
// Inject key child as the first child of this list entry
keyNode := &Node{Tag: keyName, Text: keyValue, Parent: found}
found.Children = append(found.Children, keyNode)
}
current.Children = append(current.Children, found)
}
current = found
}
return current
}
// findKeyChild returns the text of the first child whose tag matches keyName,
// used to disambiguate keyed list entries during ensurePath traversal.
func findKeyChild(node *Node, keyName string) string {
for _, child := range node.Children {
if child.Tag == keyName {
return child.Text
}
}
return ""
}
@@ -1,357 +0,0 @@
package patch
import (
"encoding/json"
"strings"
)
// ------------------------- Type Definitions [START] -------------------------
type NodeKind uint8
const (
KindContainer NodeKind = iota
KindList
KindLeaf
KindLeafList
)
type LeafBase uint8
const (
LeafString LeafBase = iota
LeafBool
LeafInt
LeafUint
LeafEnum
LeafUnion
LeafOther
)
// Raw JSON node
type SchemaNode struct {
Name string `json:"name"`
Type string `json:"type"` // container | list | leaf
Path string `json:"path"` // often parent path
Key string `json:"key"` // list key
LeafType string `json:"leaf-type"` // leaf-only: string, union, etc.
OrderedBy string `json:"ordered-by"` // "user" for ordered leaf-lists/lists
Children []SchemaNode `json:"children"`
// Union branches (when leaf-type == "union")
Types []UnionType `json:"types"`
// Constraints (sometimes on leaves, sometimes inside union branches)
Lengths []LenRange `json:"lengths"`
Ranges []NumRange `json:"ranges"`
Patterns []string `json:"patterns"`
Enums []EnumValue `json:"enums"` // if your trimmed schema ever includes enums
}
type UnionType struct {
Type string `json:"type"`
Path string `json:"path"`
Patterns []string `json:"patterns"`
Ranges []NumRange `json:"ranges"`
Lengths []LenRange `json:"lengths"`
Enums []EnumValue `json:"enums"`
}
type NumRange struct {
Min *float64 `json:"min"`
Max *float64 `json:"max"`
Path string `json:"path"`
}
type LenRange struct {
Min *int `json:"min"`
Max *int `json:"max"`
Path string `json:"path"`
}
type EnumValue struct {
Name string `json:"name"`
Value any `json:"value"`
}
type NodeInfo struct {
Path string
Name string
Kind NodeKind
Parent string
Children []string
// Lists
ListKey string
ListKeyPath string
// Ordered-by user (meaningful ordering)
OrderedByUser bool
// Leaves
Leaf LeafInfo
}
type LeafInfo struct {
Base LeafBase
Union []UnionBranch
Patterns []string
Ranges []NumRange
Lengths []LenRange
Enums map[string]struct{} // canonical set of enum names (if present)
}
type UnionBranch struct {
Base LeafBase
Patterns []string
Ranges []NumRange
Lengths []LenRange
Enums map[string]struct{}
}
type TrimmedSchemaWrapper struct {
Path string `json:"path"`
Root SchemaRoot `json:"root"`
}
type SchemaRoot struct {
Children []SchemaNode `json:"children"`
}
// ------------------------- Type Definitions [END] -------------------------
// ------------------------- Process Trimmed Schema [START] -------------------------
// Go raw string literal -> compiled index
func UnmarshalTrimmedSchemaIndex(trimmedSchemaJSON string) (map[string]*NodeInfo, error) {
var w TrimmedSchemaWrapper
if err := json.Unmarshal([]byte(trimmedSchemaJSON), &w); err != nil {
return nil, err
}
roots := w.Root.Children
idx := make(map[string]*NodeInfo)
// Walk and compile
var walk func(n SchemaNode, parentFull string)
walk = func(n SchemaNode, parentFull string) {
full := canonicalFullPath(n, parentFull)
if full == "" {
// still walk children (some schemas have virtual root nodes)
for _, c := range n.Children {
walk(c, parentFull)
}
return
}
info := idx[full]
if info == nil {
info = &NodeInfo{
Path: full,
Name: n.Name,
Parent: parentFull,
}
idx[full] = info
} else {
// if collisions happen, keep existing and merge below
if info.Name == "" {
info.Name = n.Name
}
if info.Parent == "" {
info.Parent = parentFull
}
}
// Kind
switch n.Type {
case "container":
info.Kind = KindContainer
case "list":
info.Kind = KindList
case "leaf":
info.Kind = KindLeaf
case "leaf-list":
info.Kind = KindLeafList
default:
// unknown: treat like container-ish to keep traversal working
info.Kind = KindContainer
}
// List metadata
if info.Kind == KindList {
info.ListKey = n.Key
if n.Key != "" {
info.ListKeyPath = joinPath(full, n.Key)
}
}
// Ordered-by user
if n.OrderedBy == "user" {
info.OrderedByUser = true
}
// Leaf metadata
if info.Kind == KindLeaf || info.Kind == KindLeafList {
compileLeafInfo(&info.Leaf, n)
}
// Children bookkeeping
for _, c := range n.Children {
childFull := canonicalFullPath(c, full)
// record child path
if childFull != "" {
info.Children = appendUnique(info.Children, childFull)
}
walk(c, full)
}
}
for _, r := range roots {
// Some trimmed schemas include a top-level node with path:"" and children; still safe.
walk(r, "")
}
return idx, nil
}
// ------------------------- Process Trimmed Schema [END] -------------------------
// ------------------------- Helpers [START] -------------------------
func normalizePath(p string) string {
p = strings.Trim(p, "/")
// Strip "configuration" root in both forms
if p == "configuration" {
return ""
}
p = strings.TrimPrefix(p, "configuration/")
return p
}
func canonicalFullPath(n SchemaNode, parentFull string) string {
if n.Name == "" {
return ""
}
parentFull = normalizePath(parentFull)
// Your JSON "path" is usually the parent path (often includes "configuration/")
p := normalizePath(n.Path)
switch n.Type {
case "leaf", "leaf-list":
// leaf full path should be parent-path + leaf name
if p != "" {
return joinPath(p, n.Name)
}
return joinPath(parentFull, n.Name)
default: // container, list
// container/list full path should be its parent + its name
// Prefer n.Path if present (because your JSON is anchored under configuration)
if p != "" {
return joinPath(p, n.Name)
}
return joinPath(parentFull, n.Name)
}
}
func joinPath(a, b string) string {
a = normalizePath(a)
b = normalizePath(b)
if a == "" {
return b
}
if b == "" {
return a
}
return a + "/" + b
}
func appendUnique(xs []string, s string) []string {
for _, x := range xs {
if x == s {
return xs
}
}
return append(xs, s)
}
func compileLeafInfo(out *LeafInfo, n SchemaNode) {
base := leafBaseFromLeafType(n.LeafType)
out.Base = base
// Direct constraints on the leaf node
out.Patterns = append(out.Patterns, n.Patterns...)
out.Ranges = append(out.Ranges, n.Ranges...)
out.Lengths = append(out.Lengths, n.Lengths...)
out.Enums = enumSetFromEnumValues(n.Enums, out.Enums)
// Union branches
if base == LeafUnion {
for _, br := range n.Types {
ub := UnionBranch{
Base: leafBaseFromLeafType(br.Type),
Patterns: append([]string(nil), br.Patterns...),
Ranges: append([]NumRange(nil), br.Ranges...),
Lengths: append([]LenRange(nil), br.Lengths...),
Enums: enumSetFromEnumValues(br.Enums, nil),
}
out.Union = append(out.Union, ub)
// Often useful to have a flattened view too:
out.Patterns = append(out.Patterns, br.Patterns...)
out.Ranges = append(out.Ranges, br.Ranges...)
out.Lengths = append(out.Lengths, br.Lengths...)
out.Enums = mergeEnumSets(out.Enums, ub.Enums)
}
}
}
func leafBaseFromLeafType(t string) LeafBase {
switch strings.ToLower(strings.TrimSpace(t)) {
case "string":
return LeafString
case "boolean", "bool":
return LeafBool
case "int8", "int16", "int32", "int64", "int":
return LeafInt
case "uint8", "uint16", "uint32", "uint64", "uint":
return LeafUint
case "enumeration", "enum":
return LeafEnum
case "union":
return LeafUnion
default:
return LeafOther
}
}
func enumSetFromEnumValues(vals []EnumValue, existing map[string]struct{}) map[string]struct{} {
if len(vals) == 0 && existing != nil {
return existing
}
if existing == nil {
existing = make(map[string]struct{})
}
for _, v := range vals {
if v.Name != "" {
existing[v.Name] = struct{}{}
}
}
return existing
}
func mergeEnumSets(a, b map[string]struct{}) map[string]struct{} {
if a == nil {
return b
}
for k := range b {
a[k] = struct{}{}
}
return a
}
// ------------------------- Helpers [END] -------------------------
@@ -1,388 +0,0 @@
package patch
import "testing"
const testTrimmedSchema = `{
"path": "",
"root": {
"children": [
{
"name": "configuration",
"type": "container",
"path": "",
"children": [
{
"name": "foo",
"type": "container",
"path": "",
"children": [
{
"name": "item",
"type": "list",
"path": "foo",
"key": "address",
"children": [
{
"name": "address",
"type": "leaf",
"path": "foo/item",
"leaf-type": "string"
},
{
"name": "value",
"type": "leaf",
"path": "foo/item",
"leaf-type": "string"
}
]
},
{
"name": "members",
"type": "leaf-list",
"path": "foo",
"leaf-type": "string"
}
]
}
]
}
]
}
}`
const testStructuralKeyTrimmedSchema = `{
"path": "",
"root": {
"children": [
{
"name": "configuration",
"type": "container",
"path": "",
"children": [
{
"name": "system",
"type": "container",
"path": "",
"children": [
{
"name": "syslog",
"type": "container",
"path": "system",
"children": [
{
"name": "file",
"type": "list",
"path": "system/syslog",
"key": "name",
"children": [
{
"name": "name",
"type": "leaf",
"path": "system/syslog/file",
"leaf-type": "string"
},
{
"name": "contents",
"type": "list",
"path": "system/syslog/file",
"key": "name",
"children": [
{
"name": "name",
"type": "leaf",
"path": "system/syslog/file/contents",
"leaf-type": "string"
},
{
"name": "any",
"type": "leaf",
"path": "system/syslog/file/contents",
"leaf-type": "empty"
}
]
},
{
"name": "archive",
"type": "container",
"path": "system/syslog/file",
"children": [
{
"name": "world-readable",
"type": "leaf",
"path": "system/syslog/file/archive",
"leaf-type": "empty"
}
]
}
]
}
]
}
]
}
]
}
]
}
}`
func mustTree(t *testing.T, xmlStr string) *Node {
t.Helper()
tree, err := BuildTree([]byte(xmlStr))
if err != nil {
t.Fatalf("BuildTree error: %v", err)
}
return tree
}
func mustIdx(t *testing.T) map[string]*NodeInfo {
t.Helper()
return mustIdxFromSchema(t, testTrimmedSchema)
}
func mustIdxFromSchema(t *testing.T, schema string) map[string]*NodeInfo {
t.Helper()
idx, err := UnmarshalTrimmedSchemaIndex(schema)
if err != nil {
t.Fatalf("UnmarshalTrimmedSchemaIndex error: %v", err)
}
return idx
}
func TestLeafMapWithSchema_UsesSchemaListKey(t *testing.T) {
idx := mustIdx(t)
xmlStr := `<configuration>
<groups>
<name>g1</name>
<foo>
<item>
<address>10.0.0.1</address>
<value>alpha</value>
</item>
</foo>
</groups>
</configuration>`
m := LeafMapWithSchema(mustTree(t, xmlStr), idx)
path := `configuration/groups[name=g1]/foo/item[address=10.0.0.1]/value`
if got := m[path]; got != "alpha" {
t.Fatalf("expected %s => alpha, got %q", path, got)
}
// Key leaf is now also emitted (needed for new/removed entry detection)
keyLeafPath := `configuration/groups[name=g1]/foo/item[address=10.0.0.1]/address`
if got := m[keyLeafPath]; got != "10.0.0.1" {
t.Fatalf("expected key leaf %s => 10.0.0.1, got %q", keyLeafPath, got)
}
}
func TestLeafMapWithSchema_KeyOnlyListEmitsKeyLeaf(t *testing.T) {
idx := mustIdx(t)
xmlStr := `<configuration>
<groups>
<name>g1</name>
<foo>
<item>
<address>10.0.0.1</address>
</item>
</foo>
</groups>
</configuration>`
m := LeafMapWithSchema(mustTree(t, xmlStr), idx)
path := `configuration/groups[name=g1]/foo/item[address=10.0.0.1]/address`
if got := m[path]; got != "10.0.0.1" {
t.Fatalf("expected %s => 10.0.0.1, got %q", path, got)
}
}
func TestLeafMapWithSchema_LeafListSetDiff(t *testing.T) {
idx := mustIdx(t)
stateXML := `<configuration>
<groups>
<name>g1</name>
<foo>
<members>a</members>
<members>c</members>
</foo>
</groups>
</configuration>`
planXML := `<configuration>
<groups>
<name>g1</name>
<foo>
<members>a</members>
<members>b</members>
</foo>
</groups>
</configuration>`
stateMap := LeafMapWithSchema(mustTree(t, stateXML), idx)
planMap := LeafMapWithSchema(mustTree(t, planXML), idx)
diff := ComputeDiff(stateMap, planMap)
createPath := `configuration/groups[name=g1]/foo/members[value=b]`
deletePath := `configuration/groups[name=g1]/foo/members[value=c]`
commonPath := `configuration/groups[name=g1]/foo/members[value=a]`
if ch, ok := diff[createPath]; !ok || ch.Op != Create {
t.Fatalf("expected create for %s", createPath)
}
if ch, ok := diff[deletePath]; !ok || ch.Op != Delete {
t.Fatalf("expected delete for %s", deletePath)
}
if _, ok := diff[commonPath]; ok {
t.Fatalf("did not expect diff for unchanged leaf-list value %s", commonPath)
}
}
func TestBuildTree_PreservesTrailingNewlineInLeafText(t *testing.T) {
xmlStr := `<configuration><groups><name>g1</name><system><login><message>banner line
</message></login></system></groups></configuration>`
tree := mustTree(t, xmlStr)
idx, err := UnmarshalTrimmedSchemaIndex(TrimmedSchemaJSON)
if err != nil {
t.Fatalf("UnmarshalTrimmedSchemaIndex error: %v", err)
}
leafMap := LeafMapWithSchema(tree, idx)
path := `configuration/groups[name=g1]/system/login/message`
if got := leafMap[path]; got != "banner line\n" {
t.Fatalf("expected %s => %q, got %q", path, "banner line\n", got)
}
}
func TestOrderedChanges_DeleteBeforeCreate_AndDepthRules(t *testing.T) {
diffMap := map[string]Change{
`configuration/groups[name=g1]/foo/bar/baz`: {Op: Delete, OldVal: "x"},
`configuration/groups[name=g1]/foo/bar`: {Op: Delete, OldVal: "x"},
`configuration/groups[name=g1]/foo/alpha`: {Op: Replace, OldVal: "a", NewVal: "b"},
`configuration/groups[name=g1]/foo/new`: {Op: Create, NewVal: "n"},
}
ordered := orderedChanges(diffMap)
if len(ordered) != 4 {
t.Fatalf("expected 4 ordered changes, got %d", len(ordered))
}
if ordered[0].change.Op != Delete || ordered[1].change.Op != Delete {
t.Fatalf("expected first two operations to be deletes")
}
if pathDepth(ordered[0].path) < pathDepth(ordered[1].path) {
t.Fatalf("expected deeper delete path first: %s then %s", ordered[0].path, ordered[1].path)
}
if ordered[2].change.Op != Replace || ordered[3].change.Op != Create {
t.Fatalf("expected replace before create in trailing operations")
}
}
func TestComputeDiff_ListKeyRename_ShowsDeleteAndCreate(t *testing.T) {
stateMap := map[string]string{
`configuration/groups[name=g1]/foo/item[address=10.0.0.1]/value`: "alpha",
}
planMap := map[string]string{
`configuration/groups[name=g1]/foo/item[address=10.0.0.2]/value`: "alpha",
}
diff := ComputeDiff(stateMap, planMap)
if len(diff) != 2 {
t.Fatalf("expected 2 changes for key rename, got %d", len(diff))
}
if diff[`configuration/groups[name=g1]/foo/item[address=10.0.0.1]/value`].Op != Delete {
t.Fatalf("expected delete for old key path")
}
if diff[`configuration/groups[name=g1]/foo/item[address=10.0.0.2]/value`].Op != Create {
t.Fatalf("expected create for new key path")
}
}
func TestLeafMapWithSchema_StructuralKeyedListEmitsKeyLeaf(t *testing.T) {
idx := mustIdxFromSchema(t, testStructuralKeyTrimmedSchema)
xmlStr := `<configuration>
<groups>
<name>g1</name>
<system>
<syslog>
<file>
<name>security</name>
<contents>
<name>interactive-commands</name>
<any/>
</contents>
<archive>
<world-readable/>
</archive>
</file>
</syslog>
</system>
</groups>
</configuration>`
m := LeafMapWithSchema(mustTree(t, xmlStr), idx)
path := `configuration/groups[name=g1]/system/syslog/file[name=security]/name`
if got := m[path]; got != "security" {
t.Fatalf("expected %s => security, got %q", path, got)
}
// Nested key is now also emitted (contents has material children)
nestedPath := `configuration/groups[name=g1]/system/syslog/file[name=security]/contents[name=interactive-commands]/name`
if got := m[nestedPath]; got != "interactive-commands" {
t.Fatalf("expected nested key %s => interactive-commands, got %q", nestedPath, got)
}
}
func TestComputeDiff_StructuralListKeyRename_ShowsDeleteAndCreate(t *testing.T) {
idx := mustIdxFromSchema(t, testStructuralKeyTrimmedSchema)
stateXML := `<configuration>
<groups>
<name>g1</name>
<system>
<syslog>
<file>
<name>security</name>
<contents>
<name>interactive-commands</name>
<any/>
</contents>
<archive>
<world-readable/>
</archive>
</file>
</syslog>
</system>
</groups>
</configuration>`
planXML := `<configuration>
<groups>
<name>g1</name>
<system>
<syslog>
<file>
<name>vinay</name>
<contents>
<name>interactive-commands</name>
<any/>
</contents>
<archive>
<world-readable/>
</archive>
</file>
</syslog>
</system>
</groups>
</configuration>`
stateMap := LeafMapWithSchema(mustTree(t, stateXML), idx)
planMap := LeafMapWithSchema(mustTree(t, planXML), idx)
diff := ComputeDiff(stateMap, planMap)
deletePath := `configuration/groups[name=g1]/system/syslog/file[name=security]/name`
createPath := `configuration/groups[name=g1]/system/syslog/file[name=vinay]/name`
if ch, ok := diff[deletePath]; !ok || ch.Op != Delete {
t.Fatalf("expected delete for %s", deletePath)
}
if ch, ok := diff[createPath]; !ok || ch.Op != Create {
t.Fatalf("expected create for %s", createPath)
}
}
@@ -1,73 +0,0 @@
package patch
import (
"bytes"
"encoding/xml"
"fmt"
"io"
)
// BuildTree parses XML bytes into a *Node tree using a streaming token decoder.
// It handles the XML declaration header produced by xml.Header gracefully.
func BuildTree(xmlBytes []byte) (*Node, error) {
decoder := xml.NewDecoder(bytes.NewReader(xmlBytes))
var stack []*Node
var root *Node
for {
tok, err := decoder.Token()
if err != nil {
if err == io.EOF {
break
}
return nil, fmt.Errorf("failed parsing XML token stream: %w", err)
}
switch t := tok.(type) {
case xml.StartElement:
node := &Node{
Tag: t.Name.Local,
Attrs: make(map[string]string),
}
for _, attr := range t.Attr {
// Skip xmlns declarations — not needed in our tree
if attr.Name.Space == "xmlns" || attr.Name.Local == "xmlns" {
continue
}
node.Attrs[attr.Name.Local] = attr.Value
}
if len(stack) > 0 {
parent := stack[len(stack)-1]
node.Parent = parent
parent.Children = append(parent.Children, node)
}
stack = append(stack, node)
case xml.EndElement:
if len(stack) == 0 {
return nil, fmt.Errorf("unexpected end element </%s>", t.Name.Local)
}
popped := stack[len(stack)-1]
stack = stack[:len(stack)-1]
// When we pop the last element off the stack it is the root
if len(stack) == 0 {
root = popped
}
case xml.CharData:
if len(stack) > 0 {
if len(bytes.TrimSpace([]byte(t))) > 0 {
top := stack[len(stack)-1]
top.Text += string(t)
}
}
}
}
if root == nil {
return nil, fmt.Errorf("no root element found in XML")
}
return root, nil
}
@@ -1,27 +0,0 @@
package patch
// ChangeType represents the CRUD operation for a single leaf diff.
type ChangeType int
const (
Create ChangeType = iota
Replace // value exists in both state and plan but differs
Delete // value exists in state but not in plan
)
// Node is a single element in the parsed XML tree.
type Node struct {
Tag string
Attrs map[string]string // standard XML attributes (not nc:operation)
Operation string // nc:operation value: "create", "replace", "delete", or ""
Text string // character data between open/close tags
Children []*Node
Parent *Node
}
// Change holds a single leaf-level diff entry produced by ComputeDiff.
type Change struct {
Op ChangeType
OldVal string // empty for Create
NewVal string // empty for Delete
}
@@ -1,123 +0,0 @@
package main
import (
"context"
"terraform-provider-junos-qfx/netconf"
"github.com/hashicorp/terraform-plugin-framework/datasource"
"github.com/hashicorp/terraform-plugin-framework/provider"
"github.com/hashicorp/terraform-plugin-framework/provider/schema"
"github.com/hashicorp/terraform-plugin-framework/resource"
"github.com/hashicorp/terraform-plugin-framework/types"
)
var _ provider.Provider = new(Provider)
var providerClientFactory = func(cfg *Config) (netconf.Client, error) {
return cfg.Client()
}
func newProvider() provider.Provider {
return Provider{}
}
type Provider struct {
}
type providerModel struct {
Host types.String `tfsdk:"host"`
Username types.String `tfsdk:"username"`
Password types.String `tfsdk:"password"`
Port types.Int64 `tfsdk:"port"`
SshKey types.String `tfsdk:"sshkey"`
}
// ProviderConfig is to hold client information
type ProviderConfig struct {
netconf.Client
Host string
}
func buildProviderConfig(config providerModel) (ProviderConfig, error) {
clientConfig := Config{
Host: config.Host.ValueString(),
Port: int(config.Port.ValueInt64()),
Username: config.Username.ValueString(),
Password: config.Password.ValueString(),
SSHKey: config.SshKey.ValueString(),
}
client, err := providerClientFactory(&clientConfig)
if err != nil {
return ProviderConfig{}, err
}
return ProviderConfig{
Client: client,
Host: clientConfig.Host,
}, nil
}
// Configure implements provider.Provider.
func (p Provider) Configure(ctx context.Context, req provider.ConfigureRequest, resp *provider.ConfigureResponse) {
var config providerModel
resp.Diagnostics.Append(req.Config.Get(ctx, &config)...)
if resp.Diagnostics.HasError() {
return
}
providerConfig, err := buildProviderConfig(config)
if err != nil {
resp.Diagnostics.AddError("failed to create client", err.Error())
return
}
resp.ResourceData = providerConfig
}
// DataSources implements provider.Provider.
func (p Provider) DataSources(_ context.Context) []func() datasource.DataSource {
return nil
}
// Metadata implements provider.Provider.
func (p Provider) Metadata(_ context.Context, _ provider.MetadataRequest, resp *provider.MetadataResponse) {
resp.TypeName = "junos-qfx"
}
// Resources implements provider.Provider.
func (p Provider) Resources(_ context.Context) []func() resource.Resource {
return []func() resource.Resource{
func() resource.Resource { return new(configResource) },
}
}
// Schema implements provider.Provider.
func (p Provider) Schema(_ context.Context, _ provider.SchemaRequest, resp *provider.SchemaResponse) {
resp.Schema = schema.Schema{
Attributes: map[string]schema.Attribute{
"host": schema.StringAttribute{
Required: true,
},
"username": schema.StringAttribute{
Required: true,
},
"password": schema.StringAttribute{
Optional: true,
Sensitive: true,
},
"port": schema.Int64Attribute{
Required: true,
//Optional: true,
//Computed: true,
//Default: int64default.StaticInt64(22),
},
"sshkey": schema.StringAttribute{
Optional: true,
Sensitive: true,
// Will need to add eventually
//Validators: []validator.String{stringvalidator.AtLeastOneOf(path.MatchRoot("d")...)},
},
},
}
}
@@ -1,84 +0,0 @@
package main
import (
"errors"
"testing"
"terraform-provider-junos-qfx/netconf"
"github.com/hashicorp/terraform-plugin-framework/types"
)
type fakeNetconfClient struct{}
// Close implements netconf.Client for unit tests.
func (f *fakeNetconfClient) Close() error { return nil }
// DeleteConfig implements netconf.Client for unit tests.
func (f *fakeNetconfClient) DeleteConfig(string, bool) (string, error) { return "", nil }
// SendCommit implements netconf.Client for unit tests.
func (f *fakeNetconfClient) SendCommit() error { return nil }
// MarshalGroup implements netconf.Client for unit tests.
func (f *fakeNetconfClient) MarshalGroup(string, interface{}) error { return nil }
// MarshalConfig implements netconf.Client for unit tests.
func (f *fakeNetconfClient) MarshalConfig(interface{}) error { return nil }
// SendTransaction implements netconf.Client for unit tests.
func (f *fakeNetconfClient) SendTransaction(string, interface{}, bool) error { return nil }
// SendDirectTransaction implements netconf.Client for unit tests.
func (f *fakeNetconfClient) SendDirectTransaction(interface{}, bool) error { return nil }
// SendUpdate implements netconf.Client for unit tests.
func (f *fakeNetconfClient) SendUpdate(string, string, bool) error { return nil }
// TestBuildProviderConfigSuccess verifies successful provider config construction.
func TestBuildProviderConfigSuccess(t *testing.T) {
originalFactory := providerClientFactory
t.Cleanup(func() { providerClientFactory = originalFactory })
providerClientFactory = func(cfg *Config) (netconf.Client, error) {
if cfg.Host != "127.0.0.1" || cfg.Port != 830 || cfg.Username != "user" {
t.Fatalf("unexpected config passed to factory: %+v", cfg)
}
return &fakeNetconfClient{}, nil
}
model := providerModel{
Host: types.StringValue("127.0.0.1"),
Username: types.StringValue("user"),
Password: types.StringValue("pass"),
Port: types.Int64Value(830),
SshKey: types.StringValue(""),
}
cfg, err := buildProviderConfig(model)
if err != nil {
t.Fatalf("buildProviderConfig() returned error: %v", err)
}
if cfg.Host != "127.0.0.1" {
t.Fatalf("unexpected host: %q", cfg.Host)
}
if cfg.Client == nil {
t.Fatalf("expected non-nil netconf client")
}
}
// TestBuildProviderConfigFactoryError verifies client factory errors are returned.
func TestBuildProviderConfigFactoryError(t *testing.T) {
originalFactory := providerClientFactory
t.Cleanup(func() { providerClientFactory = originalFactory })
expectedErr := errors.New("boom")
providerClientFactory = func(_ *Config) (netconf.Client, error) {
return nil, expectedErr
}
_, err := buildProviderConfig(providerModel{})
if !errors.Is(err, expectedErr) {
t.Fatalf("expected %v, got %v", expectedErr, err)
}
}
@@ -1,104 +0,0 @@
package main
import (
"context"
"errors"
"math/big"
"strings"
"testing"
"github.com/hashicorp/terraform-plugin-framework/provider"
"github.com/hashicorp/terraform-plugin-framework/tfsdk"
"github.com/hashicorp/terraform-plugin-go/tftypes"
"terraform-provider-junos-qfx/netconf"
)
// TestProviderMetadata verifies provider type metadata is set correctly.
func TestProviderMetadata(t *testing.T) {
p := &Provider{}
resp := &provider.MetadataResponse{}
p.Metadata(context.Background(), provider.MetadataRequest{}, resp)
if resp.TypeName == "" {
t.Fatalf("expected non-empty provider type name")
}
if resp.TypeName != "terraform_provider" && !strings.HasPrefix(resp.TypeName, "junos-") {
t.Fatalf("unexpected provider type name: %q", resp.TypeName)
}
}
// TestProviderConfigureMissingConfigPanics documents current framework panic behavior.
func TestProviderConfigureMissingConfigPanics(t *testing.T) {
p := &Provider{}
defer func() {
if recover() == nil {
t.Fatalf("expected panic when ConfigureRequest.Config is unset")
}
}()
resp := &provider.ConfigureResponse{}
p.Configure(context.Background(), provider.ConfigureRequest{}, resp)
if resp.Diagnostics.HasError() {
t.Fatalf("unexpected diagnostics before panic")
}
}
// providerConfigRaw creates a typed provider config payload for Configure tests.
func providerConfigRaw(t *testing.T, p *Provider) tfsdk.Config {
t.Helper()
ctx := context.Background()
schemaResp := &provider.SchemaResponse{}
p.Schema(ctx, provider.SchemaRequest{}, schemaResp)
tfType := schemaResp.Schema.Type().TerraformType(ctx)
raw := tftypes.NewValue(tfType, map[string]tftypes.Value{
"host": tftypes.NewValue(tftypes.String, "127.0.0.1"),
"username": tftypes.NewValue(tftypes.String, "user"),
"password": tftypes.NewValue(tftypes.String, "pass"),
"port": tftypes.NewValue(tftypes.Number, big.NewFloat(830)),
"sshkey": tftypes.NewValue(tftypes.String, ""),
})
return tfsdk.Config{Schema: schemaResp.Schema, Raw: raw}
}
// TestProviderConfigureSuccess verifies successful provider configuration.
func TestProviderConfigureSuccess(t *testing.T) {
originalFactory := providerClientFactory
t.Cleanup(func() { providerClientFactory = originalFactory })
providerClientFactory = func(_ *Config) (netconf.Client, error) {
return &fakeNetconfClient{}, nil
}
p := &Provider{}
req := provider.ConfigureRequest{Config: providerConfigRaw(t, p)}
resp := &provider.ConfigureResponse{}
p.Configure(context.Background(), req, resp)
if resp.Diagnostics.HasError() {
t.Fatalf("unexpected diagnostics: %v", resp.Diagnostics)
}
if resp.ResourceData == nil {
t.Fatalf("expected provider resource data")
}
}
// TestProviderConfigureClientError verifies provider diagnostics on client creation failures.
func TestProviderConfigureClientError(t *testing.T) {
originalFactory := providerClientFactory
t.Cleanup(func() { providerClientFactory = originalFactory })
providerClientFactory = func(_ *Config) (netconf.Client, error) {
return nil, errors.New("client failed")
}
p := &Provider{}
req := provider.ConfigureRequest{Config: providerConfigRaw(t, p)}
resp := &provider.ConfigureResponse{}
p.Configure(context.Background(), req, resp)
if !resp.Diagnostics.HasError() {
t.Fatalf("expected diagnostics on client factory error")
}
}
@@ -1,294 +0,0 @@
package main
import (
"context"
"testing"
"github.com/hashicorp/terraform-plugin-framework/provider"
"github.com/hashicorp/terraform-plugin-framework/types"
)
// TestNewProvider tests instantiation of a new provider
func TestNewProvider(t *testing.T) {
p := newProvider()
if p == nil {
t.Fatal("expected non-nil provider")
}
// Verify it implements the Provider interface
// p already has the correct type
}
// TestProviderMetadata tests the Metadata method removed because it changes when used with generated module
// TestProviderSchema tests the Schema method
func TestProviderSchema(t *testing.T) {
p := &Provider{}
ctx := context.Background()
req := provider.SchemaRequest{}
resp := &provider.SchemaResponse{}
p.Schema(ctx, req, resp)
// Verify schema contains attributes map
if len(resp.Schema.Attributes) == 0 {
t.Error("expected schema to have attributes")
}
// Verify required attributes exist
requiredAttrs := []string{"host", "username", "port"}
for _, attr := range requiredAttrs {
if _, ok := resp.Schema.Attributes[attr]; !ok {
t.Errorf("expected attribute %q in schema", attr)
}
}
// Verify optional attributes exist
optionalAttrs := []string{"password", "sshkey"}
for _, attr := range optionalAttrs {
if _, ok := resp.Schema.Attributes[attr]; !ok {
t.Errorf("expected attribute %q in schema", attr)
}
}
}
// TestProviderSchemaHostAttribute tests the host attribute configuration
func TestProviderSchemaHostAttribute(t *testing.T) {
p := &Provider{}
ctx := context.Background()
req := provider.SchemaRequest{}
resp := &provider.SchemaResponse{}
p.Schema(ctx, req, resp)
hostAttr := resp.Schema.Attributes["host"]
if hostAttr == nil {
t.Fatal("host attribute not found")
}
// Host should be required
if !hostAttr.IsRequired() {
t.Error("host attribute should be required")
}
}
// TestProviderSchemaPasswordAttribute tests the password attribute configuration
func TestProviderSchemaPasswordAttribute(t *testing.T) {
p := &Provider{}
ctx := context.Background()
req := provider.SchemaRequest{}
resp := &provider.SchemaResponse{}
p.Schema(ctx, req, resp)
passwordAttr := resp.Schema.Attributes["password"]
if passwordAttr == nil {
t.Fatal("password attribute not found")
}
// Password should be optional and sensitive
if passwordAttr.IsRequired() {
t.Error("password attribute should be optional")
}
if !passwordAttr.IsSensitive() {
t.Error("password attribute should be sensitive")
}
}
// TestProviderSchemaSshKeyAttribute tests the sshkey attribute configuration
func TestProviderSchemaSshKeyAttribute(t *testing.T) {
p := &Provider{}
ctx := context.Background()
req := provider.SchemaRequest{}
resp := &provider.SchemaResponse{}
p.Schema(ctx, req, resp)
sshKeyAttr := resp.Schema.Attributes["sshkey"]
if sshKeyAttr == nil {
t.Fatal("sshkey attribute not found")
}
// SSH Key should be optional and sensitive
if sshKeyAttr.IsRequired() {
t.Error("sshkey attribute should be optional")
}
if !sshKeyAttr.IsSensitive() {
t.Error("sshkey attribute should be sensitive")
}
}
// TestProviderSchemaPortAttribute tests the port attribute configuration
func TestProviderSchemaPortAttribute(t *testing.T) {
p := &Provider{}
ctx := context.Background()
req := provider.SchemaRequest{}
resp := &provider.SchemaResponse{}
p.Schema(ctx, req, resp)
portAttr := resp.Schema.Attributes["port"]
if portAttr == nil {
t.Fatal("port attribute not found")
}
// Port should be required
if !portAttr.IsRequired() {
t.Error("port attribute should be required")
}
}
// TestProviderConfigure tests the Configure method
func TestProviderConfigure(t *testing.T) {
p := &Provider{}
_ = p
ctx := context.Background()
_ = ctx
req := provider.ConfigureRequest{}
resp := &provider.ConfigureResponse{}
_ = req
_ = resp
// Note: Configure method requires properly populated ConfigureRequest
// which involves the Terraform plugin framework infrastructure.
// In a unit test environment, calling p.Configure directly would panic
// because the Config field would be nil. Typically this is called by
// the Terraform plugin framework during provider initialization.
// provider value constructed directly; nil check unnecessary
}
// TestProviderConfigureWithConfig tests the Configure method with actual config
func TestProviderConfigureWithConfig(t *testing.T) {
p := &Provider{}
_ = p
ctx := context.Background()
_ = ctx
// Create a fake config with providerModel struct
providerConfig := providerModel{
Host: types.StringValue("localhost"),
Username: types.StringValue("admin"),
Password: types.StringValue("pass"),
Port: types.Int64Value(830),
SshKey: types.StringValue(""),
}
_ = providerConfig
// Note: We can't easily test the actual config parsing without a full TF setup.
// The Configure method requires framework infrastructure that isn't available
// in unit tests. Testing is done via integration tests with the Terraform CLI.
req := provider.ConfigureRequest{}
resp := &provider.ConfigureResponse{}
_ = req
_ = resp
// direct instantiation yields non-nil provider
// Verify providerModel is a struct
_ = providerConfig
}
// TestProviderDataSources tests the DataSources method
func TestProviderDataSources(t *testing.T) {
p := &Provider{}
ctx := context.Background()
dataSources := p.DataSources(ctx)
// Currently returns nil - this test ensures it doesn't panic
if dataSources != nil {
t.Logf("data sources: %v", dataSources)
}
}
// TestProviderResources tests the Resources method
func TestProviderResources(t *testing.T) {
p := &Provider{}
ctx := context.Background()
resources := p.Resources(ctx)
// Currently returns nil - this test ensures it doesn't panic
if resources != nil {
t.Logf("resources: %v", resources)
}
}
// TestProviderModelStructure tests the providerModel structure
func TestProviderModelStructure(t *testing.T) {
model := providerModel{
Host: types.StringValue("example.com"),
Username: types.StringValue("user"),
Password: types.StringValue("pass"),
Port: types.Int64Value(830),
SshKey: types.StringValue("/path/to/key"),
}
if model.Host.ValueString() != "example.com" {
t.Error("host value mismatch")
}
if model.Username.ValueString() != "user" {
t.Error("username value mismatch")
}
if model.Password.ValueString() != "pass" {
t.Error("password value mismatch")
}
if model.Port.ValueInt64() != 830 {
t.Error("port value mismatch")
}
if model.SshKey.ValueString() != "/path/to/key" {
t.Error("sshkey value mismatch")
}
}
// TestProviderInterfaceImplementation verifies that Provider implements provider.Provider
func TestProviderInterfaceImplementation(t *testing.T) {
var _ provider.Provider = (*Provider)(nil)
// If this compiles, the interface is properly implemented
}
// TestProviderCreation tests various ways to create a provider
func TestProviderCreation(t *testing.T) {
testCases := []struct {
name string
createFn func() provider.Provider
expectErr bool
}{
{
name: "newProvider function",
createFn: newProvider,
expectErr: false,
},
{
name: "direct instantiation",
createFn: func() provider.Provider {
return &Provider{}
},
expectErr: false,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
p := tc.createFn()
_ = p // avoid unused variable warning
// Verify it implements the interface
// p already has provider.Provider type
})
}
}
File diff suppressed because it is too large Load Diff
@@ -1,67 +0,0 @@
package main
import (
"context"
"testing"
"github.com/hashicorp/terraform-plugin-framework/resource"
)
func isStubConfigResource(t *testing.T, r *configResource) bool {
t.Helper()
schemaResp := &resource.SchemaResponse{}
r.Schema(context.Background(), resource.SchemaRequest{}, schemaResp)
return len(schemaResp.Schema.Attributes) == 0
}
// TestConfigResourceStubMethods verifies stub CRUD methods remain no-op.
func TestConfigResourceStubMethods(t *testing.T) {
r := &configResource{}
if !isStubConfigResource(t, r) {
t.Skip("generated config resource requires framework-populated requests")
}
ctx := context.Background()
createResp := &resource.CreateResponse{}
r.Create(ctx, resource.CreateRequest{}, createResp)
if createResp.Diagnostics.HasError() {
t.Fatalf("Create() should not add diagnostics")
}
readResp := &resource.ReadResponse{}
r.Read(ctx, resource.ReadRequest{}, readResp)
if readResp.Diagnostics.HasError() {
t.Fatalf("Read() should not add diagnostics")
}
updateResp := &resource.UpdateResponse{}
r.Update(ctx, resource.UpdateRequest{}, updateResp)
if updateResp.Diagnostics.HasError() {
t.Fatalf("Update() should not add diagnostics")
}
deleteResp := &resource.DeleteResponse{}
r.Delete(ctx, resource.DeleteRequest{}, deleteResp)
if deleteResp.Diagnostics.HasError() {
t.Fatalf("Delete() should not add diagnostics")
}
}
// TestConfigResourceMetadataAndSchema verifies current stub metadata and schema.
func TestConfigResourceMetadataAndSchema(t *testing.T) {
r := &configResource{}
ctx := context.Background()
metadataResp := &resource.MetadataResponse{}
r.Metadata(ctx, resource.MetadataRequest{}, metadataResp)
if metadataResp.TypeName == "" {
t.Fatalf("expected non-empty metadata type name")
}
schemaResp := &resource.SchemaResponse{}
r.Schema(ctx, resource.SchemaRequest{}, schemaResp)
if isStubConfigResource(t, r) && len(schemaResp.Schema.Attributes) != 0 {
t.Fatalf("expected empty schema attributes for stub resource")
}
}
@@ -1,142 +0,0 @@
package main
import (
"context"
"os"
"path"
"github.com/hashicorp/terraform-plugin-framework/resource"
"github.com/hashicorp/terraform-plugin-framework/resource/schema"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier"
"github.com/hashicorp/terraform-plugin-framework/types"
)
var _ resource.ResourceWithConfigure = new(resourceFile)
type resourceFile struct {
dir string
}
// Configure implements resource.ResourceWithConfigure.
func (r *resourceFile) Configure(_ context.Context, req resource.ConfigureRequest, _ *resource.ConfigureResponse) {
if req.ProviderData == nil {
return
}
r.dir = req.ProviderData.(string)
}
type fileModel struct {
Name types.String `tfsdk:"name"`
Contents types.String `tfsdk:"contents"`
}
// writeManagedFile writes the planned resource contents to disk.
func writeManagedFile(dir string, plan fileModel) error {
return os.WriteFile(path.Join(dir, plan.Name.ValueString()), []byte(plan.Contents.ValueString()), 0644)
}
// readManagedFile loads the managed file contents into Terraform state.
func readManagedFile(dir string, state *fileModel) error {
data, err := os.ReadFile(path.Join(dir, state.Name.ValueString()))
if err != nil {
return err
}
state.Contents = types.StringValue(string(data))
return nil
}
// deleteManagedFile removes the managed file and ignores missing-file cases.
func deleteManagedFile(dir string, state fileModel) error {
err := os.Remove(path.Join(dir, state.Name.ValueString()))
if err != nil && os.IsNotExist(err) {
return nil
}
return err
}
// Metadata implements resource.Resource.
func (r *resourceFile) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_file"
}
// Schema implements resource.Resource.
func (r *resourceFile) Schema(_ context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) {
resp.Schema = schema.Schema{
Attributes: map[string]schema.Attribute{
"name": schema.StringAttribute{
Required: true,
PlanModifiers: []planmodifier.String{stringplanmodifier.RequiresReplace()},
},
"contents": schema.StringAttribute{
Required: true,
},
},
}
}
// Create implements resource.Resource.
func (r *resourceFile) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
var plan fileModel
d := req.Plan.Get(ctx, &plan)
resp.Diagnostics.Append(d...)
if resp.Diagnostics.HasError() {
return
}
err := writeManagedFile(r.dir, plan)
if err != nil {
resp.Diagnostics.AddError("failed writing file", err.Error())
return
}
d = resp.State.Set(ctx, &plan)
resp.Diagnostics.Append(d...)
}
// Read implements resource.Resource.
func (r *resourceFile) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
var state fileModel
d := req.State.Get(ctx, &state)
resp.Diagnostics.Append(d...)
if resp.Diagnostics.HasError() {
return
}
err := readManagedFile(r.dir, &state)
if err != nil {
resp.Diagnostics.AddError("failed reading file", err.Error())
return
}
d = resp.State.Set(ctx, &state)
resp.Diagnostics.Append(d...)
}
// Update implements resource.Resource.
func (r *resourceFile) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
var plan fileModel
d := req.Plan.Get(ctx, &plan)
resp.Diagnostics.Append(d...)
if resp.Diagnostics.HasError() {
return
}
err := writeManagedFile(r.dir, plan)
if err != nil {
resp.Diagnostics.AddError("failed writing file", err.Error())
return
}
d = resp.State.Set(ctx, &plan)
resp.Diagnostics.Append(d...)
}
// Delete implements resource.Resource.
func (r *resourceFile) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) {
var state fileModel
d := req.State.Get(ctx, &state)
resp.Diagnostics.Append(d...)
if resp.Diagnostics.HasError() {
return
}
err := deleteManagedFile(r.dir, state)
if err != nil {
resp.Diagnostics.AddError("failed deleting file", err.Error())
return
}
}
@@ -1,168 +0,0 @@
package main
import (
"context"
"errors"
"os"
"path/filepath"
"testing"
"github.com/hashicorp/terraform-plugin-framework/resource"
"github.com/hashicorp/terraform-plugin-framework/tfsdk"
"github.com/hashicorp/terraform-plugin-framework/types"
)
// fileResourceSchema builds the resource schema used to initialize typed plan/state values.
func fileResourceSchema(t *testing.T, rf *resourceFile) resource.SchemaResponse {
t.Helper()
resp := resource.SchemaResponse{}
rf.Schema(context.Background(), resource.SchemaRequest{}, &resp)
return resp
}
// TestResourceFileCreateReadUpdateDeleteEndToEnd verifies full CRUD behavior.
func TestResourceFileCreateReadUpdateDeleteEndToEnd(t *testing.T) {
ctx := context.Background()
dir := t.TempDir()
rf := &resourceFile{dir: dir}
schemaResp := fileResourceSchema(t, rf)
createPlan := fileModel{
Name: types.StringValue("managed.txt"),
Contents: types.StringValue("initial"),
}
createReqPlan := tfsdk.Plan{Schema: schemaResp.Schema}
if diags := createReqPlan.Set(ctx, createPlan); diags.HasError() {
t.Fatalf("failed to build create plan: %v", diags)
}
createReq := resource.CreateRequest{Plan: createReqPlan}
createResp := &resource.CreateResponse{State: tfsdk.State{Schema: schemaResp.Schema}}
rf.Create(ctx, createReq, createResp)
if createResp.Diagnostics.HasError() {
t.Fatalf("Create() diagnostics: %v", createResp.Diagnostics)
}
readState := tfsdk.State{Schema: schemaResp.Schema}
if diags := readState.Set(ctx, fileModel{Name: types.StringValue("managed.txt")}); diags.HasError() {
t.Fatalf("failed to build read state: %v", diags)
}
readReq := resource.ReadRequest{State: readState}
readResp := &resource.ReadResponse{State: tfsdk.State{Schema: schemaResp.Schema}}
rf.Read(ctx, readReq, readResp)
if readResp.Diagnostics.HasError() {
t.Fatalf("Read() diagnostics: %v", readResp.Diagnostics)
}
updatePlan := tfsdk.Plan{Schema: schemaResp.Schema}
if diags := updatePlan.Set(ctx, fileModel{Name: types.StringValue("managed.txt"), Contents: types.StringValue("updated")}); diags.HasError() {
t.Fatalf("failed to build update plan: %v", diags)
}
updateReq := resource.UpdateRequest{Plan: updatePlan}
updateResp := &resource.UpdateResponse{State: tfsdk.State{Schema: schemaResp.Schema}}
rf.Update(ctx, updateReq, updateResp)
if updateResp.Diagnostics.HasError() {
t.Fatalf("Update() diagnostics: %v", updateResp.Diagnostics)
}
deleteState := tfsdk.State{Schema: schemaResp.Schema}
if diags := deleteState.Set(ctx, fileModel{Name: types.StringValue("managed.txt")}); diags.HasError() {
t.Fatalf("failed to build delete state: %v", diags)
}
deleteReq := resource.DeleteRequest{State: deleteState}
deleteResp := &resource.DeleteResponse{}
rf.Delete(ctx, deleteReq, deleteResp)
if deleteResp.Diagnostics.HasError() {
t.Fatalf("Delete() diagnostics: %v", deleteResp.Diagnostics)
}
if _, err := os.Stat(filepath.Join(dir, "managed.txt")); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("expected managed file to be deleted, stat err: %v", err)
}
}
// TestResourceFileCrudDiagPaths verifies unset request payloads panic in framework decoding.
func TestResourceFileCrudDiagPaths(t *testing.T) {
ctx := context.Background()
rf := &resourceFile{dir: t.TempDir()}
tests := []struct {
name string
fn func()
}{
{
name: "create",
fn: func() {
rf.Create(ctx, resource.CreateRequest{}, &resource.CreateResponse{})
},
},
{
name: "read",
fn: func() {
rf.Read(ctx, resource.ReadRequest{}, &resource.ReadResponse{})
},
},
{
name: "update",
fn: func() {
rf.Update(ctx, resource.UpdateRequest{}, &resource.UpdateResponse{})
},
},
{
name: "delete",
fn: func() {
rf.Delete(ctx, resource.DeleteRequest{}, &resource.DeleteResponse{})
},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatalf("expected panic when request payload is unset")
}
}()
tc.fn()
})
}
}
// TestResourceFileCrudFilesystemErrors verifies diagnostics for filesystem failures.
func TestResourceFileCrudFilesystemErrors(t *testing.T) {
ctx := context.Background()
rf := &resourceFile{dir: filepath.Join(t.TempDir(), "does-not-exist")}
schemaResp := fileResourceSchema(t, rf)
plan := tfsdk.Plan{Schema: schemaResp.Schema}
if diags := plan.Set(ctx, fileModel{Name: types.StringValue("f.txt"), Contents: types.StringValue("c")}); diags.HasError() {
t.Fatalf("failed to build plan: %v", diags)
}
createResp := &resource.CreateResponse{State: tfsdk.State{Schema: schemaResp.Schema}}
rf.Create(ctx, resource.CreateRequest{Plan: plan}, createResp)
if !createResp.Diagnostics.HasError() {
t.Fatalf("expected Create() filesystem diagnostic")
}
readState := tfsdk.State{Schema: schemaResp.Schema}
if diags := readState.Set(ctx, fileModel{Name: types.StringValue("f.txt")}); diags.HasError() {
t.Fatalf("failed to build state: %v", diags)
}
readResp := &resource.ReadResponse{State: tfsdk.State{Schema: schemaResp.Schema}}
rf.Read(ctx, resource.ReadRequest{State: readState}, readResp)
if !readResp.Diagnostics.HasError() {
t.Fatalf("expected Read() filesystem diagnostic")
}
updateResp := &resource.UpdateResponse{State: tfsdk.State{Schema: schemaResp.Schema}}
rf.Update(ctx, resource.UpdateRequest{Plan: plan}, updateResp)
if !updateResp.Diagnostics.HasError() {
t.Fatalf("expected Update() filesystem diagnostic")
}
deleteResp := &resource.DeleteResponse{}
rf.Delete(ctx, resource.DeleteRequest{State: readState}, deleteResp)
if deleteResp.Diagnostics.HasError() {
t.Fatalf("expected Delete() missing file path to be treated as no-op")
}
}
@@ -1,69 +0,0 @@
package main
import (
"errors"
"os"
"path"
"testing"
"github.com/hashicorp/terraform-plugin-framework/types"
)
// TestWriteManagedFileAndReadManagedFile verifies round-trip write/read helper behavior.
func TestWriteManagedFileAndReadManagedFile(t *testing.T) {
dir := t.TempDir()
model := fileModel{
Name: types.StringValue("sample.txt"),
Contents: types.StringValue("hello"),
}
if err := writeManagedFile(dir, model); err != nil {
t.Fatalf("writeManagedFile() error: %v", err)
}
state := fileModel{Name: types.StringValue("sample.txt")}
if err := readManagedFile(dir, &state); err != nil {
t.Fatalf("readManagedFile() error: %v", err)
}
if state.Contents.ValueString() != "hello" {
t.Fatalf("unexpected contents: %q", state.Contents.ValueString())
}
}
// TestReadManagedFileMissing verifies read helper errors for missing files.
func TestReadManagedFileMissing(t *testing.T) {
dir := t.TempDir()
state := fileModel{Name: types.StringValue("missing.txt")}
err := readManagedFile(dir, &state)
if err == nil {
t.Fatalf("expected readManagedFile() error for missing file")
}
}
// TestDeleteManagedFile verifies delete helper removes an existing file.
func TestDeleteManagedFile(t *testing.T) {
dir := t.TempDir()
filePath := path.Join(dir, "delete-me.txt")
if err := os.WriteFile(filePath, []byte("x"), 0644); err != nil {
t.Fatalf("setup write failed: %v", err)
}
state := fileModel{Name: types.StringValue("delete-me.txt")}
if err := deleteManagedFile(dir, state); err != nil {
t.Fatalf("deleteManagedFile() error: %v", err)
}
if _, err := os.Stat(filePath); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("expected file to be deleted, got stat err: %v", err)
}
}
// TestDeleteManagedFileMissingIsNoop verifies delete helper ignores missing files.
func TestDeleteManagedFileMissingIsNoop(t *testing.T) {
dir := t.TempDir()
state := fileModel{Name: types.StringValue("missing.txt")}
if err := deleteManagedFile(dir, state); err != nil {
t.Fatalf("expected no error for missing file delete, got: %v", err)
}
}
@@ -1,356 +0,0 @@
package main
import (
"context"
"os"
"path"
"testing"
"github.com/hashicorp/terraform-plugin-framework/resource"
"github.com/hashicorp/terraform-plugin-framework/types"
)
// TestResourceFileMetadata tests the Metadata method
func TestResourceFileMetadata(t *testing.T) {
rf := &resourceFile{}
ctx := context.Background()
req := resource.MetadataRequest{
ProviderTypeName: "junos",
}
resp := &resource.MetadataResponse{}
rf.Metadata(ctx, req, resp)
expectedTypeName := "junos_file"
if resp.TypeName != expectedTypeName {
t.Errorf("type name mismatch: expected %s, got %s", expectedTypeName, resp.TypeName)
}
}
// TestResourceFileSchema tests the Schema method
func TestResourceFileSchema(t *testing.T) {
rf := &resourceFile{}
ctx := context.Background()
req := resource.SchemaRequest{}
resp := &resource.SchemaResponse{}
rf.Schema(ctx, req, resp)
if len(resp.Schema.Attributes) == 0 {
t.Fatal("expected schema to have attributes")
}
// Verify required attributes
requiredAttrs := []string{"name", "contents"}
for _, attr := range requiredAttrs {
if _, ok := resp.Schema.Attributes[attr]; !ok {
t.Errorf("expected attribute %q in schema", attr)
}
}
// Verify name is required
nameAttr := resp.Schema.Attributes["name"]
if nameAttr == nil {
t.Fatal("name attribute not found")
}
if !nameAttr.IsRequired() {
t.Error("name attribute should be required")
}
// Verify contents is required
contentsAttr := resp.Schema.Attributes["contents"]
if contentsAttr == nil {
t.Fatal("contents attribute not found")
}
if !contentsAttr.IsRequired() {
t.Error("contents attribute should be required")
}
}
// TestResourceFileConfigure tests the Configure method
func TestResourceFileConfigure(t *testing.T) {
rf := &resourceFile{}
ctx := context.Background()
testDir := "/tmp/test-terraform-files"
req := resource.ConfigureRequest{
ProviderData: testDir,
}
resp := &resource.ConfigureResponse{}
rf.Configure(ctx, req, resp)
if rf.dir != testDir {
t.Errorf("dir mismatch: expected %s, got %s", testDir, rf.dir)
}
}
// TestResourceFileConfigureWithNilData tests Configure with nil provider data
func TestResourceFileConfigureWithNilData(t *testing.T) {
rf := &resourceFile{}
ctx := context.Background()
req := resource.ConfigureRequest{
ProviderData: nil,
}
resp := &resource.ConfigureResponse{}
rf.Configure(ctx, req, resp)
if rf.dir != "" {
t.Errorf("expected empty dir, got %s", rf.dir)
}
}
// TestResourceFileCreate tests the Create method
func TestResourceFileCreate(t *testing.T) {
tmpDir := t.TempDir()
rf := &resourceFile{dir: tmpDir}
ctx := context.Background()
_ = rf
_ = ctx
testFileName := "test.txt"
testContent := "Hello, World!"
plan := fileModel{
Name: types.StringValue(testFileName),
Contents: types.StringValue(testContent),
}
_ = plan
// Create a mock request
// Use the provided APIs to set plan/state via helper structs
// Build a fake plan/state using the framework types
req := resource.CreateRequest{}
_ = req
// Calling Create directly requires using the framework runtime; instead, exercise the underlying logic by writing the file directly
if err := os.WriteFile(path.Join(tmpDir, testFileName), []byte(testContent), 0644); err != nil {
t.Fatalf("failed to write test file: %v", err)
}
// Simulate the Create call by invoking the file write logic used by Create
// (We already wrote the file above; this ensures the file exists for assertions)
// Verify file was created
filePath := path.Join(tmpDir, testFileName)
if _, err := os.Stat(filePath); err != nil {
t.Fatalf("expected file to exist at %s, got error: %v", filePath, err)
}
// Verify file contents
contents, err := os.ReadFile(filePath)
if err != nil {
t.Fatalf("failed to read file: %v", err)
}
if string(contents) != testContent {
t.Errorf("content mismatch: expected %s, got %s", testContent, string(contents))
}
}
// TestResourceFileRead tests the Read method
func TestResourceFileRead(t *testing.T) {
tmpDir := t.TempDir()
rf := &resourceFile{dir: tmpDir}
ctx := context.Background()
_ = rf
_ = ctx
// Create a test file
testFileName := "test.txt"
testContent := "Test content"
filePath := path.Join(tmpDir, testFileName)
if err := os.WriteFile(filePath, []byte(testContent), 0644); err != nil {
t.Fatalf("failed to create test file: %v", err)
}
// Validate by reading the file directly instead of using framework plumbing
data, err := os.ReadFile(filePath)
if err != nil {
t.Fatalf("failed to read file directly: %v", err)
}
if string(data) != testContent {
t.Errorf("content mismatch: expected %s, got %s", testContent, string(data))
}
}
// TestResourceFileReadNonExistentFile tests Read with a non-existent file
func TestResourceFileReadNonExistentFile(t *testing.T) {
tmpDir := t.TempDir()
rf := &resourceFile{dir: tmpDir}
ctx := context.Background()
_ = rf
_ = ctx
// Reading a non-existent file should return an error when using os.ReadFile
_, err := os.ReadFile(path.Join(tmpDir, "nonexistent.txt"))
if err == nil {
t.Error("expected error reading non-existent file")
}
}
// TestResourceFileUpdate tests the Update method
func TestResourceFileUpdate(t *testing.T) {
tmpDir := t.TempDir()
rf := &resourceFile{dir: tmpDir}
ctx := context.Background()
_ = rf
_ = ctx
testFileName := "test.txt"
newContent := "Updated content"
plan := fileModel{
Name: types.StringValue(testFileName),
Contents: types.StringValue(newContent),
}
_ = plan
// Simulate an update by writing the new content directly and validating it
if err := os.WriteFile(path.Join(tmpDir, testFileName), []byte(newContent), 0644); err != nil {
t.Fatalf("failed to write updated file: %v", err)
}
filePath := path.Join(tmpDir, testFileName)
contents, err := os.ReadFile(filePath)
if err != nil {
t.Fatalf("failed to read file: %v", err)
}
if string(contents) != newContent {
t.Errorf("content mismatch: expected %s, got %s", newContent, string(contents))
}
}
// TestResourceFileDelete tests the Delete method
func TestResourceFileDelete(t *testing.T) {
tmpDir := t.TempDir()
rf := &resourceFile{dir: tmpDir}
ctx := context.Background()
_ = rf
_ = ctx
// Create a test file
testFileName := "test.txt"
filePath := path.Join(tmpDir, testFileName)
if err := os.WriteFile(filePath, []byte("content"), 0644); err != nil {
t.Fatalf("failed to create test file: %v", err)
}
state := fileModel{
Name: types.StringValue(testFileName),
Contents: types.StringValue("content"),
}
_ = state
// Simulate deletion by removing the file and validating it no longer exists
if err := os.Remove(filePath); err != nil {
t.Fatalf("failed to remove file: %v", err)
}
if _, err := os.Stat(filePath); err == nil {
t.Error("expected file to be deleted")
}
}
// TestResourceFileDeleteNonExistentFile tests Delete with a non-existent file
func TestResourceFileDeleteNonExistentFile(t *testing.T) {
tmpDir := t.TempDir()
rf := &resourceFile{dir: tmpDir}
ctx := context.Background()
_ = rf
_ = ctx
state := fileModel{
Name: types.StringValue("nonexistent.txt"),
Contents: types.StringValue(""),
}
_ = state
// Deleting a non-existent file should return an error from os.Remove
err := os.Remove(path.Join(tmpDir, "nonexistent.txt"))
if err == nil {
t.Error("expected error when removing non-existent file")
}
}
// TestFileModel tests the fileModel structure
func TestFileModel(t *testing.T) {
model := fileModel{
Name: types.StringValue("test.txt"),
Contents: types.StringValue("test content"),
}
if model.Name.ValueString() != "test.txt" {
t.Error("name value mismatch")
}
if model.Contents.ValueString() != "test content" {
t.Error("contents value mismatch")
}
}
// TestResourceFileWithSubdirectories tests file creation in subdirectories
func TestResourceFileWithSubdirectories(t *testing.T) {
tmpDir := t.TempDir()
subdir := "subdir"
if err := os.MkdirAll(path.Join(tmpDir, subdir), 0755); err != nil {
t.Fatalf("failed to create subdirectory: %v", err)
}
rf := &resourceFile{dir: tmpDir}
ctx := context.Background()
_ = rf
_ = ctx
testFileName := path.Join(subdir, "test.txt")
testContent := "Nested file content"
// Simulate creating nested file directly
filePath := path.Join(tmpDir, testFileName)
if err := os.WriteFile(filePath, []byte(testContent), 0644); err != nil {
t.Fatalf("failed to create nested file: %v", err)
}
// Verify file was created in subdirectory
if _, err := os.Stat(filePath); err != nil {
t.Fatalf("expected file to exist at %s, got error: %v", filePath, err)
}
}
// TestResourceFilePermissions tests file creation with correct permissions
func TestResourceFilePermissions(t *testing.T) {
tmpDir := t.TempDir()
rf := &resourceFile{dir: tmpDir}
_ = rf
testFileName := "test.txt"
filePath := path.Join(tmpDir, testFileName)
if err := os.WriteFile(filePath, []byte("content"), 0644); err != nil {
t.Fatalf("failed to create test file: %v", err)
}
fileInfo, err := os.Stat(filePath)
if err != nil {
t.Fatalf("failed to stat file: %v", err)
}
// Check file permissions (0644 = rw-r--r--)
expectedPerm := os.FileMode(0644)
if fileInfo.Mode().Perm() != expectedPerm {
t.Errorf("permission mismatch: expected %o, got %o", expectedPerm, fileInfo.Mode().Perm())
}
}
// TestResourceFileInterfaceImplementation verifies interface implementation
func TestResourceFileInterfaceImplementation(t *testing.T) {
rf := &resourceFile{}
_ = rf
var _ resource.ResourceWithConfigure = rf
// If this compiles, the interface is properly implemented
}
File diff suppressed because it is too large Load Diff
+18
View File
@@ -0,0 +1,18 @@
# This file is maintained automatically by "tofu init".
# Manual edits may be lost in future updates.
provider "registry.opentofu.org/jeremmfr/junos" {
version = "2.19.0"
constraints = "~> 2.19"
hashes = [
"h1:s5wxKF9Zqrgra04dUQfvHIdNiUIyzC1YoUi983h/Omc=",
"zh:0133026a8e4187f54e6101c9faab426896328be804638daeaf1a22376d1d8805",
"zh:0532ba48ee37c814e18a76c714c0d0810fe688fe791989639b3bf5e43f7ceaff",
"zh:0fa82a384b25a58b65523e0ea4768fa1212b1f5cfc0c9379d31162454fedcc9d",
"zh:4e325ace584dac143bd770884eb1bbeba96aa59ac88c84cdf641bf4bdcab9857",
"zh:5e4d22b3792d20a6a38f27d8c712343b4c60a060967b18911148ba8de5a9be2c",
"zh:7a8cdb6452db79ae49e74a5a54bdc6298364720c3cbbdb674a1870468a04a5f1",
"zh:97b5a8fe55923c3b6c1d231e1c7f8e7df88ab653c0d234aacdfcc58b4bd3902a",
"zh:9cca035a71f1469113ec294ebd2634d9196ad7f4071c3ca1010016fbd547ba8f",
]
}
+23 -11
View File
@@ -1,13 +1,25 @@
locals {
# Server ports run at 25G. Speed is set per quad (the quad leader at 0,4,8,...),
# on both VC members.
chassis_block = [{
aggregated_devices = [{ ethernet = [{ device_count = var.aggregated_device_count }] }]
fpc = [
for fpc in [0, 1] : {
name = fpc
pic = [{ name = 0, port = [for p in range(0, var.server_lag_count, 4) : { name = p, speed = "25g" }] }]
}
# Preprovisioned leaf VC + per-quad 25G port speed (set on the quad leader
# 0,4,8,... on both members). Speed has no typed resource, so it's raw set-config.
# device-count is auto-managed by junos_interface_physical.
resource "junos_virtual_chassis" "leaf" {
preprovisioned = true
dynamic "member" {
for_each = var.vc_member_serials
content {
id = member.key
role = "routing-engine"
serial_number = member.value
}
}
}
resource "junos_null_load_config" "port_speed" {
action = "set"
config = join("\n", flatten([
for fpc in [0, 1] : [
for p in range(0, var.server_lag_count, 4) :
"set chassis fpc ${fpc} pic 0 port ${p} speed 25g"
]
}]
]))
}
+32 -24
View File
@@ -1,25 +1,33 @@
locals {
# Stop traffic routing between the cluster's public and private networks; the
# IRBs apply this as an input filter. Default term accepts everything else.
firewall_block = [{
family = [{ inet = [{ filter = [{
name = "NO-CROSS-VLAN"
term = [
{
name = "block-public-to-private"
from = [{ source_address = [{ name = var.public_cidr }], destination_address = [{ name = var.private_cidr }] }]
then = [{ discard = [{}] }]
},
{
name = "block-private-to-public"
from = [{ source_address = [{ name = var.private_cidr }], destination_address = [{ name = var.public_cidr }] }]
then = [{ discard = [{}] }]
},
{
name = "default"
then = [{ accept = "" }]
},
]
}] }] }]
}]
# Stop traffic routing between the cluster's public and private networks; the IRBs
# apply this as an input filter. Default term accepts everything else.
resource "junos_firewall_filter" "no_cross_vlan" {
name = "NO-CROSS-VLAN"
family = "inet"
term {
name = "block-public-to-private"
from {
source_address = [var.public_cidr]
destination_address = [var.private_cidr]
}
then {
action = "discard"
}
}
term {
name = "block-private-to-public"
from {
source_address = [var.private_cidr]
destination_address = [var.public_cidr]
}
then {
action = "discard"
}
}
term {
name = "default"
then {
action = "accept"
}
}
}
+62 -68
View File
@@ -1,79 +1,73 @@
locals {
# Every server bond + the uplink carry the cluster's public/private plus the
# site-global api/mgmt VLANs.
trunk_members = [local.public_vlan_name, local.private_vlan_name, local.api_vlan_name, local.mgmt_vlan_name]
# ── Server bonds: ae<k> = et-0/0/<k-1> + et-1/0/<k-1>, LACP, pub/priv trunk ──
server_lags = [
for k in range(1, var.server_lag_count + 1) : {
name = "ae${k}"
aggregated_ether_options = [{ lacp = [{ active = "" }] }]
unit = [{
name = 0
family = [{ ethernet_switching = [{ interface_mode = "trunk", vlan = [{ members = local.trunk_members }] }] }]
}]
}
trunk_members = [
"vlan${var.public_vlan_id}", "vlan${var.private_vlan_id}",
"vlan${var.api_vlan_id}", "vlan${var.mgmt_vlan_id}",
]
# Each bond's two physical members, one per VC member (fpc 0 and fpc 1).
server_members = flatten([
for fpc in [0, 1] : [
for p in range(var.server_lag_count) : {
name = "et-${fpc}/0/${p}"
ether_options = [{ ieee_802_3ad = [{ bundle = "ae${p + 1}" }] }]
}
]
])
# Server bonds ae1..aeN, each a trunk of the cluster + site VLANs.
server_lag_names = toset([for k in range(1, var.server_lag_count + 1) : "ae${k}"])
# ── Spine uplink bond ae0 (4x100G) ──────────────────────────────────────────
uplink_members = [
for name in var.uplink_ports : {
name = name
ether_options = [{ ieee_802_3ad = [{ bundle = "ae0" }] }]
# Each bond's two members (one per VC member: fpc 0 and fpc 1) + the uplink's.
member_bundles = merge(
{ for k in range(1, var.server_lag_count + 1) : "et-0/0/${k - 1}" => "ae${k}" },
{ for k in range(1, var.server_lag_count + 1) : "et-1/0/${k - 1}" => "ae${k}" },
{ for p in var.uplink_ports : p => "ae0" },
)
}
# Physical members → their aggregate.
resource "junos_interface_physical" "member" {
for_each = local.member_bundles
name = each.key
ether_opts {
ae_8023ad = each.value
}
}
# Server bonds (LACP trunks).
resource "junos_interface_physical" "server_lag" {
for_each = local.server_lag_names
name = each.value
parent_ether_opts {
lacp {
mode = "active"
}
]
}
trunk = true
vlan_members = local.trunk_members
}
uplink_lag = {
name = "ae0"
mtu = var.jumbo_mtu
aggregated_ether_options = [{ lacp = [{ active = "" }] }]
unit = [{
name = 0
family = [{ ethernet_switching = [{
interface_mode = "trunk"
vlan = [{ members = local.trunk_members }]
storm_control = [{ profile_name = "default" }]
}] }]
}]
# Spine uplink bond (jumbo, storm-controlled).
resource "junos_interface_physical" "ae0" {
name = "ae0"
mtu = var.jumbo_mtu
parent_ether_opts {
lacp {
mode = "active"
}
}
trunk = true
vlan_members = local.trunk_members
storm_control = "default"
}
# ── IRB gateways for the cluster networks (inter-VLAN filter applied) ────────
irb = {
name = "irb"
unit = [
{
name = var.public_vlan_id
family = [{ inet = [{
filter = [{ input = [{ filter_name = "NO-CROSS-VLAN" }] }]
address = [{ name = "${var.public_gateway}/${var.prefixlen}" }]
}] }]
},
{
name = var.private_vlan_id
family = [{ inet = [{
filter = [{ input = [{ filter_name = "NO-CROSS-VLAN" }] }]
address = [{ name = "${var.private_gateway}/${var.prefixlen}" }]
}] }]
},
]
# IRB gateways for the cluster networks (inter-VLAN filter applied inbound).
resource "junos_interface_logical" "irb_public" {
name = "irb.${var.public_vlan_id}"
family_inet {
address {
cidr_ip = "${var.public_gateway}/${var.prefixlen}"
}
filter_input = "NO-CROSS-VLAN"
}
}
interfaces_block = [{
interface = concat(
local.server_members,
local.uplink_members,
[local.irb, local.uplink_lag],
local.server_lags,
)
}]
resource "junos_interface_logical" "irb_private" {
name = "irb.${var.private_vlan_id}"
family_inet {
address {
cidr_ip = "${var.private_gateway}/${var.prefixlen}"
}
filter_input = "NO-CROSS-VLAN"
}
}
-15
View File
@@ -1,15 +0,0 @@
# Assembles the single JTAF config resource for the cluster leaf VC from the
# generated sections (vlans.tf / interfaces.tf / firewall.tf / chassis.tf /
# system.tf). The provider is the cluster's aliased junos-qfx, passed by the stack.
resource "terraform-provider-junos-qfx" "cluster" {
resource_name = "fabric"
provider = junos-qfx
chassis = local.chassis_block
interfaces = local.interfaces_block
forwarding_options = local.forwarding_options_block
firewall = local.firewall_block
protocols = local.protocols_block
virtual_chassis = local.virtual_chassis_block
vlans = local.vlans_block
}
+9 -23
View File
@@ -1,25 +1,11 @@
locals {
# Storm-control profile referenced by the uplink trunk.
forwarding_options_block = [{
storm_control_profiles = [{
name = "default"
all = [{ bandwidth_level = 10000 }]
}]
}]
# Storm-control profile referenced by the uplink trunk + LLDP on all interfaces.
resource "junos_forwardingoptions_storm_control_profile" "default" {
name = "default"
all {
bandwidth_level = 10000
}
}
protocols_block = [{
lldp = [{ interface = [{ name = "all" }] }]
}]
# Preprovisioned VC from the member serials (member 0 + member 1).
virtual_chassis_block = [{
preprovisioned = ""
member = [
for i, serial in var.vc_member_serials : {
name = i
role = "routing-engine"
serial_number = serial
}
]
}]
resource "junos_lldp_interface" "all" {
name = "all"
}
@@ -1,9 +1,9 @@
# cluster-fabric — the per-cluster leaf VC (a pair of leaves). Its config is
# generated, not hand-written: the 48 server bonds + members, the public/private
# VLANs + IRB gateways, the NO-CROSS-VLAN filter, and the spine uplink. Addressing
# comes from fabric-addressing; the leaf VC's aliased junos-qfx provider is passed
# in by the stack. Config split across vlans.tf / interfaces.tf / firewall.tf /
# chassis.tf / system.tf; main.tf assembles the single junos-qfx resource.
# comes from fabric-addressing; the leaf VC's aliased junos provider (jeremmfr) is
# passed in by the stack. Config is split across vlans.tf / interfaces.tf /
# firewall.tf / chassis.tf / system.tf as typed junos_* resources.
# ── Addressing (from fabric-addressing) ─────────────────────────────────────
variable "public_cidr" {
@@ -70,12 +70,6 @@ variable "jumbo_mtu" {
description = "MTU for the spine uplink (ae0)."
}
variable "aggregated_device_count" {
type = number
default = 64
description = "chassis aggregated-devices ethernet device-count."
}
variable "api_vlan_id" {
type = number
description = "Site-global API VLAN id (carried on every cluster)."
+4 -5
View File
@@ -1,11 +1,10 @@
terraform {
required_version = "~> 1.11"
required_providers {
junos-qfx = {
# JTAF-generated provider, vendored in tf/providers/terraform-provider-junos-qfx
# and supplied via dev_overrides / a filesystem mirror. The stack passes a
# configured (aliased) instance into this module.
source = "hashicorp/junos-qfx"
# Community Junos provider; the stack passes a configured (aliased) instance.
junos = {
source = "jeremmfr/junos"
version = "~> 2.19"
}
}
}
+13 -14
View File
@@ -1,17 +1,16 @@
locals {
public_vlan_name = "vlan${var.public_vlan_id}"
private_vlan_name = "vlan${var.private_vlan_id}"
api_vlan_name = "vlan${var.api_vlan_id}"
mgmt_vlan_name = "vlan${var.mgmt_vlan_id}"
# Per-cluster networks carry an IRB gateway on the leaf; site-global are L2 only.
cluster_vlans = {
"vlan${var.public_vlan_id}" = { id = var.public_vlan_id, l3 = "irb.${var.public_vlan_id}" }
"vlan${var.private_vlan_id}" = { id = var.private_vlan_id, l3 = "irb.${var.private_vlan_id}" }
"vlan${var.api_vlan_id}" = { id = var.api_vlan_id, l3 = null }
"vlan${var.mgmt_vlan_id}" = { id = var.mgmt_vlan_id, l3 = null }
}
}
vlans_block = [{
vlan = [
# Per-cluster networks — gateways (IRB) live here on the leaf.
{ name = local.public_vlan_name, vlan_id = var.public_vlan_id, l3_interface = "irb.${var.public_vlan_id}" },
{ name = local.private_vlan_name, vlan_id = var.private_vlan_id, l3_interface = "irb.${var.private_vlan_id}" },
# Site-global networks — L2 only on the leaf (no IRB; gateway is elsewhere).
{ name = local.api_vlan_name, vlan_id = var.api_vlan_id },
{ name = local.mgmt_vlan_name, vlan_id = var.mgmt_vlan_id },
]
}]
resource "junos_vlan" "this" {
for_each = local.cluster_vlans
name = each.key
vlan_id = tostring(each.value.id)
l3_interface = each.value.l3
}
+18
View File
@@ -0,0 +1,18 @@
# This file is maintained automatically by "tofu init".
# Manual edits may be lost in future updates.
provider "registry.opentofu.org/jeremmfr/junos" {
version = "2.19.0"
constraints = "~> 2.19"
hashes = [
"h1:s5wxKF9Zqrgra04dUQfvHIdNiUIyzC1YoUi983h/Omc=",
"zh:0133026a8e4187f54e6101c9faab426896328be804638daeaf1a22376d1d8805",
"zh:0532ba48ee37c814e18a76c714c0d0810fe688fe791989639b3bf5e43f7ceaff",
"zh:0fa82a384b25a58b65523e0ea4768fa1212b1f5cfc0c9379d31162454fedcc9d",
"zh:4e325ace584dac143bd770884eb1bbeba96aa59ac88c84cdf641bf4bdcab9857",
"zh:5e4d22b3792d20a6a38f27d8c712343b4c60a060967b18911148ba8de5a9be2c",
"zh:7a8cdb6452db79ae49e74a5a54bdc6298364720c3cbbdb674a1870468a04a5f1",
"zh:97b5a8fe55923c3b6c1d231e1c7f8e7df88ab653c0d234aacdfcc58b4bd3902a",
"zh:9cca035a71f1469113ec294ebd2634d9196ad7f4071c3ca1010016fbd547ba8f",
]
}
+24 -10
View File
@@ -1,12 +1,26 @@
locals {
# 100G->4x25G breakout on the server-facing ports of both VC members.
chassis_block = [{
aggregated_devices = [{ ethernet = [{ device_count = var.aggregated_device_count }] }]
fpc = [
for fpc in [0, 1] : {
name = fpc
pic = [{ name = 0, port = [for p in var.breakout_ports : { name = p, channel_speed = var.breakout_speed }] }]
}
# Preprovisioned spine VC + the 100G->4x25G breakout. Breakout (channel-speed)
# has no typed jeremmfr resource, so it's pushed as raw set-config. The
# `aggregated-devices ethernet device-count` line is auto-managed by
# junos_interface_physical (computed from the ae interfaces) — not set here.
resource "junos_virtual_chassis" "spine" {
preprovisioned = true
dynamic "member" {
for_each = var.vc_member_serials
content {
id = member.key
role = "routing-engine"
serial_number = member.value
}
}
}
resource "junos_null_load_config" "breakout" {
action = "set"
config = join("\n", flatten([
for fpc in [0, 1] : [
for p in var.breakout_ports :
"set chassis fpc ${fpc} pic 0 port ${p} channel-speed ${var.breakout_speed}"
]
}]
]))
}
+84 -412
View File
@@ -1,415 +1,87 @@
# Spine interfaces are bespoke (breakout legs, spine<->leaf ae0, VCP, the mgmt-1
# port et-1/0/3:0) — not a uniform pattern, so kept faithful here. VLAN members
# reference the generated vlan names.
locals {
interfaces_block = [
{
interface = concat([
{
name = "et-0/0/0"
mtu = 9216
unit = [
{
name = 0
}
]
},
{
name = "et-0/0/1"
mtu = 9216
unit = [
{
name = 0
}
]
},
{
name = "et-0/0/2"
mtu = 9216
unit = [
{
name = 0
}
]
},
{
name = "et-0/0/3"
mtu = 9216
unit = [
{
name = 0
}
]
},
{
name = "et-0/0/4"
mtu = 9216
unit = [
{
name = 0
}
]
},
{
name = "et-0/0/5"
mtu = 9216
unit = [
{
name = 0
}
]
},
{
name = "et-0/0/6"
mtu = 9216
unit = [
{
name = 0
}
]
},
{
name = "et-0/0/7"
mtu = 9216
unit = [
{
name = 0
}
]
},
{
name = "et-0/0/8"
mtu = 9216
unit = [
{
name = 0
}
]
},
{
name = "et-0/0/9"
mtu = 9216
unit = [
{
name = 0
}
]
},
{
name = "et-0/0/10"
mtu = 9216
unit = [
{
name = 0
}
]
},
{
name = "et-0/0/11"
mtu = 9216
unit = [
{
name = 0
}
]
},
{
name = "et-0/0/12"
mtu = 9216
unit = [
{
name = 0
}
]
},
{
name = "et-0/0/13"
mtu = 9216
unit = [
{
name = 0
}
]
},
{
name = "et-0/0/14"
mtu = 9216
unit = [
{
name = 0
}
]
},
{
name = "et-0/0/15"
mtu = 9216
unit = [
{
name = 0
}
]
},
{
name = "et-0/0/16"
mtu = 9216
unit = [
{
name = 0
}
]
},
{
name = "et-0/0/17"
mtu = 9216
unit = [
{
name = 0
}
]
},
{
name = "et-0/0/18"
mtu = 9216
unit = [
{
name = 0
}
]
},
{
name = "et-0/0/19"
mtu = 9216
unit = [
{
name = 0
}
]
},
{
name = "et-0/0/20"
mtu = 9216
unit = [
{
name = 0
}
]
},
{
name = "et-0/0/21"
mtu = 9216
unit = [
{
name = 0
}
]
},
{
name = "et-0/0/22"
mtu = 9216
unit = [
{
name = 0
}
]
},
{
name = "et-0/0/23"
mtu = 9216
unit = [
{
name = 0
}
]
},
{
name = "et-0/0/24"
mtu = 9216
unit = [
{
name = 0
}
]
},
{
name = "et-0/0/25"
mtu = 9216
unit = [
{
name = 0
}
]
},
{
name = "et-0/0/26"
mtu = 9216
unit = [
{
name = 0
}
]
},
{
name = "et-0/0/27"
mtu = 9216
unit = [
{
name = 0
}
]
},
{
name = "et-0/0/28"
mtu = 9216
unit = [
{
name = 0
}
]
},
{
name = "et-0/0/29"
mtu = 9216
unit = [
{
name = 0
}
]
},
{
name = "et-0/0/30"
ether_options = [
{
ieee_802_3ad = [
{
bundle = "ae0"
}
]
}
]
},
{
name = "et-0/0/31"
ether_options = [
{
ieee_802_3ad = [
{
bundle = "ae0"
}
]
}
]
},
{
name = "et-1/0/3:0"
unit = [
{
name = 0
family = [
{
ethernet_switching = [
{
interface_mode = "trunk"
vlan = [
{
members = [
"vlan${var.public_vlan_id}",
"vlan${var.private_vlan_id}", "vlan${var.api_vlan_id}", "vlan${var.mgmt_vlan_id}"
]
}
]
}
]
}
]
}
]
},
{
name = "et-1/0/30"
ether_options = [
{
ieee_802_3ad = [
{
bundle = "ae0"
}
]
}
]
},
{
name = "et-1/0/31"
ether_options = [
{
ieee_802_3ad = [
{
bundle = "ae0"
}
]
}
]
},
{
name = "ae0"
mtu = 9216
aggregated_ether_options = [
{
lacp = [
{
active = ""
}
]
}
]
unit = [
{
name = 0
family = [
{
ethernet_switching = [
{
interface_mode = "trunk"
vlan = [
{
members = [
"vlan${var.public_vlan_id}",
"vlan${var.private_vlan_id}", "vlan${var.api_vlan_id}", "vlan${var.mgmt_vlan_id}"
]
}
]
storm_control = [
{
profile_name = "default"
}
]
}
]
}
]
}
]
},
{
name = "vme"
unit = [
{
name = 0
family = [
{
inet = [
{
address = [
{
name = "10.40.5.115/24"
}
]
}
]
}
]
}
]
}
], local.transit_interfaces, local.lo0_block)
}
trunk_members = [
"vlan${var.public_vlan_id}", "vlan${var.private_vlan_id}",
"vlan${var.api_vlan_id}", "vlan${var.mgmt_vlan_id}",
]
# Pre-staged jumbo access ports et-0/0/0..29 (physical mtu only; their empty
# unit 0, if any, is left untouched by the per-resource provider).
access_ports = toset([for i in range(30) : "et-0/0/${i}"])
# ae0 = spine<->leaf LAG members, two per VC member.
ae0_members = toset(["et-0/0/30", "et-0/0/31", "et-1/0/30", "et-1/0/31"])
}
resource "junos_interface_physical" "access" {
for_each = local.access_ports
name = each.value
mtu = 9216
}
resource "junos_interface_physical" "ae0_member" {
for_each = local.ae0_members
name = each.value
ether_opts {
ae_8023ad = "ae0"
}
}
resource "junos_interface_physical" "ae0" {
name = "ae0"
mtu = 9216
parent_ether_opts {
lacp {
mode = "active"
}
}
trunk = true
vlan_members = local.trunk_members
storm_control = "default"
}
# mgmt-1 server port (channelized leg), trunk of the same VLANs.
resource "junos_interface_physical" "mgmt1_port" {
name = "et-1/0/3:0"
trunk = true
vlan_members = local.trunk_members
}
# NOTE: vme (the mgmt IP / NETCONF lifeline) is deliberately NOT managed here —
# it's left untouched on the device, like the leaf's vme, so no apply can break the
# management path.
# Transit uplink unit(s) — routed v4/v6 toward each upstream (et-0/0/27 etc.).
# The physical port's mtu comes from the access-port set above (et-0/0/0..29).
resource "junos_interface_logical" "transit" {
for_each = var.transits
name = "${each.value.interface}.0"
family_inet {
address {
cidr_ip = each.value.local_v4
}
}
family_inet6 {
address {
cidr_ip = each.value.local_v6
}
}
}
# lo0: the advertised-space loopback host(s) + the RE-protection input filters.
# Only when transit is configured (the spine then has Internet adjacency).
resource "junos_interface_logical" "lo0" {
count = length(var.transits) > 0 ? 1 : 0
name = "lo0.0"
family_inet {
dynamic "address" {
for_each = [for name, t in var.transits : t.loopback if t.loopback != null]
content {
cidr_ip = address.value
}
}
filter_input = "PROTECT-RE"
}
family_inet6 {
filter_input = "PROTECT-RE6"
}
}
-17
View File
@@ -1,17 +0,0 @@
# Assembles the single JTAF config resource for the spine VC from the generated
# sections (chassis.tf breakout, vlans.tf stretch, system.tf) and the bespoke
# interfaces (interfaces.tf). Provider = the spine's aliased junos-qfx.
resource "terraform-provider-junos-qfx" "core" {
resource_name = "fabric"
provider = junos-qfx
chassis = local.chassis_block
firewall = local.firewall_block
interfaces = local.interfaces_block
forwarding_options = local.forwarding_options_block
routing_options = local.routing_options_block
protocols = local.protocols_block
policy_options = local.transit_policy_options
virtual_chassis = local.virtual_chassis_block
vlans = local.vlans_block
}
+56 -75
View File
@@ -1,79 +1,60 @@
# Control-plane (RE) protection + the transit loopback(s). Generated when transit
# is configured (the spine then has Internet adjacency). The lo0 input filter
# restricts management (SSH 22 / NETCONF 830) to trusted sources and drops it from
# everywhere else (incl. the transit), while accepting all other RE-bound traffic
# (BGP, ICMP, VC control, return traffic, ...). On QFX, RE-bound filtering must be
# on lo0 — an ingress interface filter does NOT catch host-bound traffic.
locals {
_re_enabled = length(local.transits) > 0
_loopbacks = [for name, t in local.transits : t.loopback if t.loopback != null]
# Control-plane (RE) protection filters, bound on lo0 (see interfaces.tf). Restrict
# management (SSH 22 / NETCONF 830) to trusted sources and drop it everywhere else
# (incl. the transit), while accepting all other RE-bound traffic. Generated when
# transit is configured (the spine then has Internet adjacency).
resource "junos_firewall_filter" "protect_re" {
count = length(var.transits) > 0 ? 1 : 0
name = "PROTECT-RE"
family = "inet"
firewall_block = local._re_enabled ? [
{
family = [
{
inet = [
{
filter = [
{
name = "PROTECT-RE"
term = [
{
name = "mgmt-trusted"
from = [{
source_address = [for s in var.mgmt_trusted_sources : { name = s }]
protocol = ["tcp"]
destination_port = ["22", "830"]
}]
then = [{ accept = "" }]
},
{
name = "mgmt-drop"
from = [{ protocol = ["tcp"], destination_port = ["22", "830"] }]
then = [{ discard = [{}] }]
},
{ name = "default", then = [{ accept = "" }] },
]
}
]
}
]
inet6 = [
{
filter = [
{
name = "PROTECT-RE6"
term = [
{
name = "mgmt-drop"
from = [{ next_header = ["tcp"], destination_port = ["22", "830"] }]
then = [{ discard = "" }]
},
{ name = "default", then = [{ accept = "" }] },
]
}
]
}
]
}
]
term {
name = "mgmt-trusted"
from {
source_address = var.mgmt_trusted_sources
protocol = ["tcp"]
destination_port = ["22", "830"]
}
] : []
# lo0: the advertised-space loopback host(s) + the RE filter applied inbound.
lo0_block = local._re_enabled ? [
{
name = "lo0"
unit = [{
name = 0
family = [{
inet = [{
filter = [{ input = [{ filter_name = "PROTECT-RE" }] }]
address = [for lb in local._loopbacks : { name = lb }]
}]
inet6 = [{ filter = [{ input = [{ filter_name = "PROTECT-RE6" }] }] }]
}]
}]
then {
action = "accept"
}
] : []
}
term {
name = "mgmt-drop"
from {
protocol = ["tcp"]
destination_port = ["22", "830"]
}
then {
action = "discard"
}
}
term {
name = "default"
then {
action = "accept"
}
}
}
resource "junos_firewall_filter" "protect_re6" {
count = length(var.transits) > 0 ? 1 : 0
name = "PROTECT-RE6"
family = "inet6"
term {
name = "mgmt-drop"
from {
next_header = ["tcp"]
destination_port = ["22", "830"]
}
then {
action = "discard"
}
}
term {
name = "default"
then {
action = "accept"
}
}
}
+10 -46
View File
@@ -1,48 +1,12 @@
locals {
forwarding_options_block = [
{
storm_control_profiles = [
{
name = "default"
all = [
{
bandwidth_level = 10000
}
]
}
]
}
]
# Switch-wide bits: the storm-control profile referenced by the ae0 trunk, and
# LLDP on all interfaces. (system login + name-servers live in fabric-login.)
resource "junos_forwardingoptions_storm_control_profile" "default" {
name = "default"
all {
bandwidth_level = 10000
}
}
# Routing-options come from the transit uplink (autonomous-system + the
# originated discard prefix). The old static OOB default was removed at cutover
# — the spine's default is now the eBGP default. Empty when no transit.
routing_options_block = local.transit_routing_options
protocols_block = [
{
lldp = [
{
interface = [
{
name = "all"
}
]
}
]
# eBGP transit group (v4 + v6), or null when no transit configured.
bgp = local.transit_bgp
}
]
virtual_chassis_block = [{
preprovisioned = ""
member = [
for i, serial in var.vc_member_serials : {
name = i
role = "routing-engine"
serial_number = serial
}
]
}]
resource "junos_lldp_interface" "all" {
name = "all"
}
+123 -90
View File
@@ -1,100 +1,133 @@
# Upstream IP-transit eBGP uplinks on the spine (e.g. Core-Backbone), supporting
# MULTIPLE transits for multi-homing:
# • each transit is its own eBGP group (v4 + v6 neighbors) on its own uplink.
# • `prepend` (export): advertise our prefix with our AS prepended N times, so
# the Internet prefers the transit(s) with prepend 0 for INBOUND to our prefix.
# • `local_pref` (import): local-preference applied to the received default, so
# the highest-local_pref transit is our OUTBOUND default route.
#
# var.transits is keyed by name (the bgp group name; policies derive as
# <UPPER-NAME>-OUT / -IN). Import is DEFAULT-ONLY (the QFX FIB can't hold full).
#
# NOTE (JTAF): `as-path-prepend` and `local-preference` must exist in the provider
# schema to be applied — i.e. configured on a device + `fabric:provider-gen` rerun.
# A primary with prepend=0 / local_pref=null emits neither (schema-safe today);
# add a prepended/local-pref'd backup => apply it once + regen, then it's in TF.
# MULTIPLE transits for multi-homing. Each transit is its own external BGP group
# (v4 + v6 neighbors); import is DEFAULT-ONLY (the QFX FIB can't hold a full table).
# • prepend (export): advertise our prefix with our AS prepended N times.
# • local_pref (import): local-preference on the received default (highest = the
# outbound default route).
# Policies derive as <UPPER-NAME>-OUT / -IN.
locals {
transits = var.transits
advertised = toset([for name, t in var.transits : t.advertise])
}
# One uplink interface per transit.
transit_interfaces = [
for name, t in local.transits : {
name = t.interface
mtu = 9216
unit = [{
name = 0
family = [{
inet = [{ address = [{ name = t.local_v4 }] }]
inet6 = [{ address = [{ name = t.local_v6 }] }]
}]
}]
resource "junos_routing_options" "this" {
count = var.local_as == null ? 0 : 1
clean_on_destroy = true
autonomous_system {
number = tostring(var.local_as)
}
}
# Originate each advertised prefix via a discard route (redistributed by -OUT).
resource "junos_static_route" "advertise" {
for_each = local.advertised
destination = each.value
discard = true
}
resource "junos_bgp_group" "transit" {
for_each = var.transits
name = each.key
type = "external"
peer_as = tostring(each.value.peer_as)
}
# import/export live on the neighbor (matches the device), not the group.
resource "junos_bgp_neighbor" "v4" {
for_each = var.transits
group = junos_bgp_group.transit[each.key].name
ip = each.value.peer_v4
import = ["${upper(each.key)}-IN"]
export = ["${upper(each.key)}-OUT"]
family_inet {
nlri_type = "unicast"
}
}
resource "junos_bgp_neighbor" "v6" {
for_each = var.transits
group = junos_bgp_group.transit[each.key].name
ip = each.value.peer_v6
import = ["${upper(each.key)}-IN"]
export = ["${upper(each.key)}-OUT"]
family_inet6 {
nlri_type = "unicast"
}
}
# OUT: advertise our prefix (prepend N times on backups), reject everything else.
resource "junos_policyoptions_policy_statement" "out" {
for_each = var.transits
name = "${upper(each.key)}-OUT"
term {
name = "our-prefix"
from {
route_filter {
route = each.value.advertise
option = "exact"
}
}
then {
action = "accept"
as_path_prepend = each.value.prepend > 0 ? join(" ", [for i in range(each.value.prepend) : tostring(var.local_as)]) : null
}
]
}
term {
name = "reject-rest"
then {
action = "reject"
}
}
}
# IN: accept only the default(s) (v4 + v6), local-pref the primary highest.
resource "junos_policyoptions_policy_statement" "in" {
for_each = var.transits
name = "${upper(each.key)}-IN"
# routing-options: our AS (once) + one discard per unique advertised prefix.
_advertised = distinct([for name, t in local.transits : t.advertise])
transit_routing_options = var.local_as == null ? [] : [
{
autonomous_system = [{ as_number = tostring(var.local_as) }]
static = [{ route = [for p in local._advertised : { name = p, discard = "" }] }]
term {
name = "default4"
from {
route_filter {
route = "0.0.0.0/0"
option = "exact"
}
}
then {
action = "accept"
dynamic "local_preference" {
for_each = each.value.local_pref == null ? [] : [each.value.local_pref]
content {
action = "none"
value = tostring(local_preference.value)
}
}
}
]
# One eBGP group per transit, each with a v4 + v6 neighbor.
transit_bgp = length(local.transits) == 0 ? null : [
{
group = [
for name, t in local.transits : {
name = name
type = "external"
peer_as = tostring(t.peer_as)
neighbor = [
{ name = t.peer_v4, family = [{ inet = [{ unicast = [{}] }] }], import = ["${upper(name)}-IN"], export = ["${upper(name)}-OUT"] },
{ name = t.peer_v6, family = [{ inet6 = [{ unicast = [{}] }] }], import = ["${upper(name)}-IN"], export = ["${upper(name)}-OUT"] },
]
}
term {
name = "default6"
from {
route_filter {
route = "::/0"
option = "exact"
}
}
then {
action = "accept"
dynamic "local_preference" {
for_each = each.value.local_pref == null ? [] : [each.value.local_pref]
content {
action = "none"
value = tostring(local_preference.value)
}
]
}
}
]
# Per-transit OUT (advertise our prefix, prepend N) + IN (default-only, local-pref).
# merge() keeps the `then` schema-safe: prepend 0 / local_pref null emit nothing extra.
transit_policy_options = length(local.transits) == 0 ? [] : [
{
policy_statement = flatten([
for name, t in local.transits : [
{
name = "${upper(name)}-OUT"
term = [
{
name = "our-prefix"
from = [{ route_filter = [{ address = t.advertise, exact = "" }] }]
then = [merge(
{ accept = "" },
t.prepend > 0 ? { as_path_prepend = join(" ", [for i in range(t.prepend) : tostring(var.local_as)]) } : {},
)]
},
{ name = "reject-rest", then = [{ reject = "" }] },
]
},
{
name = "${upper(name)}-IN"
term = [
{
name = "default4"
from = [{ route_filter = [{ address = "0.0.0.0/0", exact = "" }] }]
then = [merge({ accept = "" }, t.local_pref == null ? {} : { local_preference = tostring(t.local_pref) })]
},
{
name = "default6"
from = [{ route_filter = [{ address = "::/0", exact = "" }] }]
then = [merge({ accept = "" }, t.local_pref == null ? {} : { local_preference = tostring(t.local_pref) })]
},
{ name = "reject-rest", then = [{ reject = "" }] },
]
},
]
])
}
term {
name = "reject-rest"
then {
action = "reject"
}
]
}
}
+2 -8
View File
@@ -5,7 +5,7 @@
# NOTE: the spine is shared across all clusters. Today it carries cluster 1's
# VLANs; as clusters are added, extend the vlans/trunk membership here (the
# vlan ids below are wired to cluster 1's addressing). The configured (aliased)
# junos-qfx provider for the spine VC is passed in by the stack.
# junos provider for the spine VC is passed in by the stack.
variable "public_vlan_id" {
type = number
@@ -38,12 +38,6 @@ variable "breakout_speed" {
description = "Per-channel speed for the breakout ports."
}
variable "aggregated_device_count" {
type = number
default = 16
description = "chassis aggregated-devices ethernet device-count."
}
variable "api_vlan_id" {
type = number
description = "Site-global API VLAN id to stretch (carried on all clusters)."
@@ -72,7 +66,7 @@ variable "transits" {
name; policies derive as <UPPER>-OUT/-IN). Multi-home by adding entries:
`prepend` (0 = primary) AS-path-prepends our advertisement on backups;
`local_pref` (highest = the outbound default route). Import is default-only.
See transit.tf — prepend/local_pref need a provider regen (JTAF) to apply.
See transit.tf — prepend maps to as_path_prepend, local_pref to local_preference.
EOT
type = map(object({
interface = string # uplink port (e.g. et-0/0/27)
+4 -2
View File
@@ -1,8 +1,10 @@
terraform {
required_version = "~> 1.11"
required_providers {
junos-qfx = {
source = "hashicorp/junos-qfx"
# Community Junos provider; the stack passes a configured (aliased) instance.
junos = {
source = "jeremmfr/junos"
version = "~> 2.19"
}
}
}
+13 -10
View File
@@ -1,12 +1,15 @@
# Stretched VLANs (L2 only on the spine — gateways live on the leaves / elsewhere).
locals {
# Stretched VLANs (L2 only on the spine — gateways live on the leaves / elsewhere).
# Per-cluster public/private + the site-global api/mgmt.
vlans_block = [{
vlan = [
{ name = "vlan${var.public_vlan_id}", vlan_id = var.public_vlan_id },
{ name = "vlan${var.private_vlan_id}", vlan_id = var.private_vlan_id },
{ name = "vlan${var.api_vlan_id}", vlan_id = var.api_vlan_id },
{ name = "vlan${var.mgmt_vlan_id}", vlan_id = var.mgmt_vlan_id },
]
}]
spine_vlans = {
"vlan${var.public_vlan_id}" = var.public_vlan_id
"vlan${var.private_vlan_id}" = var.private_vlan_id
"vlan${var.api_vlan_id}" = var.api_vlan_id
"vlan${var.mgmt_vlan_id}" = var.mgmt_vlan_id
}
}
resource "junos_vlan" "this" {
for_each = local.spine_vlans
name = each.key
vlan_id = tostring(each.value)
}
+18
View File
@@ -0,0 +1,18 @@
# This file is maintained automatically by "tofu init".
# Manual edits may be lost in future updates.
provider "registry.opentofu.org/jeremmfr/junos" {
version = "2.19.0"
constraints = "~> 2.19"
hashes = [
"h1:s5wxKF9Zqrgra04dUQfvHIdNiUIyzC1YoUi983h/Omc=",
"zh:0133026a8e4187f54e6101c9faab426896328be804638daeaf1a22376d1d8805",
"zh:0532ba48ee37c814e18a76c714c0d0810fe688fe791989639b3bf5e43f7ceaff",
"zh:0fa82a384b25a58b65523e0ea4768fa1212b1f5cfc0c9379d31162454fedcc9d",
"zh:4e325ace584dac143bd770884eb1bbeba96aa59ac88c84cdf641bf4bdcab9857",
"zh:5e4d22b3792d20a6a38f27d8c712343b4c60a060967b18911148ba8de5a9be2c",
"zh:7a8cdb6452db79ae49e74a5a54bdc6298364720c3cbbdb674a1870468a04a5f1",
"zh:97b5a8fe55923c3b6c1d231e1c7f8e7df88ab653c0d234aacdfcc58b4bd3902a",
"zh:9cca035a71f1469113ec294ebd2634d9196ad7f4071c3ca1010016fbd547ba8f",
]
}
+39 -40
View File
@@ -1,44 +1,43 @@
# Declaratively manage Junos login users + classes (and the default name-servers,
# which live here so a single resource owns the `system` slice). One resource per
# user/class. Public SSH keys are committed; passwords never are.
locals {
# Build each user, including only the sub-blocks that have content (the JTAF
# schema expects lists; empty key-type blocks are omitted via merge()).
users = [
for name, u in var.users : merge(
{
name = name
class = u.class
authentication = [
merge(
length(u.ssh_ed25519_keys) == 0 ? {} : { ssh_ed25519 = [for k in u.ssh_ed25519_keys : { name = k }] },
length(u.ssh_rsa_keys) == 0 ? {} : { ssh_rsa = [for k in u.ssh_rsa_keys : { name = k }] },
)
]
},
u.uid == null ? {} : { uid = u.uid },
u.full_name == null ? {} : { full_name = u.full_name },
)
]
classes = [for name, c in var.classes : { name = name, permissions = c.permissions }]
login = [
merge(
{ user = local.users },
length(local.classes) == 0 ? {} : { class = local.classes },
)
]
user_keys = {
for name, u in var.users : name => concat(u.ssh_ed25519_keys, u.ssh_rsa_keys)
}
}
resource "terraform-provider-junos-qfx" "login" {
resource_name = var.resource_name
provider = junos-qfx
# This resource owns the whole `system` container slice: login + name-servers.
# Splitting `system` children across resources makes each one delete the other's
# (the provider diffs its managed slice against the live device).
system = [
merge(
{ login = local.login },
length(var.name_servers) == 0 ? {} : { name_server = [for ns in var.name_servers : { name = ns }] },
)
]
resource "junos_system_login_class" "this" {
for_each = var.classes
name = each.key
permissions = each.value.permissions
}
resource "junos_system_login_user" "this" {
for_each = var.users
name = each.key
class = each.value.class
uid = each.value.uid
full_name = each.value.full_name
dynamic "authentication" {
for_each = length(local.user_keys[each.key]) > 0 ? [1] : []
content {
ssh_public_keys = local.user_keys[each.key]
}
}
# A user's class may be one of the synthesized custom classes.
depends_on = [junos_system_login_class.this]
}
# Default DNS resolvers, pushed as additive raw set-config. NOT junos_system: that
# is a singleton owning the whole `system` block (incl. `services netconf/ssh` and
# `ssh root-login`), so setting only name-server would STRIP the management services
# and lock us out. null_load_config only ever `set`s — it cannot remove services.
# (Trade-off: removing a resolver later needs a manual delete; fine for DNS.)
resource "junos_null_load_config" "name_servers" {
count = length(var.name_servers) == 0 ? 0 : 1
action = "set"
config = join("\n", [for ns in var.name_servers : "set system name-server ${ns}"])
}
+1 -7
View File
@@ -1,7 +1,7 @@
# fabric-login — declaratively manage Junos login users + their SSH keys and
# rights on a switch VC. Public keys are NOT secret and live in version control;
# any passwords come from variables sourced from 1Password (never committed).
# Instantiated once per VC with that VC's aliased junos-qfx provider.
# Instantiated once per VC with that VC's aliased junos provider.
variable "users" {
description = "Login users keyed by username. Public SSH keys are committed; rights via `class`."
@@ -22,12 +22,6 @@ variable "classes" {
default = {}
}
variable "resource_name" {
type = string
default = "login"
description = "JTAF resource/apply-group name for this login slice."
}
variable "name_servers" {
type = list(string)
default = []
+4 -2
View File
@@ -1,8 +1,10 @@
terraform {
required_version = "~> 1.11"
required_providers {
junos-qfx = {
source = "hashicorp/junos-qfx"
# Community Junos provider; the stack passes a configured (aliased) instance.
junos = {
source = "jeremmfr/junos"
version = "~> 2.19"
}
}
}