Load balancer as a service (service owner)¶
The canonical real-life NetOrca deployment, taken from F5 DevCentral's public
bigip-automation level 6
demo: a BIG-IP team offers LOAD_BALANCER as a self-service product. Consumer teams declare
the load balancers they need (from Git or the GUI), NetOrca turns declarations into change
instances, and the BIG-IP team's scheduled Ansible playbooks validate, deploy and report -
no tickets, no email, full audit trail.
This page is that demo's service-owner automation, rebuilt on the v2 modules. The original repositories (service definition, fulfilment automation) used the v1 collection; what changed is summarised at the end. The consumer half of the story is told in Consumer GitOps.
sequenceDiagram
participant C as Consumer (Git repo)
participant N as NetOrca
participant V as validate playbook (scheduled)
participant D as deploy playbook (scheduled)
participant F as BIG-IP
C->>N: declaration merged & submitted
N->>N: change instance created (PENDING)
V->>N: poll PENDING
V->>N: APPROVED (or REJECTED + reason)
D->>N: poll APPROVED
D->>F: render AS3 from declarations, deploy
D->>N: COMPLETED + deployed_item (or ERROR + log)
N-->>C: request shows COMPLETED, deployed state visible
The service¶
The BIG-IP team publishes LOAD_BALANCER as a JSON Schema (name, partition, type, virtual
server, members - with validation baked in). A consumer's declaration of one instance looks
like this:
application1:
services:
LOAD_BALANCER:
- name: load_balancer1
partition: prod
location: dmz
type: http
virtual_server:
ip: 10.1.10.152
port: 80
members:
- ip: 10.1.20.21
port: 30880
NetOrca compares each submission against the previous state and raises change instances:
CREATE for new entries, MODIFY for edited ones, DELETE for removed ones. Those change
instances are the service owner's work queue, and the whole operating model is two scheduled
playbooks against that queue.
Stage 1 - validate and approve¶
Runs frequently (every few minutes). Pulls PENDING changes, applies the team's checks to each
declaration, approves what passes and rejects the rest with the reason in the log - the
consumer sees it immediately in their request instead of waiting for a human to notice.
- name: Validate and approve pending LOAD_BALANCER changes
hosts: localhost
gather_facts: false
tasks:
- name: Poll the validation queue
netautomate.netorca.netorca_change_instance_info:
service_name: [LOAD_BALANCER]
state: [PENDING]
exclude_referenced: true
register: pending
- name: Validate each change against team policy
ansible.builtin.set_fact:
verdicts: >-
{{ (verdicts | default([])) + [{
'id': item.id,
'ok': item.change_type == 'DELETE' or (
decl.partition | default('') in ['prod', 'dev', 'sit', 'uat', 'qa']
and (decl.virtual_server.ip | default('')) is match('10\.1\.10\.')
),
'reason': 'partition must be a known environment and the VIP must be in 10.1.10.0/24'
}] }}
vars:
decl: "{{ item.new_declaration.declaration | default({}) }}"
loop: "{{ pending.change_instances }}"
loop_control:
label: "#{{ item.id }} ({{ item.change_type }})"
- name: Approve the valid changes
netautomate.netorca.netorca_change_instance:
id: "{{ item.id }}"
state: APPROVED
log: "approved by automated validation"
loop: "{{ verdicts | default([]) | selectattr('ok') | list }}"
loop_control:
label: "#{{ item.id }}"
- name: Reject the rest, telling the consumer why
netautomate.netorca.netorca_change_instance:
id: "{{ item.id }}"
state: REJECTED
log: "rejected by automated validation: {{ item.reason }}"
loop: "{{ verdicts | default([]) | rejectattr('ok') | list }}"
loop_control:
label: "#{{ item.id }}"
The checks here (allowed partitions, VIP range) are placeholders for whatever your team polices:
IPAM lookups, naming standards, conflict detection against the existing estate
(declaration-content search makes that easy - see
Filtering). A runnable,
service-agnostic version of this playbook ships in the repo:
examples/scenarios/load_balancer/validate_and_approve.yml.
GUI approvals vs API approvals
Transitions performed through the API - what these playbooks do - are always available to the
service owner team's key. The service's allow_manual_approval / allow_manual_completion
flags govern only whether humans may approve/complete from the GUI. A 403 here means the
key's team or context is wrong, not a missing flag.
Stage 2 - deploy and complete¶
Runs on its own schedule. This is the declarative pattern from the original demo: rather than applying changes one by one, render the entire desired state of the BIG-IP tenant from the in-service declarations and push it as one AS3 call - then report every change in the batch. Deletions fall out naturally: an item being deleted is simply left out of the render.
- name: Deploy approved LOAD_BALANCER changes to BIG-IP
hosts: localhost
gather_facts: false
vars:
tenant: level6_netorca_tenant
tasks:
- name: Approved changes waiting for deployment
netautomate.netorca.netorca_change_instance_info:
service_name: [LOAD_BALANCER]
state: [APPROVED]
exclude_referenced: true
register: work
- name: Nothing approved - end the run
ansible.builtin.meta: end_play
when: work.count == 0
- name: The desired state - every in-service declaration
netautomate.netorca.netorca_service_item_info:
service_name: [LOAD_BALANCER]
runtime_state: [IN_SERVICE]
register: estate
- name: Items whose DELETE is in this batch drop out of the render
ansible.builtin.set_fact:
deleting_ids: >-
{{ work.change_instances | selectattr('change_type', 'eq', 'DELETE')
| map(attribute='service_item.id') | list }}
- name: Deploy the tenant, then report every change in the batch
block:
- name: Render AS3 from the declarations
ansible.builtin.set_fact:
tenant_body: "{{ lookup('template', 'templates/as3_tenant.j2') }}"
vars:
desired_items: "{{ estate.service_items | rejectattr('id', 'in', deleting_ids) | list }}"
- name: Push to BIG-IP (your infrastructure step - swap for your platform)
ansible.builtin.uri:
url: "{{ lookup('env', 'BIGIP_URL') }}/mgmt/shared/appsvcs/declare"
method: POST
url_username: "{{ lookup('env', 'BIGIP_USER') }}"
url_password: "{{ lookup('env', 'BIGIP_PASSWORD') }}"
force_basic_auth: true
body: "{{ tenant_body }}"
body_format: json
status_code: [200, 202]
timeout: 120
- name: Complete every change, recording what serves the request now
netautomate.netorca.netorca_change_instance:
id: "{{ item.id }}"
state: COMPLETED
log: "deployed by {{ lookup('env', 'CI_JOB_URL') | default('scheduled ansible run', true) }}"
deployed_item: >-
{{ omit if item.change_type == 'DELETE' else {
'tenant': tenant,
'application': item.new_declaration.declaration.name,
'virtual_server': item.new_declaration.declaration.virtual_server
} }}
loop: "{{ work.change_instances }}"
loop_control:
label: "#{{ item.id }} ({{ item.change_type }})"
rescue:
- name: Deployment failed - mark the whole batch ERROR with the reason
netautomate.netorca.netorca_change_instance:
id: "{{ item.id }}"
state: ERROR
log: "AS3 deploy failed: {{ ansible_failed_result.msg | default('unknown error') }}"
loop: "{{ work.change_instances }}"
loop_control:
label: "#{{ item.id }}"
And the AS3 template it renders (templates/as3_tenant.j2, trimmed - full original in the
demo repo):
{
"class": "AS3",
"action": "deploy",
"persist": true,
"declaration": {
"class": "ADC",
"schemaVersion": "3.49.0",
"{{ tenant }}": {
"class": "Tenant",
{% for item in desired_items %}
"{{ item.declaration.name }}": {
"class": "Application",
"serviceMain": {
"class": "Service_HTTP",
"virtualAddresses": ["{{ item.declaration.virtual_server.ip }}"],
"pool": "{{ item.declaration.name }}_pool"
},
"{{ item.declaration.name }}_pool": {
"class": "Pool",
"monitors": ["http"],
"members": [{
"servicePort": 80,
"serverAddresses": {{ item.declaration.members | map(attribute='ip') | list | to_json }}
}]
}
}{{ "," if not loop.last }}
{% endfor %}
}
}
}
Two behaviours worth noticing:
- The outcome reported is the outcome that happened. Success and failure both end at NetOrca -
COMPLETEDwith adeployed_itemthe consumer can see, orERRORwith the real reason in the log. If the run dies entirely, changes simply stayAPPROVEDand the next scheduled run picks them up - the queue is the state, so the loop is safely re-runnable. DELETEcompletes in the same run. Completing aDELETEchange moves its service item toDECOMMISSIONED, which drops it from all future renders too.
If your infrastructure is not declarative (per-object APIs, no full-state render), use the
per-change variant instead: each change fulfilled inside its own block/rescue, so one failure
doesn't fail the batch - that is exactly what
examples/scenarios/load_balancer/poll_and_deploy.yml does.
Scheduling it¶
Both playbooks are pollers - run them on any scheduler. The original demo used AWX job templates; a GitLab scheduled pipeline works just as well:
# .gitlab-ci.yml of the team's automation repo
validate:
script: ansible-playbook validate_change_instances.yml
rules:
- if: '$CI_PIPELINE_SOURCE == "schedule"'
deploy:
script: ansible-playbook deploy_change_instances.yml
rules:
- if: '$CI_PIPELINE_SOURCE == "schedule"'
with NETORCA_API_URL / NETORCA_API_KEY (and the BIG-IP credentials) as masked CI/CD
variables, and a schedule of every 5-15 minutes. For webhook-triggered instead of polled
fulfilment, the webhook modules arrive later in the v2 series.
Secrets, done right¶
The v1 demo playbook carried its API key inline with a comment saying "in live environment this
should be a secret". Make that structural instead of aspirational: the v2 modules read
NETORCA_API_URL / NETORCA_API_KEY / NETORCA_CONTEXT from the environment, so playbooks
contain no credential material at all - locally the key lives in a git-ignored .env, in CI
it is a masked variable, in AWX a custom credential type
(Authentication has all three patterns). api_key is
no_log, so it never appears in task output either.
What changed from the v1 playbooks¶
If you operated the level-6 demo (or anything like it) on collection 1.x, this is what the same workflow gains on 2.x - see the migration guide for mechanics:
| v1 behaviour | v2 behaviour |
|---|---|
filters: dict, typos silently ignored |
explicit validated parameters; a bad value fails with the server's message |
registered.change_instances.results nesting |
flat work.change_instances list + work.count |
| first API page only - a busy queue was silently truncated | auto-pagination; limit when you want less |
every polled change marked COMPLETED, even when the deploy failed |
block/rescue reports COMPLETED or ERROR + log - the state consumers see is the truth |
| no deployed-item reporting | deployed_item on completion; consumers see what serves their request |
| API key hardcoded in the playbook | environment/CI-variable auth, no_log |
--check performed live writes |
real check mode everywhere |
| illegal transitions surfaced as raw API errors | validated client-side, with the legal targets listed |
Try it¶
- Runnable, service-agnostic versions of both stages ship in this repo:
validate_and_approve.ymlandpoll_and_deploy.yml. - The full original demo (consumer repos, service definition, GUI walkthroughs, GIFs) is at f5devcentral/bigip-automation, level 6.
- The audit side of this scenario - who approved what, when - is
audit_change_history.yml.