Skip to content

Example playbooks

The examples/ directory of the repository contains runnable demonstrations of every workflow the collection supports so far, organised by the same topics as this site: getting_started/, scenarios/ and guides/. They are embedded below verbatim - what you read here is exactly what ships. All of them authenticate from the environment (the pattern) and are safe against a shared instance: reads are reads, and writes either act only on genuinely existing work or default to check mode (arm them with -e apply_changes=true).

export NETORCA_API_URL=... NETORCA_API_KEY=...
ansible-playbook examples/getting_started/first_playbook.yml

Getting started

First playbook

Any persona. The whole chain proven read-only - the walkthrough decodes the output and the first failures.

---
# The first playbook: prove the whole chain - collection, SDK, credentials,
# API - read-only. Walked through line by line (including what the first
# failures look like) in docs/getting_started/first_playbook.md.
#
#   ansible-playbook examples/getting_started/first_playbook.yml
- name: Hello NetOrca
  hosts: localhost
  gather_facts: false
  vars:
    ansible_python_interpreter: "{{ ansible_playbook_python }}"
  tasks:
    - name: My service items
      netautomate.netorca.netorca_service_item_info:
        runtime_state: [IN_SERVICE]
      register: items

    - name: Anything waiting for my team?
      netautomate.netorca.netorca_change_instance_info:
        state: [PENDING, APPROVED]
      register: open_changes

    - name: What we can see
      ansible.builtin.debug:
        msg:
          - "in service: {{ items.count }} item(s) across
             {{ items.service_items | map(attribute='service.name') | unique | list | length }} service(s)"
          - "open changes: {{ open_changes.count }}"
          - "first item: {{ (items.service_items | first).name | default('(none yet)') }}"

Scenario: load balancer as a service

The flagship service-owner workflow.

Validate and approve

The validation stage: poll PENDING, decide per change, approve or reject with the reason in the log. Decisions are only written with -e apply_decisions=true.

---
# Persona: service owner
# The VALIDATION stage of the canonical two-stage workflow (the deployment
# stage is poll_and_deploy.yml): poll PENDING changes, run your checks against
# each declaration, then approve the valid ones and reject the rest with the
# reason in the log - so consumers immediately see why.
#
# Modernised from the level-6 BIG-IP demo's validate_change_instances.yml
# (gitlab.com/netorca_public/bigip-automation/bigip-team-automation).
#
# SAFE BY DEFAULT: decisions are only written with -e apply_decisions=true;
# without it the write tasks run in check mode (nothing is sent).
#
#   ansible-playbook examples/scenarios/load_balancer/validate_and_approve.yml
#   ansible-playbook examples/scenarios/load_balancer/validate_and_approve.yml -e apply_decisions=true
- name: Validate and approve pending changes
  hosts: localhost
  gather_facts: false
  vars:
    ansible_python_interpreter: "{{ ansible_playbook_python }}"
    # limit to one service; leave empty to validate everything you own
    service_name: ""
    apply_decisions: false
  tasks:
    - name: Poll the validation queue (PENDING changes)
      netautomate.netorca.netorca_change_instance_info:
        service_name: "{{ [service_name] if service_name else omit }}"
        state: [PENDING]
        exclude_referenced: true
      register: pending

    - name: Validate each change (replace the rule with your real checks)
      ansible.builtin.set_fact:
        verdicts: >-
          {{ (verdicts | default([])) + [{
               'id': item.id,
               'ok': item.change_type == 'DELETE'
                     or ((item.new_declaration.declaration | default({})).name | default('')) != '',
               'reason': 'declaration must set a name'
             }] }}
      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"
      check_mode: "{{ not (apply_decisions | bool) }}"
      loop: "{{ verdicts | default([]) | selectattr('ok') | list }}"
      loop_control:
        label: "#{{ item.id }}"

    - name: Reject the invalid ones, telling the consumer why
      netautomate.netorca.netorca_change_instance:
        id: "{{ item.id }}"
        state: REJECTED
        log: "rejected by automated validation: {{ item.reason }}"
      check_mode: "{{ not (apply_decisions | bool) }}"
      loop: "{{ verdicts | default([]) | rejectattr('ok') | list }}"
      loop_control:
        label: "#{{ item.id }}"

    - name: Summary
      ansible.builtin.debug:
        msg: >-
          {{ pending.count }} pending change(s):
          {{ verdicts | default([]) | selectattr('ok') | list | length }} approved,
          {{ verdicts | default([]) | rejectattr('ok') | list | length }} rejected
          {{ '(check mode - nothing written; add -e apply_decisions=true)' if not (apply_decisions | bool) else '' }}

Poll and deploy

The deployment stage, per-change variant: poll APPROVED, fulfil each change inside block/rescue, report COMPLETED + deployed item or ERROR + log. Swap the debug task for your infrastructure role.

---
# Persona: service owner
# The canonical NetOrca fulfilment loop, run on a schedule (AWX / cron / CI):
#
#   1. poll change instances in APPROVED state for your service
#   2. for each: drive your real automation from the consumer's declaration
#   3. report back: COMPLETED + deployed_item on success, ERROR + log on failure
#
# Authentication comes from NETORCA_API_URL / NETORCA_API_KEY environment
# variables. Replace the "Fulfil the request" task with your infra role
# (F5, DNS, firewall, ...). Writes only happen when approved changes exist.
#
#   ansible-playbook examples/scenarios/load_balancer/poll_and_deploy.yml
#   ansible-playbook examples/scenarios/load_balancer/poll_and_deploy.yml -e service_name=dns
- name: NetOrca fulfilment loop
  hosts: localhost
  gather_facts: false
  vars:
    ansible_python_interpreter: "{{ ansible_playbook_python }}"
    # limit the loop to one service; leave empty to fulfil everything you own
    service_name: ""
  tasks:
    - name: What is currently in service (context for the report)
      netautomate.netorca.netorca_service_item_info:
        service_name: "{{ [service_name] if service_name else omit }}"
        runtime_state: [IN_SERVICE]
      register: estate

    - name: Poll change instances that are approved and waiting for us
      netautomate.netorca.netorca_change_instance_info:
        service_name: "{{ [service_name] if service_name else omit }}"
        state: [APPROVED]
        exclude_referenced: true
      register: work

    - name: Show the queue
      ansible.builtin.debug:
        msg: >-
          {{ estate.count }} service item(s) in service;
          {{ work.count }} approved change(s) waiting
          {{ '- nothing to fulfil this run' if work.count == 0 else '- fulfilling now' }}

    - name: Fulfil each approved change
      ansible.builtin.include_tasks: tasks/fulfil_one.yml
      loop: "{{ work.change_instances }}"
      loop_control:
        loop_var: change
        label: "change #{{ change.id }} ({{ change.change_type }})"

The per-change task file it includes:

---
# One change instance: apply it to the infrastructure, then report the outcome
# to NetOrca. On any failure the rescue block records ERROR with the reason so
# the consumer and the platform can see what happened.
- name: "Fulfil change #{{ change.id }}"
  block:
    - name: Apply the declaration to the infrastructure (replace with your role)
      ansible.builtin.debug:
        msg: >-
          would deploy {{ change.change_type }} for
          {{ change.new_declaration.declaration | default({}) }}
      register: infra_result

    - name: Report success and record what was deployed
      netautomate.netorca.netorca_change_instance:
        id: "{{ change.id }}"
        state: COMPLETED
        deployed_item: "{{ change.new_declaration.declaration | default({}) }}"
        log: "deployed by ansible run {{ lookup('env', 'CI_JOB_URL') | default('local', true) }}"
  rescue:
    - name: Report failure with the reason
      netautomate.netorca.netorca_change_instance:
        id: "{{ change.id }}"
        state: ERROR
        log: "ansible fulfilment failed: {{ ansible_failed_result.msg | default('unknown error') }}"

Render and deploy AS3

The deployment stage, declarative variant from the scenario: render the entire desired tenant from the IN_SERVICE declarations (deletions fall out of the render), push one AS3 call, report the whole batch.

---
# Persona: service owner
# The DECLARATIVE deployment stage from the load-balancer scenario: render the
# entire desired state of the BIG-IP tenant from the in-service declarations
# (items being deleted in this batch fall out of the render), push it as one
# AS3 call, then report every change in the batch - COMPLETED with a deployed
# item, or ERROR with the reason if the push failed.
#
# SAFE BY DEFAULT:
#   - without BIGIP_URL in the environment, the rendered AS3 is printed
#     instead of pushed (so the render logic is testable anywhere)
#   - completions are only written with -e apply_changes=true
#
#   ansible-playbook examples/scenarios/load_balancer/render_and_deploy_as3.yml
#   BIGIP_URL=https://... BIGIP_USER=... BIGIP_PASSWORD=... \
#     ansible-playbook examples/scenarios/load_balancer/render_and_deploy_as3.yml -e apply_changes=true
- name: Deploy approved LOAD_BALANCER changes to BIG-IP (declarative)
  hosts: localhost
  gather_facts: false
  vars:
    ansible_python_interpreter: "{{ ansible_playbook_python }}"
    service_name: LOAD_BALANCER
    tenant: netorca_tenant
    apply_changes: false
  tasks:
    - name: Approved changes waiting for deployment
      netautomate.netorca.netorca_change_instance_info:
        service_name: ["{{ service_name }}"]
        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: ["{{ service_name }}"]
        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('ansible.builtin.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('ansible.builtin.env', 'BIGIP_URL') }}/mgmt/shared/appsvcs/declare"
            method: POST
            url_username: "{{ lookup('ansible.builtin.env', 'BIGIP_USER') }}"
            url_password: "{{ lookup('ansible.builtin.env', 'BIGIP_PASSWORD') }}"
            force_basic_auth: true
            body: "{{ tenant_body }}"
            body_format: json
            status_code: [200, 202]
            timeout: 120
          when: lookup('ansible.builtin.env', 'BIGIP_URL') != ''

        - name: No BIG-IP configured - show what would have been pushed
          ansible.builtin.debug:
            msg: "{{ tenant_body }}"
          when: lookup('ansible.builtin.env', 'BIGIP_URL') == ''

        - name: Complete every change, recording what serves the request now
          netautomate.netorca.netorca_change_instance:
            id: "{{ item.id }}"
            state: COMPLETED
            log: "deployed by {{ lookup('ansible.builtin.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
                 } }}
          check_mode: "{{ not (apply_changes | bool) }}"
          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') }}"
          check_mode: "{{ not (apply_changes | bool) }}"
          loop: "{{ work.change_instances }}"
          loop_control:
            label: "#{{ item.id }}"

The template it renders:

{
  "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 %}
    }
  }
}

Deploy by change type

The imperative variant for infrastructure without a full-state render: CREATE/MODIFY apply the new declaration, DELETE tears down what the old declaration describes.

---
# Persona: service owner
# The IMPERATIVE deployment stage, branched by change type - for
# infrastructure without a full-state render (per-object APIs): CREATE and
# MODIFY changes apply the new declaration, DELETE changes tear down what the
# old declaration describes. Completions carry a deployed item; deletions
# complete bare (completing a DELETE decommissions the service item).
#
# The two "apply/remove" tasks are placeholders - swap in your infra role.
# For per-change block/rescue error reporting, combine this branching with
# tasks/fulfil_one.yml from poll_and_deploy.yml.
#
# SAFE BY DEFAULT: completions are only written with -e apply_changes=true.
#
#   ansible-playbook examples/scenarios/load_balancer/deploy_by_change_type.yml
#   ansible-playbook examples/scenarios/load_balancer/deploy_by_change_type.yml -e apply_changes=true
- name: Deploy approved changes, branched by change type
  hosts: localhost
  gather_facts: false
  vars:
    ansible_python_interpreter: "{{ ansible_playbook_python }}"
    # limit to one service; leave empty to process everything you own
    service_name: ""
    apply_changes: false
  tasks:
    - name: Approved changes waiting for deployment
      netautomate.netorca.netorca_change_instance_info:
        service_name: "{{ [service_name] if service_name else omit }}"
        state: [APPROVED]
        exclude_referenced: true
      register: work

    - name: Split the batch by what has to happen
      ansible.builtin.set_fact:
        upserts: "{{ work.change_instances | selectattr('change_type', 'in', ['CREATE', 'MODIFY']) | list }}"
        deletions: "{{ work.change_instances | selectattr('change_type', 'eq', 'DELETE') | list }}"

    - name: Apply each new or changed declaration (placeholder - your infrastructure step)
      ansible.builtin.debug:
        msg: >-
          {{ item.change_type | lower }} {{ item.service_item.name }}:
          apply {{ item.new_declaration.declaration | default({}) }}
      loop: "{{ upserts }}"
      loop_control:
        label: "#{{ item.id }} ({{ item.change_type }})"

    - name: Tear down each deleted declaration (placeholder - your infrastructure step)
      ansible.builtin.debug:
        msg: >-
          delete {{ item.service_item.name }}:
          remove {{ (item.old_declaration | default(item.new_declaration) | default({})).declaration | default({}) }}
      loop: "{{ deletions }}"
      loop_control:
        label: "#{{ item.id }}"

    - name: Complete the creates and modifies, recording what was deployed
      netautomate.netorca.netorca_change_instance:
        id: "{{ item.id }}"
        state: COMPLETED
        log: applied by scheduled ansible run
        deployed_item:
          declaration: "{{ item.new_declaration.declaration | default({}) }}"
      check_mode: "{{ not (apply_changes | bool) }}"
      loop: "{{ upserts }}"
      loop_control:
        label: "#{{ item.id }}"

    - name: Complete the deletions (this decommissions the service items)
      netautomate.netorca.netorca_change_instance:
        id: "{{ item.id }}"
        state: COMPLETED
        log: removed by scheduled ansible run
      check_mode: "{{ not (apply_changes | bool) }}"
      loop: "{{ deletions }}"
      loop_control:
        label: "#{{ item.id }}"

    - name: Summary
      ansible.builtin.debug:
        msg: >-
          {{ upserts | length }} applied, {{ deletions | length }} removed
          {{ '(check mode - nothing written; add -e apply_changes=true)' if not (apply_changes | bool) else '' }}

Reconcile deployed state

Deployed items as a durable state store: audit which in-service items lack a deployed record, optionally stamp them - the reconciliation pattern for out-of-band fixes.

---
# Persona: service owner
# Deployed items as a durable state store: consumers see a service item's
# deployed item as "what actually serves my request", so it should never be
# missing or stale. This play audits every in-service item and (optionally)
# stamps the ones that have no deployed record at all - the pattern for
# reconciling after out-of-band fixes, migrations, or the WAF demo's
# tuning-data loop, where external processes keep enriching the record.
#
# SAFE BY DEFAULT: stamps are only written with -e apply_changes=true.
#
#   ansible-playbook examples/scenarios/load_balancer/reconcile_deployed_state.yml
#   ansible-playbook examples/scenarios/load_balancer/reconcile_deployed_state.yml -e apply_changes=true
- name: Reconcile deployed items with the in-service estate
  hosts: localhost
  gather_facts: false
  vars:
    ansible_python_interpreter: "{{ ansible_playbook_python }}"
    # limit to one service; leave empty to audit everything you own
    service_name: ""
    apply_changes: false
  tasks:
    - name: The in-service estate
      netautomate.netorca.netorca_service_item_info:
        service_name: "{{ [service_name] if service_name else omit }}"
        runtime_state: [IN_SERVICE]
      register: estate

    - name: Which items have no deployed record?
      ansible.builtin.set_fact:
        undocumented: "{{ estate.service_items | rejectattr('deployed_item') | list }}"

    - name: Report the drift
      ansible.builtin.debug:
        msg: >-
          {{ estate.count }} item(s) in service,
          {{ undocumented | length }} without a deployed item:
          {{ undocumented | map(attribute='name') | list }}

    - name: Stamp a reconciliation record on the undocumented items
      netautomate.netorca.netorca_deployed_item:
        service_item_id: "{{ item.id }}"
        data:
          reconciled: true
          declaration: "{{ item.declaration }}"
          note: stamped by reconcile_deployed_state.yml - replace with real deployment facts
      check_mode: "{{ not (apply_changes | bool) }}"
      loop: "{{ undocumented }}"
      loop_control:
        label: "{{ item.name }}"

    - name: Re-running is a no-op - the module deep-compares the data
      ansible.builtin.debug:
        msg: >-
          done {{ '(check mode - nothing written; add -e apply_changes=true)'
                  if not (apply_changes | bool) else '' }}

Audit change history

The audit trail: recent completed changes, then the full state history (who moved it, through which states, when) of the newest one.

---
# Persona: service owner
# Audit trail: list recently completed changes, then pull the full state
# history of the most recent one - who moved it through which states, when.
#
#   ansible-playbook examples/scenarios/load_balancer/audit_change_history.yml
- name: Audit completed changes
  hosts: localhost
  gather_facts: false
  vars:
    ansible_python_interpreter: "{{ ansible_playbook_python }}"
  tasks:
    - name: Last 5 completed changes across my services
      netautomate.netorca.netorca_change_instance_info:
        state: [COMPLETED]
        ordering: -modified
        limit: 5
      register: completed

    - name: Show them
      ansible.builtin.debug:
        msg: >-
          change #{{ item.id }}: {{ item.change_type }}
          on {{ item.service_item.name | default(item.service_item) }}
          (modified {{ item.modified }})
      loop: "{{ completed.change_instances }}"
      loop_control:
        label: "#{{ item.id }}"

    - name: Full state history of the most recent one
      netautomate.netorca.netorca_change_instance_info:
        id: "{{ completed.change_instances[0].id }}"
        include_history: true
      register: audited
      when: completed.count > 0

    - name: The audit trail
      ansible.builtin.debug:
        var: audited.history
      when: completed.count > 0

Scenario: requesting services from Git

The consumer GitOps flow; everything runs with context: consumer.

Query my items

Your estate, your open requests.

---
# Persona: consumer
# The same modules, opposite point of view: context=consumer scopes results to
# what YOUR team has requested/consumes, instead of what you serve to others.
# (Set NETORCA_CONTEXT=consumer to make it the default for a whole run.)
#
#   ansible-playbook examples/scenarios/consumer_gitops/query_my_items.yml
- name: What does my team consume
  hosts: localhost
  gather_facts: false
  vars:
    ansible_python_interpreter: "{{ ansible_playbook_python }}"
  tasks:
    - name: My consumed service items
      netautomate.netorca.netorca_service_item_info:
        context: consumer
      register: mine

    - name: Any of my requests still open?
      netautomate.netorca.netorca_change_instance_info:
        context: consumer
        state: [PENDING, APPROVED]
      register: open_requests

    - name: Report
      ansible.builtin.debug:
        msg:
          - "consuming {{ mine.count }} service item(s) across {{ mine.service_items | map(attribute='service.name') | unique | list | length }} service(s)"
          - "open requests: {{ open_requests.count }}"

Track to completion

The consumer pipeline's finishing move: block until everything from this merge is done, then fail loudly with the service owner's reasons if anything was rejected.

---
# Persona: consumer
# The consumer pipeline's finishing move: after a merge submits declarations,
# block until every change instance from that commit is done, then fail
# loudly with the service owner's reasons if anything was rejected or
# errored. The commit_id filter ties change instances back to the exact
# merge that caused them.
#
# Read-only - safe against any shared instance.
#
#   ansible-playbook examples/scenarios/consumer_gitops/track_to_completion.yml -e commit=abc1234
#   (in CI, commit defaults to CI_COMMIT_SHORT_SHA)
- name: Wait for my merge to be fulfilled
  hosts: localhost
  gather_facts: false
  vars:
    ansible_python_interpreter: "{{ ansible_playbook_python }}"
    commit: "{{ lookup('ansible.builtin.env', 'CI_COMMIT_SHORT_SHA') }}"
    wait_retries: 60
    wait_delay: 30
  tasks:
    - name: A commit to track must be named
      ansible.builtin.assert:
        that: commit | length > 0
        fail_msg: "pass -e commit=<short sha> (or run in CI where CI_COMMIT_SHORT_SHA is set)"

    - name: Wait until no change from this commit is still open
      netautomate.netorca.netorca_change_instance_info:
        context: consumer
        commit_id: "{{ commit }}"
        state: [PENDING, APPROVED]
      register: open_changes
      until: open_changes.count == 0
      retries: "{{ wait_retries }}"
      delay: "{{ wait_delay }}"

    - name: Did anything get rejected or fail?
      netautomate.netorca.netorca_change_instance_info:
        context: consumer
        commit_id: "{{ commit }}"
        state: [REJECTED, ERROR]
      register: bad

    - name: Surface the service owner's reasons
      ansible.builtin.fail:
        msg: >-
          {{ bad.count }} change(s) not fulfilled:
          {{ bad.change_instances | map(attribute='log') | list }}
      when: bad.count > 0

    - name: All deployed
      ansible.builtin.debug:
        msg: "everything from commit {{ commit }} is COMPLETED"

Scenario: AI-operated service with Pack

The Pack workflow - the AI renders, your automation executes. Concepts in the Pack and AI guide.

Pack status

Read-only estate readout: which services have Pack on, the processors and models behind them, and the executor work queue.

---
# Persona: service owner
# One-page readout of the Pack estate: which services have Pack switched on,
# the AI processors behind them, the LLM catalogue, and the executor work
# queue (successful runs nobody has applied yet). Optionally zoom into one
# service item's newest run and its rendered config.
#
# Read-only - safe against any shared instance.
#
#   ansible-playbook examples/scenarios/pack_ai/pack_status.yml
#   ansible-playbook examples/scenarios/pack_ai/pack_status.yml -e service_item_id=389
- name: Pack estate status
  hosts: localhost
  gather_facts: false
  vars:
    ansible_python_interpreter: "{{ ansible_playbook_python }}"
    # zoom into one service item's latest run; 0 = skip
    service_item_id: 0
  tasks:
    - name: Pack profiles (the per-service switches)
      netautomate.netorca.netorca_pack_profile_info:
      register: profiles

    - name: AI processors
      netautomate.netorca.netorca_ai_processor_info:
      register: processors

    - name: LLM model catalogue (extra_data values are always REDACTED)
      netautomate.netorca.netorca_llm_model_info:
      register: models

    - name: Executor work queue - successful runs not yet applied
      netautomate.netorca.netorca_pack_pipeline_info:
        state: [OK]
        applied: false
        limit: 10
      register: queue

    - name: Newest run of the chosen service item
      netautomate.netorca.netorca_pack_pipeline_info:
        latest: true
        object_id: "{{ service_item_id }}"
      register: latest
      when: service_item_id | int > 0

    - name: Its rendered config stage
      netautomate.netorca.netorca_pack_data_info:
        object_id: "{{ service_item_id }}"
        stage: config
      register: config_data
      when: service_item_id | int > 0

    - name: Status
      ansible.builtin.debug:
        msg:
          - "pack enabled on: {{ profiles.pack_profiles | selectattr('pack_enabled') | map(attribute='service') | list }}"
          - "processors: {{ processors.ai_processors | map(attribute='name') | list }}"
          - "llm models: {{ models.llm_models | selectattr('is_active') | map(attribute='name') | list }}"
          - "executor queue: {{ queue.count }} run(s) waiting"
          - >-
            {{ 'latest run of item ' ~ service_item_id ~ ': v' ~ latest.pack_pipelines[0].version
               ~ ' state=' ~ latest.pack_pipelines[0].state
               ~ ' cost=' ~ latest.pack_pipelines[0].cost
               if (service_item_id | int > 0 and latest.count > 0) else 'no item zoom requested' }}

Provision the AI stack

Processors and the Pack switch, declaratively; LLM models are referenced read-only (their management is GUI-only by platform design).

---
# Persona: service owner
# Provision the whole AI stack of a service declaratively: pick an LLM model
# from the platform catalogue, upsert the pipeline processors (config +
# verify, optionally a change validator that can auto-approve), and switch
# Pack on. Re-running converges; editing a prompt updates exactly that
# processor.
#
# LLM models themselves are configured once by an admin in the GUI - the
# platform accepts model writes only from superuser sessions, so playbooks
# reference them read-only by name.
#
# SAFE BY DEFAULT: nothing is written without -e apply_changes=true.
#
#   ansible-playbook examples/scenarios/pack_ai/provision_ai_stack.yml -e service_id=49
#   ansible-playbook examples/scenarios/pack_ai/provision_ai_stack.yml -e service_id=49 -e apply_changes=true
- name: Provision a service's AI stack
  hosts: localhost
  gather_facts: false
  vars:
    ansible_python_interpreter: "{{ ansible_playbook_python }}"
    service_id: 0
    apply_changes: false
    llm_model_name: ""  # empty = first active model in the catalogue
    enable_validator: false
    config_prompt: |
      Render F5 AS3 for the VIRTUAL_SERVER declaration: one HTTPS virtual
      server vs_<name> on the declared vip, one pool pool_<name> with the
      declared backend. Follow the knowledge documents for naming and
      monitors.
    verify_prompt: |
      Verify the rendered AS3 matches the declaration exactly - names, vip,
      backend. Approve only exact intent matches; list every mismatch.
  tasks:
    - name: A real service must be named
      ansible.builtin.assert:
        that: service_id | int > 0
        fail_msg: "pass -e service_id=<id of a service your team owns>"

    - name: The platform's LLM catalogue
      netautomate.netorca.netorca_llm_model_info:
      register: models

    - name: Pick the model
      ansible.builtin.set_fact:
        llm_id: >-
          {{ (models.llm_models | selectattr('name', 'eq', llm_model_name) | list
              if llm_model_name else models.llm_models | selectattr('is_active') | list)
             | map(attribute='id') | first | default(0) }}

    - name: A usable model must exist
      ansible.builtin.assert:
        that: llm_id | int > 0
        fail_msg: >-
          no matching LLM model - an admin configures models in the GUI's
          Platform Settings (available: {{ models.llm_models | map(attribute='name') | list }})

    - name: Config processor - declaration in, rendered config out
      netautomate.netorca.netorca_ai_processor:
        service_id: "{{ service_id }}"
        action_type: config
        name: vip_config
        llm_model: "{{ llm_id }}"
        prompt: "{{ config_prompt }}"
        extra_data:
          enable_pack_context: true
        active: true
      check_mode: "{{ not (apply_changes | bool) }}"

    - name: Verify processor - checks the rendering before anyone deploys it
      netautomate.netorca.netorca_ai_processor:
        service_id: "{{ service_id }}"
        action_type: verify
        name: vip_verify
        llm_model: "{{ llm_id }}"
        prompt: "{{ verify_prompt }}"
        active: true
      check_mode: "{{ not (apply_changes | bool) }}"

    - name: Change validator - reviews PENDING requests as they arrive
      netautomate.netorca.netorca_ai_processor:
        service_id: "{{ service_id }}"
        action_type: change_instance_validator
        name: request_validator
        llm_model: "{{ llm_id }}"
        prompt: Approve requests whose declaration names an allowed backend; reject with the reason otherwise.
        extra_data:
          allow_auto_approval: false
          allow_auto_rejection: false
      check_mode: "{{ not (apply_changes | bool) }}"
      when: enable_validator | bool

    - name: Switch Pack on
      netautomate.netorca.netorca_pack_profile:
        service_id: "{{ service_id }}"
        pack_enabled: true
      check_mode: "{{ not (apply_changes | bool) }}"

    - name: Summary
      ansible.builtin.debug:
        msg: >-
          AI stack for service {{ service_id }}: model id {{ llm_id }},
          processors config+verify{{ '+validator' if enable_validator | bool else '' }}, pack on
          {{ '(check mode - nothing written; add -e apply_changes=true)' if not (apply_changes | bool) else '' }}

Manage knowledge

The AI knowledge base and its retrieval tuning, kept in sync like any other config in Git.

---
# Persona: service owner
# Keep a service's AI knowledge base and retrieval tuning declarative: the
# documents the processors render against, and the pack profile that governs
# how they reach the prompts. Both modules are idempotent upserts, so this
# play converges - run it from CI whenever the knowledge files change.
#
# SAFE BY DEFAULT: nothing is written without -e apply_changes=true.
#
#   ansible-playbook examples/scenarios/pack_ai/manage_knowledge.yml -e service_id=49
#   ansible-playbook examples/scenarios/pack_ai/manage_knowledge.yml -e service_id=49 -e apply_changes=true
- name: Manage a service's Pack knowledge base
  hosts: localhost
  gather_facts: false
  vars:
    ansible_python_interpreter: "{{ ansible_playbook_python }}"
    service_id: 0
    apply_changes: false
    # in real life: loop over files in your repo instead
    knowledge_docs:
      - filename: bigip-standards.md
        content: |
          # Load balancer standards
          Virtual servers are named vs_<name> and pools pool_<name>, on /Common.
          Health monitors: tcp for TCP apps, http with a GET / for web apps.
      - filename: network-facts.md
        content: |
          # Network facts
          VIP ranges live in 10.1.10.0/24; backends in 10.0.0.0/16.
  tasks:
    - name: A real service must be named
      ansible.builtin.assert:
        that: service_id | int > 0
        fail_msg: "pass -e service_id=<id of a service your team owns>"

    - name: Sync the knowledge documents
      netautomate.netorca.netorca_ai_document:
        service_id: "{{ service_id }}"
        filename: "{{ item.filename }}"
        raw_content: "{{ item.content }}"
        enabled: true
      loop: "{{ knowledge_docs }}"
      loop_control:
        label: "{{ item.filename }}"
      check_mode: "{{ not (apply_changes | bool) }}"

    - name: Tune retrieval and make sure Pack is on
      netautomate.netorca.netorca_pack_profile:
        service_id: "{{ service_id }}"
        pack_enabled: true
        top_k: 10
        cosine_similarity_threshold: 0.8
      check_mode: "{{ not (apply_changes | bool) }}"

    - name: What the service effectively runs with
      netautomate.netorca.netorca_pack_profile_info:
        resolved_for: "{{ service_id }}"
      register: effective

    - name: Summary
      ansible.builtin.debug:
        msg: >-
          {{ knowledge_docs | length }} document(s) synced;
          effective profile: pack_enabled={{ effective.pack_profiles[0].pack_enabled }},
          top_k={{ effective.pack_profiles[0].top_k }}
          {{ '(check mode - nothing written; add -e apply_changes=true)' if not (apply_changes | bool) else '' }}

Pack executor loop

The executor's whole job: pick up successful runs, deploy what the AI rendered, report into the run, mark applied - failures reported too, feeding the retrigger loop.

---
# Persona: service owner
# The pack EXECUTOR LOOP, run on a schedule: pick up successful pipeline runs
# nobody has applied yet, deploy what the AI rendered, report the result into
# the run, and take it off the queue. The AI half of the workflow (rendering,
# verification) already happened server-side - this is the only part Pack
# leaves to you.
#
# Replace the "Deploy the rendered config" task with your infra role (F5 AS3,
# panos, k8s, ...). The rendered payload is pipeline.config.data.
#
# SAFE BY DEFAULT: results are only written with -e apply_changes=true;
# without it the write tasks run in check mode (nothing is sent).
#
#   ansible-playbook examples/scenarios/pack_ai/executor_loop.yml
#   ansible-playbook examples/scenarios/pack_ai/executor_loop.yml -e apply_changes=true
- name: Pack executor loop
  hosts: localhost
  gather_facts: false
  vars:
    ansible_python_interpreter: "{{ ansible_playbook_python }}"
    apply_changes: false
  tasks:
    - name: Runs waiting for an executor
      netautomate.netorca.netorca_pack_pipeline_info:
        state: [OK]
        applied: false
      register: queue

    - name: Show the queue
      ansible.builtin.debug:
        msg: >-
          {{ queue.count }} run(s) waiting
          {{ '- nothing to execute this pass' if queue.count == 0 else '- executing now' }}

    - name: Execute each run
      ansible.builtin.include_tasks: tasks/execute_one.yml
      loop: "{{ queue.pack_pipelines | selectattr('config') | list }}"
      loop_control:
        loop_var: pipeline
        label: "pipeline #{{ pipeline.id }} v{{ pipeline.version }}"

The per-run task file it includes:

---
# One pipeline run: deploy, report into the run, mark applied - with the
# failure path reporting too (the retrigger loop feeds on honest errors).
- name: "Execute pipeline #{{ pipeline.id }}"
  block:
    - name: Deploy the rendered config (placeholder - your infrastructure step)
      ansible.builtin.debug:
        msg: >-
          deploying stage data #{{ pipeline.config.id }}
          for {{ pipeline.config.scope.scope }} {{ pipeline.config.object_id }}

    - name: Report success into the run
      netautomate.netorca.netorca_pack_data:
        object_id: "{{ pipeline.config.object_id }}"
        object_type: "{{ pipeline.config.scope.scope }}"
        stage: execution
        data:
          success: true
          deployed_at: "{{ now(utc=true).isoformat() }}"
      check_mode: "{{ not (apply_changes | bool) }}"

    - name: Take the run off the queue
      netautomate.netorca.netorca_pack_pipeline:
        id: "{{ pipeline.id }}"
        applied: true
      check_mode: "{{ not (apply_changes | bool) }}"

  rescue:
    - name: Report the failure into the run
      netautomate.netorca.netorca_pack_data:
        object_id: "{{ pipeline.config.object_id }}"
        object_type: "{{ pipeline.config.scope.scope }}"
        stage: execution
        data:
          success: false
          error: "{{ ansible_failed_result.msg | default('unknown error') }}"
      check_mode: "{{ not (apply_changes | bool) }}"

Full lifecycle

The whole story in one play, live-proven end to end: declare a service item (additive PATCH submission - the interim pattern until the submission module ships), the change auto-raises and the AI renders and verifies, you execute and complete the change with a deployed item, then a retrigger carries executor feedback into a re-render. Fires real LLM runs, so it arms only with -e confirm=true.

---
# Persona: consumer + service owner (one team wearing both hats)
# The COMPLETE Pack lifecycle in one play, exactly as it runs in production:
#
#   declare a service item -> the change is raised (and approved) -> the AI
#   renders the config and verifies it -> you execute and report back ->
#   complete the change with a deployed item -> retrigger with executor
#   feedback -> the AI re-renders honouring it -> close the loop.
#
# The submission step uses ansible.builtin.uri because the consumer
# submission module ships later in the v2 series; every other step is this
# collection. The PATCH submit is ADDITIVE (the new application is merged
# into your declared state) - never POST a partial state, that raises DELETE
# changes for everything you leave out.
#
# COSTS REAL MONEY (two LLM runs) and writes for real, so it only runs armed:
#
#   ansible-playbook examples/scenarios/pack_ai/full_lifecycle.yml -e confirm=true
#   ... -e demo_name=my-vip -e vip=10.1.10.201 -e backend=10.0.0.77:8080
#
# Re-running with an unchanged declaration is a clean no-op: declarative
# submission raises no changes, and the play ends early.
- name: Full Pack lifecycle - declare, AI render and verify, execute, feedback loop
  hosts: localhost
  gather_facts: false
  vars:
    ansible_python_interpreter: "{{ ansible_playbook_python }}"
    confirm: false
    service_name: VIRTUAL_SERVER
    team_name: networks
    app_name: ansible_e2e_app
    demo_name: ansible-e2e-demo
    vip: 10.1.10.198
    backend: "10.0.0.98:8080"
    retrigger_comment: >-
      Executor feedback: the backend health endpoint is TCP-only, use a tcp
      monitor on pool_{{ demo_name }} instead of http. Everything else was
      deployed as rendered.
    api_root: "{{ lookup('ansible.builtin.env', 'NETORCA_API_URL') | regex_replace('/v1/?$', '') }}"
  tasks:
    - name: This play fires real LLM runs - arm it explicitly
      ansible.builtin.assert:
        that: confirm | bool
        fail_msg: re-run with -e confirm=true (two LLM runs will be billed to the platform's model)

    # ------------------------------------------------------------ declare
    - name: Build the submission envelope (task-arg dict KEYS are never templated)
      ansible.builtin.set_fact:
        envelope: >-
          {{ {team_name: {
                'metadata': {'team_name': team_name},
                app_name: {
                  'metadata': {'owner': 'ansible-demo', 'environment': 'demo'},
                  'services': {service_name: [
                    {'name': demo_name, 'vip': vip, 'backend': backend, 'waf': false}
                  ]}}}} }}

    - name: Declare the service item (consumer submission, additive PATCH)
      ansible.builtin.uri:
        url: "{{ api_root }}/v1/orcabase/consumer/submissions/submit/"
        method: PATCH
        # uri verifies against the system CA store; export SSL_CERT_FILE (for
        # example to certifi's bundle) if your OS store lacks the issuer.
        validate_certs: "{{ lookup('ansible.builtin.env', 'NETORCA_VALIDATE_CERTS') | default(true, true) | bool }}"
        headers:
          Authorization: "Api-Key {{ lookup('ansible.builtin.env', 'NETORCA_API_KEY') }}"
        body_format: json
        body: "{{ envelope }}"
        status_code: [200, 201]
      register: submission

    - name: What this submission raised for our item
      ansible.builtin.set_fact:
        raised: >-
          {{ submission.json.change_instances | default([])
             | selectattr('service_item.name', 'eq', demo_name) | list }}

    - name: Unchanged declaration raises no changes - nothing to do
      ansible.builtin.meta: end_play
      when: raised | length == 0

    - name: The change and its service item
      ansible.builtin.set_fact:
        change_id: "{{ raised[0].id }}"
        item_id: "{{ raised[0].service_item.id }}"

    - name: Raised
      ansible.builtin.debug:
        msg: >-
          change #{{ change_id }} ({{ raised[0].change_type }},
          state {{ raised[0].state }}) on service item {{ item_id }} ({{ demo_name }})

    # ------------------------------------------------- approve if needed
    - name: Approve the change if this service requires approval
      netautomate.netorca.netorca_change_instance:
        id: "{{ change_id }}"
        state: APPROVED
        log: approved by the full-lifecycle demo
      when: raised[0].state == 'PENDING'

    # ------------------------------------------ the AI renders + verifies
    - name: Wait for the auto-triggered pipeline to finish (config -> verify)
      netautomate.netorca.netorca_pack_pipeline_info:
        latest: true
        object_id: "{{ item_id }}"
      register: run
      until: run.count == 1 and run.pack_pipelines[0].state in ['OK', 'FAILED']
      retries: 40
      delay: 15

    - name: The AI must have produced a healthy run
      ansible.builtin.assert:
        that: run.pack_pipelines[0].state == 'OK'
        fail_msg: "pipeline {{ run.pack_pipelines[0].id }} FAILED - inspect its stage data"

    - name: Read what the AI rendered and how verify ruled
      netautomate.netorca.netorca_pack_data_info:
        object_id: "{{ item_id }}"
        stage: "{{ item }}"
      loop: [config, verify]
      register: stages

    - name: Render and verdict
      ansible.builtin.debug:
        msg:
          - "pipeline {{ run.pack_pipelines[0].id }} v{{ run.pack_pipelines[0].version }} cost={{ run.pack_pipelines[0].cost }}"
          - "config keys: {{ stages.results[0].pack_data[0].data.keys() | list }}"
          - "verify approved: {{ stages.results[1].pack_data[0].data.approved }}"

    # ------------------------------------------------------- execute (us)
    - name: Deploy the rendered config (placeholder - your infrastructure step)
      ansible.builtin.debug:
        msg: "deploying {{ stages.results[0].pack_data[0].data.as3_json.id | default('the rendered config') }}"

    - name: Report the execution result into the run
      netautomate.netorca.netorca_pack_data:
        object_id: "{{ item_id }}"
        stage: execution
        data:
          success: true
          simulated: true
          executor: netautomate.netorca full_lifecycle.yml
          deployed_at: "{{ now(utc=true).isoformat() }}"

    - name: Take the run off the executor queue
      netautomate.netorca.netorca_pack_pipeline:
        id: "{{ run.pack_pipelines[0].id }}"
        applied: true

    - name: Complete the change, recording what serves the request
      netautomate.netorca.netorca_change_instance:
        id: "{{ change_id }}"
        state: COMPLETED
        log: "full lifecycle demo: AI-rendered config executed (pipeline {{ run.pack_pipelines[0].id }})"
        deployed_item:
          vs_name: "vs_{{ demo_name }}"
          pool_name: "pool_{{ demo_name }}"
          vip: "{{ vip }}"
          rendered_by: "pack pipeline {{ run.pack_pipelines[0].id }} v{{ run.pack_pipelines[0].version }}"
          simulated: true

    # ------------------------------------------ self-heal: feedback loop
    - name: Retrigger with executor feedback for the AI
      netautomate.netorca.netorca_pack_trigger:
        object_id: "{{ item_id }}"
        retrigger: true
        comment: "{{ retrigger_comment }}"

    - name: Wait for the re-rendered run (new version, terminal state)
      netautomate.netorca.netorca_pack_pipeline_info:
        latest: true
        object_id: "{{ item_id }}"
      register: rerun
      until: >-
        rerun.count == 1
        and (rerun.pack_pipelines[0].version | int) > (run.pack_pipelines[0].version | int)
        and rerun.pack_pipelines[0].state in ['OK', 'FAILED']
      retries: 40
      delay: 15

    - name: How the AI honoured the feedback
      netautomate.netorca.netorca_pack_data_info:
        object_id: "{{ item_id }}"
        stage: config
      register: config2

    - name: Report the re-render's execution and close the loop
      netautomate.netorca.netorca_pack_data:
        object_id: "{{ item_id }}"
        stage: execution
        data:
          success: true
          simulated: true
          executor: netautomate.netorca full_lifecycle.yml
          note: re-render accepted
      when: rerun.pack_pipelines[0].state == 'OK'

    - name: Take the re-render off the queue
      netautomate.netorca.netorca_pack_pipeline:
        id: "{{ rerun.pack_pipelines[0].id }}"
        applied: true
      when: rerun.pack_pipelines[0].state == 'OK'

    # ------------------------------------------------------------ report
    - name: The service item afterwards
      netautomate.netorca.netorca_service_item_info:
        id: "{{ item_id }}"
      register: final_item

    - name: All runs of the item
      netautomate.netorca.netorca_pack_pipeline_info:
        service_item_id: ["{{ item_id }}"]
      register: all_runs

    - name: Final report
      ansible.builtin.debug:
        msg:
          - >-
            item {{ demo_name }} (id {{ item_id }}):
            runtime_state={{ final_item.service_items[0].runtime_state }}
            change_state={{ final_item.service_items[0].change_state }}
          - "deployed_item: {{ final_item.service_items[0].deployed_item.data }}"
          - >-
            v{{ rerun.pack_pipelines[0].version }} pool monitors after feedback:
            {{ (config2.pack_data[0].data.as3_json.declaration.Common.Shared['pool_' ~ demo_name].monitors)
               | default('n/a') }}
          - "total LLM cost: {{ all_runs.pack_pipelines | map(attribute='cost') | map('float') | sum | round(4) }}"

Guides

Find service items by declaration content - substring, regex or exact-match, per field (mechanics).

---
# Persona: service owner
# Search service items BY THEIR DECLARATION CONTENT - the platform's advanced
# search (docs.netorca.io/api_guide/advanced_search/) as first-class module
# parameters. Each key in the dict matches against that field of the consumer
# declaration: substring (declaration_contains), regex (declaration_regex) or
# exact (declaration).
#
#   ansible-playbook examples/guides/declaration_search.yml
- name: Find service items by declaration content
  hosts: localhost
  gather_facts: false
  vars:
    ansible_python_interpreter: "{{ ansible_playbook_python }}"
  tasks:
    - name: Items whose declared target contains 'netorca.io'
      netautomate.netorca.netorca_service_item_info:
        declaration_contains:
          target: netorca.io
      register: by_target

    - name: Items whose declared name matches ^pattern (regex)
      netautomate.netorca.netorca_service_item_info:
        declaration_regex:
          name: "^pattern"
      register: by_name

    - name: Report
      ansible.builtin.debug:
        msg:
          - "target contains 'netorca.io': {{ by_target.count }} item(s): {{ by_target.service_items | map(attribute='name') | list }}"
          - "name matches ^pattern: {{ by_name.count }} item(s): {{ by_name.service_items | map(attribute='name') | list }}"

Guardrails demo

Hold the collection wrong, safely: an illegal transition (blocked client-side), an invalid enum (rejected by the argument spec), a lookup that matches nothing (empty, not an error). Read error handling alongside it.

---
# What the collection's guardrails look like when you hold it wrong.
# Every task here is expected to fail (or come back empty) SAFELY - nothing
# is ever written. Useful reading for playbook authors and AI generators.
#
#   ansible-playbook examples/guides/guardrails_demo.yml
- name: Guardrails demonstration
  hosts: localhost
  gather_facts: false
  vars:
    ansible_python_interpreter: "{{ ansible_playbook_python }}"
  tasks:
    - name: Find a completed change to poke at
      netautomate.netorca.netorca_change_instance_info:
        state: [COMPLETED]
        limit: 1
      register: completed

    - name: "1) Illegal transition is blocked client-side, before any API write"
      netautomate.netorca.netorca_change_instance:
        id: "{{ completed.change_instances[0].id }}"
        state: APPROVED
      register: illegal
      ignore_errors: true
      when: completed.count > 0

    - name: "2) Invalid enum value is rejected by the module's argument spec"
      netautomate.netorca.netorca_change_instance_info:
        state: [BANANA]
      register: bad_choice
      ignore_errors: true

    - name: "3) A lookup that matches nothing is empty, not an error"
      netautomate.netorca.netorca_service_item_info:
        id: 999999999
      register: missing

    - name: What the caller sees
      ansible.builtin.debug:
        msg:
          - "illegal transition -> {{ illegal.msg | default('(no completed change on this team to demo with)') }}"
          - "invalid choice -> {{ bad_choice.msg }}"
          - "missing id -> count={{ missing.count }}, failed={{ missing.failed | default(false) }}"