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 (else stays APPROVED, retried)
    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 and approves them - NetOrca has already validated each declaration against the service schema, so this stage only applies the team-specific checks a schema can't, rejecting an offender with the reason in the log so the consumer sees it immediately 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

    # =================================================================
    # HERE IS YOUR CUSTOM VALIDATION
    # NetOrca already validated the declaration against the LOAD_BALANCER
    # JSON Schema, so partition, VIP shape, members etc. are guaranteed.
    # Add only checks the schema can't express (estate conflicts, IPAM,
    # quota): reject those with a REJECTED task before this one. With
    # nothing extra to check, every PENDING change is approved.
    # =================================================================
    - name: Approve each pending change
      netautomate.netorca.netorca_change_instance:
        id: "{{ item.id }}"
        state: APPROVED
        log: "approved by automated validation"
      loop: "{{ pending.change_instances }}"
      loop_control:
        label: "#{{ item.id }} ({{ item.change_type }})"

There is deliberately no field-level checking here. NetOrca validated the declaration against the LOAD_BALANCER JSON Schema before it ever became a PENDING change, so re-checking partitions or VIP ranges in Ansible would only duplicate the platform. The marked block is where your checks go - the ones a schema can't express: conflicts with the existing estate, IPAM allocation, quotas (declaration-content search makes conflict checks easy - see Filtering). Reject those with a REJECTED task and let the rest approve; the generic, service-agnostic walkthrough is Writing your own validation, and a runnable version ships as 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 mark every approved change COMPLETED. Deletes need no special handling: a decommissioned item is no longer IN_SERVICE, so it simply isn't in the next 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: Render the whole tenant from the in-service declarations
      ansible.builtin.set_fact:
        tenant_body: "{{ lookup('template', 'templates/as3_tenant.j2') }}"
      vars:
        desired_items: "{{ estate.service_items }}"

    # -----------------------------------------------------------------
    # HERE IS YOUR CUSTOM DEPLOYMENT - swap for your platform
    # If this push fails the run fails and nothing below runs, so the
    # batch stays APPROVED and the next run retries it.
    # -----------------------------------------------------------------
    - name: Push the rendered tenant to BIG-IP
      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
    # --- END OF YOUR CUSTOM DEPLOYMENT ---

    - name: Mark every approved change COMPLETED
      netautomate.netorca.netorca_change_instance:
        id: "{{ item.id }}"
        state: COMPLETED
        log: "deployed by {{ lookup('env', 'CI_JOB_URL') | default('scheduled ansible run', true) }}"
      loop: "{{ work.change_instances }}"
      loop_control:
        label: "#{{ item.id }} ({{ item.change_type }})"

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:

  • Success is reported; failure just isn't. A deployed change ends at COMPLETED with a deployed_item the consumer can see. A failed push fails the run and completes nothing, so the batch stays APPROVED and the next scheduled run retries it - the queue is the state, so the loop is safely re-runnable. We never transition a change to ERROR.
  • Deletes need no special handling. Completing a DELETE change decommissions its service item, so it is no longer IN_SERVICE and simply drops out of the next render - AS3 reconciles it away.

If your infrastructure is not declarative (per-object APIs, no full-state render), use the imperative variant instead: get the APPROVED changes of each type with the change_type filter and run your action on them - CREATE/MODIFY apply the new declaration, DELETE tears down the old one - then mark each COMPLETED. That is exactly what examples/scenarios/load_balancer/deploy_by_change_type.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 only real successes are COMPLETED; a failed deploy leaves the change APPROVED for the next run - never a false success
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