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¶
Declaration search¶
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) }}"