On VCF Automation 9.1, the Cloud Consumption Interface turns an NSX distributed firewall policy into what is, mechanically, just a Kubernetes object — you POST a manifest and NSX enforces it. That means firewall-as-code without ever logging into NSX Manager. This is the complete, tested toolkit: every Postman request, a browser helper, and two scripts that push a whole rule set from a CSV. It also includes the three walls I hit — a 404, a PowerShell quirk, and a dependency error — because you’ll hit them too, and the errors don’t say what they mean.
VCF architects and platform engineers automating east-west segmentation on VCFA 9.1. If you’re building a real GitOps pipeline, the idiomatic tool is vcf-cli plus kubectl apply against these same resources. What follows is the REST-level view — the exact request shapes, clicked through in Postman — useful when you want to understand what’s on the wire, or bulk-load rules from a spreadsheet a human maintains.
What the CCI actually exposes
The All-Apps organization in VCFA 9.1 fronts a Kubernetes-style API at /cci/kubernetes. NSX VPC objects appear on it as Custom Resource Definitions in the vpc.nsx.vmware.com/v1alpha1 group. Distributed firewall policy is the FirewallPolicy kind; security groups are NetworkSecurityGroup; named services are NetworkService. You address them with ordinary REST verbs and a bearer token, and the platform realizes your intent as genuine NSX vDefend policy.
Create an environment with these variables so the requests below resolve: vcfaHost, vcfaTenant, apiToken (durable, from the portal), accessToken (filled by the token request’s script), and namespace is not needed — this resource is cluster-scoped. Select the environment in the top-right dropdown. For a *.vcf.lab self-signed cert, turn off Settings → SSL certificate verification. If you’re in the signed-out Lightweight client (no environments or scripts), just paste literal values instead of {{variables}}.
Get a bearer token
Every request carries an Authorization: Bearer header. Mint an API token once in the VCFA portal (account settings → API Tokens, shown only once), then exchange it for a short-lived access token. The exchange is an OAuth refresh-token grant against the tenant manager:
# Headers Content-Type: application/x-www-form-urlencoded Accept: application/json # Body → x-www-form-urlencoded grant_type refresh_token refresh_token {{apiToken}}
Capture the token automatically so every later request can use it — add this on the request’s Scripts → Post-response tab:
const res = pm.response.json(); pm.environment.set("accessToken", res.access_token); // some builds: res.token
x-www-form-urlencoded POST returns access_token (a Bearer token), valid for expires_in seconds (3600 here).Already holding a valid bearer token from the portal or a prior exchange? Skip all of the above and paste it straight into the Authorization header (or the scripts’ -AccessToken parameter). Both scripts below support that.
A 400 on the exchange endpoint usually means you fed it something that isn’t a refresh token (an access token, say). A 401 later, on the actual API calls, means your access token expired — re-run the token request to refresh {{accessToken}}. The durable API token doesn’t expire on the same cadence; the access token does.
Discover the resource — and learn it’s cluster-scoped
Here’s the first wall. POST to what looks like the obvious path with a namespace segment and you get a 404 whose details block is empty — meaning the route itself isn’t served, not that your object is missing. Don’t guess the path; ask the API server what it serves:
# Headers
Authorization: Bearer {{accessToken}}
Accept: application/jsonIn the response, find the firewallpolicies entry:
{
"name": "firewallpolicies",
"singularName": "firewallpolicy",
"namespaced": false,
"kind": "FirewallPolicy",
"verbs": ["create","delete","get","list","patch","update"]
}firewallpolicies is namespaced: false — cluster-scoped, so the request path carries no /namespaces/ segment. Adding one is what produces the empty-details 404. The CCI proxy is already per-tenant; your org scoping rides on the token.Create, read, update, delete
Create a policy by POSTing to the collection. Trim metadata to just the name — uid, resourceVersion, and creationTimestamp are server-owned, and a non-empty resourceVersion can be rejected on create:
# Headers Authorization: Bearer {{accessToken}} Content-Type: application/json # Body → raw · JSON { "kind": "FirewallPolicy", "apiVersion": "vpc.nsx.vmware.com/v1alpha1", "metadata": { "name": "policy-web01" }, "spec": { "appliedTo": { "groupNames": ["Any"] }, "category": "Environment", "regionName": "region-west", "priority": 500000, "stateful": true, "tcpStrict": false, "locked": false, "rules": [ { "name": "allow-web-in", "direction": "In", "action": "Allow", "ipProtocol": "IPV4", "from": [{ "ipAddress": "10.30.0.0/24" }], "to": [{ "ipAddress": "192.168.10.20" }], "appliedTo": { "groupNames": ["Any"] }, "services": [{ "networkServiceName": "Any" }], "disabled": false } ] } }
Read. A crucial quirk: the list response omits each policy’s rules array — you get only policy-level fields and status. To see the rules, GET the policy by name:
# List — no rules array, but shows status.ruleCount + Realized per policy GET https://{{vcfaHost}}/cci/kubernetes/apis/vpc.nsx.vmware.com/v1alpha1/firewallpolicies # By name — includes the full rules array GET https://{{vcfaHost}}/cci/kubernetes/apis/vpc.nsx.vmware.com/v1alpha1/firewallpolicies/policy-web01 # Headers (both) Authorization: Bearer {{accessToken}}
Update is read-modify-write: GET the object, change what you need, PUT the whole thing back echoing the current resourceVersion. A PUT replaces the entire rules array, so send them all — an omitted rule is a deleted rule.
# Headers Authorization: Bearer {{accessToken}} Content-Type: application/json # Body → raw · JSON (full object; paste from your GET, echo resourceVersion) { "kind": "FirewallPolicy", "apiVersion": "vpc.nsx.vmware.com/v1alpha1", "metadata": { "name": "policy-web01", "resourceVersion": "<from the GET>" }, "spec": { "appliedTo": { "groupNames": ["Any"] }, "category": "Environment", "regionName": "region-west", "priority": 500000, "stateful": true, "tcpStrict": false, "locked": false, "rules": [ /* ALL rules here — the array is replaced wholesale */ ] } }
PUT in Postman, echoing resourceVersion. The response is a 200 — but status.Realized is still False, reason InProgress: the API accepted the intent, NSX hasn’t finished enforcing it yet. Poll the GET until it flips to True.# Headers
Authorization: Bearer {{accessToken}}The API returns 200 when it accepts your intent, not when NSX has enforced it. Every object carries the truth in status.conditions[].type == "Realized", alongside status.ruleCount. After any write, GET the policy and confirm Realized: True and the count you expect. No separate realized-state endpoint needed.
Rule fields, and the IP-or-group union
Source and destination members are a union type — a member is either a literal address or a group reference. This matters: for simple, literal rules you don’t need to pre-create any groups.
| Member style | Example | Use when |
|---|---|---|
ipAddress (host) | { "ipAddress": "192.168.10.20" } | A single literal host |
ipAddress (CIDR) | { "ipAddress": "10.30.0.0/24" } | A literal subnet |
groupName | { "groupName": "web-servers" } | Dynamic or reused membership |
| Field | Values |
|---|---|
direction | In, Out, InOut |
action | Allow, Drop (silent), Reject (informs client) |
ipProtocol | IPV4, IPV6, IPV4_IPV6 |
services | networkServiceName refs — Any or an existing named service (read-only via this API) |
category (policy) | Eval order: Ethernet → Emergency → Infrastructure → Environment → Application |
priority (policy) | Lower number = evaluated first, within the category band |
Groups must exist before the policy that references them
The second wall. If a rule references groupName: "web-servers" and that group doesn’t exist, the create fails with a 400 whose message spells out the exact policy path NSX couldn’t resolve. Groups are a sibling resource — cluster-scoped, full create verbs — so you provision them the same way, first:
# Headers Authorization: Bearer {{accessToken}} Content-Type: application/json # Body → raw · JSON { "kind": "NetworkSecurityGroup", "apiVersion": "vpc.nsx.vmware.com/v1alpha1", "metadata": { "name": "web-servers" }, "spec": { "regionName": "region-west", "criteria": [ { "ipAddresses": ["10.20.0.0/24"] } ] } }
The criteria shape can be IP-based (above) or condition/tag-based for dynamic membership. Create one and GET it back to confirm the exact schema your 9.1 build expects before scripting a batch. The group and the policy that references it must share the same regionName, or the reference won’t resolve.
Drive a whole rule set from a spreadsheet
Now the payoff. Postman is great for single requests, but a whole rule set wants a script. Keep your rules in a CSV, one row per rule, grouped by a policy column. Multiple members in a cell are separated by ;. Row order is preserved as evaluation order, so a catch-all drop belongs last.
policy,name,direction,action,protocol,from,to,service,appliedTo,disabled,category,priority,region policy-web01,allow-web-in,In,Allow,IPV4,10.30.0.0/24,192.168.10.20,Any,Any,false,Environment,500000,region-west policy-web01,allow-app-out,Out,Allow,IPV4,192.168.10.20,10.30.0.5,Any,Any,false,,, policy-web01,allow-dns,Out,Allow,IPV4,192.168.10.20,10.10.10.53;10.10.10.54,Any,Any,false,,, policy-web01,default-drop,InOut,Drop,IPV4,0.0.0.0/0,0.0.0.0/0,Any,Any,false,,, policy-app02,app-from-web,In,Allow,IPV4,web-servers,app-servers,Any,app-servers,false,Environment,510000,region-west policy-app02,app-deny,InOut,Drop,IPV4,0.0.0.0/0,0.0.0.0/0,Any,Any,false,,,
Two scripts consume that CSV identically. Both do the same loop: authenticate (exchange an API token, or use a bearer token you pass in), build a FirewallPolicy body per policy with IP-vs-group auto-detection, upsert (POST if new, PUT with resourceVersion if it exists), then poll Realized and report per policy. Both take --dry-run so you can inspect the JSON before anything hits the server, and --insecure for a self-signed lab cert.
Python
#!/usr/bin/env python3
"""
create_dfw_rules.py
Create or update VCF Automation (CCI) distributed firewall policies and rules
from a CSV file.
Flow per run:
1. Exchange the VCFA API token for a short-lived access (bearer) token.
2. Read the CSV and group rows by policy name.
3. Build a FirewallPolicy body per policy (auto-detecting IP/CIDR vs group name).
4. Upsert: GET the policy -> POST if absent, PUT (with resourceVersion) if present.
5. Re-read and report the Realized status and rule count.
Endpoint (cluster-scoped, no namespace):
POST/PUT https://<host>/cci/kubernetes/apis/vpc.nsx.vmware.com/v1alpha1/firewallpolicies[/<name>]
Config is read from environment variables (override with flags):
VCFA_HOST e.g. auto-a.site-a.vcf.lab
VCFA_TENANT your All-Apps org / tenant name
VCFA_API_TOKEN the durable API token minted in the VCFA portal
CSV columns (header required):
policy,name,direction,action,protocol,from,to,service,appliedTo,disabled
Optional per-policy columns (taken from the first row of each policy group):
category,priority,region
Multiple members in one cell are separated by ';'.
Rule order in the CSV is preserved as evaluation order (first match wins).
"""
import argparse
import csv
import json
import os
import re
import sys
import time
import requests
IP_RE = re.compile(r"^\d{1,3}(\.\d{1,3}){3}(/\d{1,2})?$")
API_GROUP = "vpc.nsx.vmware.com/v1alpha1"
# ---------- small helpers ----------
def log(msg):
print(msg, flush=True)
def die(msg, code=1):
print(f"ERROR: {msg}", file=sys.stderr, flush=True)
sys.exit(code)
def split_cell(cell):
return [p.strip() for p in (cell or "").split(";") if p.strip()]
def member(cell):
"""Turn a from/to cell into a list of {ipAddress|groupName} members."""
out = []
for v in split_cell(cell):
out.append({"ipAddress": v} if IP_RE.match(v) else {"groupName": v})
return out
def services(cell):
vals = split_cell(cell) or ["Any"]
return [{"networkServiceName": s} for s in vals]
def group_names(cell):
vals = split_cell(cell) or ["Any"]
return {"groupNames": vals}
# ---------- token exchange ----------
def get_access_token(session, host, tenant, api_token):
url = f"https://{host}/tm/oauth/tenant/{tenant}/token"
resp = session.post(
url,
headers={
"Content-Type": "application/x-www-form-urlencoded",
"Accept": "application/json",
},
data={"grant_type": "refresh_token", "refresh_token": api_token},
)
if resp.status_code != 200:
die(f"token exchange failed [{resp.status_code}]: {resp.text[:300]}")
body = resp.json()
token = body.get("access_token") or body.get("token")
if not token:
die(f"token exchange returned no access_token/token field: {list(body)}")
return token
# ---------- CSV -> policy bodies ----------
def build_policies(rows, defaults):
"""Group CSV rows by 'policy' and build a FirewallPolicy body for each.
Returns an ordered dict {policy_name: body}."""
order = []
groups = {}
for r in rows:
name = (r.get("policy") or "").strip()
if not name:
die("a CSV row is missing the 'policy' column value")
if name not in groups:
groups[name] = []
order.append(name)
groups[name].append(r)
policies = {}
for pname in order:
prows = groups[pname]
head = prows[0]
rules = []
for r in prows:
rules.append({
"name": (r.get("name") or "").strip(),
"direction": (r.get("direction") or "InOut").strip(),
"action": (r.get("action") or "Allow").strip(),
"ipProtocol": (r.get("protocol") or "IPV4").strip(),
"from": member(r.get("from")),
"to": member(r.get("to")),
"appliedTo": group_names(r.get("appliedTo")),
"services": services(r.get("service")),
"disabled": str(r.get("disabled", "")).strip().lower() == "true",
})
body = {
"kind": "FirewallPolicy",
"apiVersion": API_GROUP,
"metadata": {"name": pname},
"spec": {
"appliedTo": {"groupNames": ["Any"]},
"category": (head.get("category") or defaults["category"]).strip(),
"regionName": (head.get("region") or defaults["region"]).strip(),
"priority": int(head.get("priority") or defaults["priority"]),
"stateful": True,
"tcpStrict": defaults["tcp_strict"],
"locked": False,
"rules": rules,
},
}
policies[pname] = body
return policies
# ---------- API calls ----------
def policy_url(host, name=None):
base = f"https://{host}/cci/kubernetes/apis/{API_GROUP}/firewallpolicies"
return f"{base}/{name}" if name else base
def get_policy(session, host, token, name):
resp = session.get(
policy_url(host, name),
headers={"Authorization": f"Bearer {token}", "Accept": "application/json"},
)
if resp.status_code == 200:
return resp.json()
if resp.status_code == 404:
return None
die(f"GET {name} failed [{resp.status_code}]: {resp.text[:300]}")
def upsert_policy(session, host, token, name, body):
existing = get_policy(session, host, token, name)
headers = {
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
"Accept": "application/json",
}
if existing is None:
resp = session.post(policy_url(host), headers=headers, data=json.dumps(body))
verb = "created"
else:
rv = existing.get("metadata", {}).get("resourceVersion")
if rv is not None:
body["metadata"]["resourceVersion"] = rv
resp = session.put(policy_url(host, name), headers=headers, data=json.dumps(body))
verb = "updated"
if resp.status_code not in (200, 201):
die(f"{verb} {name} failed [{resp.status_code}]: {resp.text[:400]}")
return verb
def realized(policy):
if not policy:
return None, None
conds = policy.get("status", {}).get("conditions", [])
state = next((c.get("status") for c in conds if c.get("type") == "Realized"), None)
return state, policy.get("status", {}).get("ruleCount")
# ---------- main ----------
def main():
ap = argparse.ArgumentParser(description="Create/update CCI DFW policies from a CSV.")
ap.add_argument("csv", help="path to the rules CSV")
ap.add_argument("--host", default=os.environ.get("VCFA_HOST"))
ap.add_argument("--tenant", default=os.environ.get("VCFA_TENANT"))
ap.add_argument("--api-token", default=os.environ.get("VCFA_API_TOKEN"))
ap.add_argument("--category", default="Environment", help="default category (default: Environment)")
ap.add_argument("--priority", type=int, default=500000, help="default priority (default: 500000)")
ap.add_argument("--region", default="region-west", help="default regionName (default: region-west)")
ap.add_argument("--tcp-strict", action="store_true", help="set tcpStrict=true on policies (default false)")
ap.add_argument("--insecure", action="store_true", help="skip TLS verification (self-signed lab certs)")
ap.add_argument("--ca-cert", help="path to a CA bundle for TLS verification")
ap.add_argument("--dry-run", action="store_true", help="print the JSON bodies and exit; no API calls")
args = ap.parse_args()
with open(args.csv, newline="", encoding="utf-8") as f:
rows = list(csv.DictReader(f))
if not rows:
die("CSV has no data rows")
defaults = {
"category": args.category,
"priority": args.priority,
"region": args.region,
"tcp_strict": args.tcp_strict,
}
policies = build_policies(rows, defaults)
if args.dry_run:
for name, body in policies.items():
log(f"# ---- {name} ({len(body['spec']['rules'])} rules) ----")
log(json.dumps(body, indent=2))
log(f"\n{len(policies)} policy/policies parsed, "
f"{sum(len(b['spec']['rules']) for b in policies.values())} rules total. (dry-run)")
return
for k in ("host", "tenant", "api_token"):
if not getattr(args, k):
die(f"missing --{k.replace('_','-')} (or its VCFA_* env var)")
session = requests.Session()
if args.ca_cert:
session.verify = args.ca_cert
elif args.insecure:
session.verify = False
requests.packages.urllib3.disable_warnings()
log("WARNING: TLS verification disabled (--insecure)")
log("Exchanging API token for access token ...")
token = get_access_token(session, args.host, args.tenant, args.api_token)
ok = True
for name, body in policies.items():
n = len(body["spec"]["rules"])
log(f"\n> {name}: {n} rule(s)")
verb = upsert_policy(session, args.host, token, name, body)
# brief re-read to confirm realization
state, count = None, None
for _ in range(6):
state, count = realized(get_policy(session, args.host, token, name))
if state == "True":
break
time.sleep(2)
flag = "OK" if state == "True" else "PENDING"
if state != "True":
ok = False
log(f" {verb}; Realized={state} ruleCount={count} [{flag}]")
log("\nDone." if ok else "\nDone with warnings (some policies not yet Realized).")
sys.exit(0 if ok else 2)
if __name__ == "__main__":
main()
pip install requests export VCFA_HOST=auto-a.site-a.vcf.lab export VCFA_ACCESS_TOKEN=eyJ... # or VCFA_TENANT + VCFA_API_TOKEN to auto-exchange python3 create_dfw_rules.py rules.csv --dry-run # preview JSON, no calls python3 create_dfw_rules.py rules.csv --insecure # create/update for real
PowerShell
Runs on stock Windows PowerShell 5.1 and on PowerShell 7+. One quirk to know about, because it produces a baffling error if you write this kind of code from scratch:
PowerShell’s ConvertTo-Json and its array handling fight each other. On a function return, a leading unary comma (return ,$out) is needed to stop the array unrolling to a scalar. But on a direct hashtable-value assignment, that same comma double-wraps — producing "groupNames": [["Any"]] instead of ["Any"], and NSX rejects it with “cannot unmarshal array into Go struct field … of type string”. The script below has this right: commas on returns, none on assignments. If you adapt it, keep that distinction.
<#
.SYNOPSIS
Create or update VCF Automation (CCI) distributed firewall policies and rules from a CSV.
.DESCRIPTION
Per run:
1. Exchange the VCFA API token for a short-lived access (bearer) token.
2. Import the CSV and group rows by the 'policy' column.
3. Build a FirewallPolicy body per policy (auto-detecting IP/CIDR vs group name).
4. Upsert: GET the policy -> POST if absent, PUT (with resourceVersion) if present.
5. Re-read and report Realized status + rule count.
Endpoint (cluster-scoped, no namespace):
https://<host>/cci/kubernetes/apis/vpc.nsx.vmware.com/v1alpha1/firewallpolicies[/<name>]
Runs on Windows PowerShell 5.1 (built in) and PowerShell 7+.
.PARAMETER Csv
Path to the rules CSV.
.EXAMPLE
$env:VCFA_HOST='auto-a.site-a.vcf.lab'; $env:VCFA_TENANT='myorg'; $env:VCFA_API_TOKEN='...'
.\Create-DfwRules.ps1 -Csv .\rules.csv -DryRun
.\Create-DfwRules.ps1 -Csv .\rules.csv -Insecure
.NOTES
CSV columns (header required):
policy,name,direction,action,protocol,from,to,service,appliedTo,disabled
Optional per-policy columns (read from the first row of each policy group):
category,priority,region
Separate multiple members in one cell with ';'. Row order = evaluation order.
#>
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)][string]$Csv,
[string]$VcfaHost = $env:VCFA_HOST,
[string]$Tenant = $env:VCFA_TENANT,
[string]$ApiToken = $env:VCFA_API_TOKEN,
[string]$AccessToken = $env:VCFA_ACCESS_TOKEN,
[string]$Category = 'Environment',
[int]$Priority = 500000,
[string]$Region = 'region-west',
[switch]$TcpStrict,
[switch]$Insecure,
[string]$CaCert,
[switch]$DryRun
)
$ErrorActionPreference = 'Stop'
$ApiGroup = 'vpc.nsx.vmware.com/v1alpha1'
$IpRegex = '^\d{1,3}(\.\d{1,3}){3}(/\d{1,2})?$'
function Fail($msg) { Write-Error $msg; exit 1 }
# ---- member / cell helpers ----
function Split-Cell($cell) {
if ([string]::IsNullOrWhiteSpace($cell)) { return @() }
return ($cell -split ';' | ForEach-Object { $_.Trim() } | Where-Object { $_ })
}
function Convert-Members($cell) {
$out = @()
foreach ($v in (Split-Cell $cell)) {
if ($v -match $IpRegex) { $out += [ordered]@{ ipAddress = $v } }
else { $out += [ordered]@{ groupName = $v } }
}
return ,$out # comma keeps it an array even with one element
}
function Convert-Services($cell) {
$vals = Split-Cell $cell; if ($vals.Count -eq 0) { $vals = @('Any') }
return ,@($vals | ForEach-Object { [ordered]@{ networkServiceName = $_ } })
}
function Convert-GroupNames($cell) {
$vals = Split-Cell $cell; if ($vals.Count -eq 0) { $vals = @('Any') }
return [ordered]@{ groupNames = @($vals) }
}
# ---- build one policy body from its rows ----
function Build-Policy($name, $rows) {
$head = $rows[0]
$rules = @()
foreach ($r in $rows) {
$rules += [ordered]@{
name = "$($r.name)".Trim()
direction = if ($r.direction) { "$($r.direction)".Trim() } else { 'InOut' }
action = if ($r.action) { "$($r.action)".Trim() } else { 'Allow' }
ipProtocol = if ($r.protocol) { "$($r.protocol)".Trim() } else { 'IPV4' }
from = Convert-Members $r.from
to = Convert-Members $r.to
appliedTo = Convert-GroupNames $r.appliedTo
services = Convert-Services $r.service
disabled = ("$($r.disabled)".Trim().ToLower() -eq 'true')
}
}
$cat = if ($head.PSObject.Properties['category'] -and $head.category) { "$($head.category)".Trim() } else { $Category }
$reg = if ($head.PSObject.Properties['region'] -and $head.region) { "$($head.region)".Trim() } else { $Region }
$prio = if ($head.PSObject.Properties['priority'] -and $head.priority) { [int]$head.priority } else { $Priority }
return [ordered]@{
kind = 'FirewallPolicy'
apiVersion = $ApiGroup
metadata = [ordered]@{ name = $name }
spec = [ordered]@{
appliedTo = [ordered]@{ groupNames = @('Any') }
category = $cat
regionName = $reg
priority = $prio
stateful = $true
tcpStrict = [bool]$TcpStrict
locked = $false
rules = $rules
}
}
}
# ---- HTTP: build common args for cert handling / version ----
function Get-IrmArgs {
$a = @{}
if ($CaCert) { $a['Certificate'] = $null } # placeholder; CA bundle handled below
if ($PSVersionTable.PSVersion.Major -ge 6 -and $Insecure) { $a['SkipCertificateCheck'] = $true }
return $a
}
function Get-AccessToken {
$uri = "https://$VcfaHost/tm/oauth/tenant/$Tenant/token"
$body = "grant_type=refresh_token&refresh_token=" + [uri]::EscapeDataString($ApiToken)
$args = Get-IrmArgs
try {
$resp = Invoke-RestMethod -Method Post -Uri $uri -Body $body `
-ContentType 'application/x-www-form-urlencoded' -Headers @{ Accept = 'application/json' } @args
} catch {
Fail "token exchange failed: $($_.Exception.Message)"
}
$tok = $resp.access_token; if (-not $tok) { $tok = $resp.token }
if (-not $tok) { Fail "token response had no access_token/token field" }
return $tok
}
function Get-PolicyUrl($name) {
$base = "https://$VcfaHost/cci/kubernetes/apis/$ApiGroup/firewallpolicies"
if ($name) { return "$base/$name" } else { return $base }
}
function Get-Policy($token, $name) {
$args = Get-IrmArgs
try {
return Invoke-RestMethod -Method Get -Uri (Get-PolicyUrl $name) `
-Headers @{ Authorization = "Bearer $token"; Accept = 'application/json' } @args
} catch {
$code = $null
if ($_.Exception.Response) { $code = [int]$_.Exception.Response.StatusCode }
if ($code -eq 404) { return $null }
Fail "GET $name failed: $($_.Exception.Message)"
}
}
function Set-Policy($token, $name, $body) {
$existing = Get-Policy $token $name
$headers = @{ Authorization = "Bearer $token"; Accept = 'application/json' }
$args = Get-IrmArgs
if ($null -eq $existing) {
$json = $body | ConvertTo-Json -Depth 12
Invoke-RestMethod -Method Post -Uri (Get-PolicyUrl) -Headers $headers `
-ContentType 'application/json' -Body $json @args | Out-Null
return 'created'
} else {
if ($existing.metadata.resourceVersion) {
$body.metadata['resourceVersion'] = $existing.metadata.resourceVersion
}
$json = $body | ConvertTo-Json -Depth 12
Invoke-RestMethod -Method Put -Uri (Get-PolicyUrl $name) -Headers $headers `
-ContentType 'application/json' -Body $json @args | Out-Null
return 'updated'
}
}
function Get-Realized($policy) {
if ($null -eq $policy) { return @($null, $null) }
$state = ($policy.status.conditions | Where-Object { $_.type -eq 'Realized' } | Select-Object -First 1).status
return @($state, $policy.status.ruleCount)
}
# ================= main =================
if (-not (Test-Path $Csv)) { Fail "CSV not found: $Csv" }
$rows = Import-Csv -Path $Csv
if (-not $rows) { Fail "CSV has no data rows" }
if (-not ($rows | Get-Member -Name policy -MemberType NoteProperty)) {
Fail "CSV is missing the required 'policy' column"
}
# preserve first-seen policy order
$order = @(); $seen = @{}
foreach ($r in $rows) {
$p = "$($r.policy)".Trim()
if (-not $p) { Fail "a CSV row has an empty 'policy' value" }
if (-not $seen.ContainsKey($p)) { $seen[$p] = $true; $order += $p }
}
$policies = [ordered]@{}
foreach ($p in $order) {
$prows = @($rows | Where-Object { "$($_.policy)".Trim() -eq $p })
$policies[$p] = Build-Policy $p $prows
}
if ($DryRun) {
foreach ($p in $order) {
$b = $policies[$p]
Write-Host "# ---- $p ($($b.spec.rules.Count) rules) ----"
$b | ConvertTo-Json -Depth 12
}
$total = ($order | ForEach-Object { $policies[$_].spec.rules.Count } | Measure-Object -Sum).Sum
Write-Host "`n$($order.Count) policy/policies parsed, $total rules total. (dry-run)"
exit 0
}
if (-not $VcfaHost) { Fail "missing -VcfaHost (or VCFA_HOST env var)" }
if (-not $AccessToken) {
foreach ($n in @('Tenant','ApiToken')) {
if (-not (Get-Variable -Name $n -ValueOnly)) {
Fail "missing -$n (or its VCFA_* env var). Alternatively pass -AccessToken to skip the exchange."
}
}
}
# TLS handling for Windows PowerShell 5.1 (no -SkipCertificateCheck there)
if ($PSVersionTable.PSVersion.Major -lt 6) {
[System.Net.ServicePointManager]::SecurityProtocol = [System.Net.SecurityProtocol]::Tls12
if ($Insecure) {
Write-Host "WARNING: TLS verification disabled (-Insecure)"
Add-Type @"
using System.Net;
using System.Security.Cryptography.X509Certificates;
public class TrustAllCerts {
public static void Set() {
ServicePointManager.ServerCertificateValidationCallback =
delegate { return true; };
}
}
"@ -ErrorAction SilentlyContinue
[TrustAllCerts]::Set()
}
} elseif ($Insecure) {
Write-Host "WARNING: TLS verification disabled (-Insecure)"
}
if ($AccessToken) {
Write-Host "Using provided bearer token (skipping token exchange)."
$token = $AccessToken
} else {
Write-Host "Exchanging API token for access token ..."
$token = Get-AccessToken
}
$ok = $true
foreach ($p in $order) {
$body = $policies[$p]
$count = $body.spec.rules.Count
Write-Host "`n> $p : $count rule(s)"
$verb = Set-Policy $token $p $body
$state = $null; $rc = $null
for ($i = 0; $i -lt 6; $i++) {
$res = Get-Realized (Get-Policy $token $p)
$state = $res[0]; $rc = $res[1]
if ($state -eq 'True') { break }
Start-Sleep -Seconds 2
}
$flag = if ($state -eq 'True') { 'OK' } else { 'PENDING' }
if ($state -ne 'True') { $ok = $false }
Write-Host " $verb; Realized=$state ruleCount=$rc [$flag]"
}
Write-Host $(if ($ok) { "`nDone." } else { "`nDone with warnings (some policies not yet Realized)." })
exit $(if ($ok) { 0 } else { 2 })
$env:VCFA_HOST='auto-a.site-a.vcf.lab' .\Create-DfwRules.ps1 -Csv .\rules.csv -DryRun # preview .\Create-DfwRules.ps1 -Csv .\rules.csv -AccessToken 'eyJ...' -Insecure # bearer token # or auto-exchange: $env:VCFA_TENANT='myorg'; $env:VCFA_API_TOKEN='...' .\Create-DfwRules.ps1 -Csv .\rules.csv -Insecure
Give -AccessToken just the raw eyJ… value — the script adds the Bearer prefix itself. Paste Bearer eyJ… and you’ll double it up and get a 401.
Where this earns its keep: VCD-to-VCFA firewall migration
If you’re moving tenants off vCloud Director onto VCF Automation, the distributed firewall is one of the least glamorous and most error-prone parts of the cut-over — dozens or hundreds of per-tenant rules that have to land exactly right, because a missed allow is an outage and a missed deny is an exposure. Hand-recreating them in a new console doesn’t scale and doesn’t audit. This CSV-driven path turns it into a reviewable data problem: export the source DFW rules from VCD, normalize them into the same rules.csv shape (source, destination, service, action, direction), and let the script load them into VCFA — idempotently, so you can rerun it as many times as the migration-validation cycle needs. The CSV becomes the migration artifact itself: security and network teams review a spreadsheet instead of clicking through screens, it drops into source control as the record of what moved, and --dry-run gives you a diff before anything touches the target region. IP- and CIDR-based rules — the bulk of most VCD rule sets — map straight across as ipAddress members; group-based ones become NetworkSecurityGroup objects you create first, from the same kind of CSV. And because every policy reports Realized inline, you get an automated post-migration check: load the set, confirm every policy flipped to True, then cut traffic over. It won’t do the semantic mapping for you — VCD sections and applied-to scoping don’t line up one-to-one with VCFA’s category bands, and that judgement still needs an architect — but it removes the mechanical re-entry that makes firewall migration slow and risky, and leaves you reviewing intent in a CSV instead of transcribing rules by hand.
The other two walls, in one place
The first wall — the namespaced-path 404 — is covered above, in discovery. The two that bite later both throw a 400 whose message points away from the real cause:
| Symptom | Real cause | Fix |
|---|---|---|
400 “cannot unmarshal array … type string” | PowerShell double-wrapped an array ([["Any"]]) | No leading comma on hashtable-value assignments |
400 “Policy object path … does not exist” | A rule references a group that isn’t created yet | POST the NetworkSecurityGroup first, same region |
Once the token flow and the cluster-scoped path click, DFW on VCFA 9.1 is just declarative Kubernetes resources you can POST from anything — Postman, Python, PowerShell, or a full kubectl/Terraform pipeline when you’re ready. The scripts here take a spreadsheet a human can maintain and turn it into realized, enforced firewall policy, with the realized status checked on the way out. Start with --dry-run, verify one policy, then let it run the set.
Lab-tested on VCF Automation 9.1 (CCI, vpc.nsx.vmware.com/v1alpha1). Verify resource schemas against your own build with a GET before batching — API surfaces shift between releases.


Leave a Reply