Skip to content

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 - COMPLETED with a deployed_item the consumer can see, or ERROR with the real reason in the log. If the run dies entirely, changes simply stay APPROVED and the next scheduled run picks them up - the queue is the state, so the loop is safely re-runnable.
  • DELETE completes in the same run. Completing a DELETE change moves its service item to DECOMMISSIONED, 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