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 and approve. NetOrca already schema-validated each declaration, so the marked HERE IS YOUR CUSTOM VALIDATION block is an optional, commented-out placeholder for business checks the schema can't do (then reject the offenders). New to this? See Writing your own validation. Decisions are only written with -e apply_decisions=true.

---
# Persona: service owner
# The VALIDATION stage of the workflow (the deployment stage is either
# render_and_deploy_as3.yml (declarative) or deploy_by_change_type.yml
# (imperative)): poll PENDING changes and approve them.
#
# NetOrca has ALREADY validated each declaration against the service's JSON
# Schema before it becomes a PENDING change, so structure is guaranteed here.
# Custom validation is optional and only for what the schema cannot express
# (estate conflicts, IPAM, quota). See docs/guides/custom_validation.md.
#
# 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

    # =======================================================================
    # HERE IS YOUR CUSTOM VALIDATION (optional)
    # NetOrca already validated the declaration against the service schema, so
    # structure is guaranteed - don't re-check fields here. Only for a business
    # check the schema can't do (estate conflicts, IPAM, quota): reject the
    # offenders by uncommenting and editing the task below, then add
    # `when: not <your test>` to the approve task so it skips them.
    #
    # - name: Reject changes that fail your check
    #     netautomate.netorca.netorca_change_instance:
    #       id: "{{ item.id }}"
    #       state: REJECTED
    #       log: "rejected: <your reason>"
    #     check_mode: "{{ not (apply_decisions | bool) }}"
    #     loop: "{{ pending.change_instances }}"
    #     when: <your test against item>
    # =======================================================================
    - name: Approve each pending change
      netautomate.netorca.netorca_change_instance:
        id: "{{ item.id }}"
        state: APPROVED
        log: "approved by automated validation"
      check_mode: "{{ not (apply_decisions | bool) }}"
      loop: "{{ pending.change_instances }}"
      loop_control:
        label: "#{{ item.id }} ({{ item.change_type }})"

    - name: Summary
      ansible.builtin.debug:
        msg: >-
          {{ pending.count }} pending change(s) approved
          {{ '(check mode - nothing written; add -e apply_decisions=true)' if not (apply_decisions | bool) else '' }}

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 (F5/AS3 style): render the entire desired
# state of the BIG-IP tenant from every IN_SERVICE declaration and push it as
# one AS3 call, then mark the approved changes COMPLETED. Deletes need no
# special handling - a decommissioned item is no longer IN_SERVICE, so it drops
# out of the render and AS3 reconciles it away. If the push fails the run fails
# and nothing is completed, so the changes stay APPROVED for the next run.
#
# SAFE BY DEFAULT:
#   - without BIGIP_URL in the environment, the rendered AS3 is printed
#     instead of pushed (so the render 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: Render the whole tenant from the in-service declarations
      ansible.builtin.set_fact:
        tenant_body: "{{ lookup('ansible.builtin.template', 'templates/as3_tenant.j2') }}"
      vars:
        desired_items: "{{ estate.service_items }}"

    # -----------------------------------------------------------------
    # HERE IS YOUR CUSTOM DEPLOYMENT - swap for your platform
    # Push the rendered desired state. A failure here fails the run, so
    # nothing is completed and the batch stays APPROVED for the next run.
    # -----------------------------------------------------------------
    - name: Push the rendered tenant to BIG-IP
      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') != ''
    # --- END OF YOUR CUSTOM DEPLOYMENT ---

    - 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: Mark every approved change COMPLETED
      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) }}"
      check_mode: "{{ not (apply_changes | bool) }}"
      loop: "{{ work.change_instances }}"
      loop_control:
        label: "#{{ item.id }} ({{ item.change_type }})"

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: get the APPROVED changes of each type (the change_type filter) and run your placeholder action on them - CREATE/MODIFY apply the new declaration, DELETE tears down what the old declaration describes.

---
# Persona: service owner
# The IMPERATIVE deployment stage (PaloAlto style), for infrastructure without
# a full-state render (per-object APIs): get the APPROVED change instances of
# each type and run your action on them - CREATE/MODIFY apply the new
# declaration, DELETE tears down what the old declaration described. Each change
# is marked COMPLETED after its action; a failed action fails the run and leaves
# the change APPROVED for the next run (we never mark it ERROR).
#
# The two "apply/remove" tasks are PLACEHOLDERS - swap in your infra role or a
# device module (panos_*, bigip, ...).
#
# 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: Get APPROVED creates and modifies
      netautomate.netorca.netorca_change_instance_info:
        service_name: "{{ [service_name] if service_name else omit }}"
        state: [APPROVED]
        change_type: [CREATE, MODIFY]
        exclude_referenced: true
      register: upserts

    # =================================================================
    # HERE IS YOUR CUSTOM DEPLOYMENT (create / modify)
    # Replace this placeholder with your real infra step - apply
    # item.new_declaration.declaration to your platform.
    # =================================================================
    - name: Apply each new or changed declaration
      ansible.builtin.debug:
        msg: "apply {{ item.new_declaration.declaration | default({}) }}"
      loop: "{{ upserts.change_instances }}"
      loop_control:
        label: "#{{ item.id }} ({{ item.change_type }})"

    - 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.change_instances }}"
      loop_control:
        label: "#{{ item.id }}"

    - name: Get APPROVED deletes
      netautomate.netorca.netorca_change_instance_info:
        service_name: "{{ [service_name] if service_name else omit }}"
        state: [APPROVED]
        change_type: [DELETE]
        exclude_referenced: true
      register: deletions

    # =================================================================
    # HERE IS YOUR CUSTOM DEPLOYMENT (delete)
    # Replace this placeholder with your real infra step - tear down
    # what item.old_declaration.declaration described.
    # =================================================================
    - name: Tear down each deleted declaration
      ansible.builtin.debug:
        msg: "remove {{ (item.old_declaration | default({})).declaration | default({}) }}"
      loop: "{{ deletions.change_instances }}"
      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.change_instances }}"
      loop_control:
        label: "#{{ item.id }}"

    - name: Summary
      ansible.builtin.debug:
        msg: >-
          {{ upserts.count }} applied, {{ deletions.count }} 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) }}"