sales.demos — project history
Frozen archive — retired 2026-09-10
This is CHANGELOG.md from
ericcames/sales.demos as it stood
when the per-PR changelog obligation was retired
(sales.demos#432).
Nothing is appended to it again.
Bare issue numbers refer to ericcames/sales.demos. They do not autolink
on this site — read (#423) as
https://github.com/ericcames/sales.demos/issues/423. References to other
repos are written out in full.
Entries are verbatim and newest first. For anything after 2026-09-10, see where things live now.
Changed -- README is a front door again, 944 lines to 236 (#423)
- It described a repo that no longer existed in six places. Each was
checkable against the tree, and each was wrong:
demos/ocpvirt/was documented as holding "job templates, surveys", and the skill/playbook contract table citeddemos/ocpvirt/controller_job_templates.yml. That file has never existed. The directory held a lone.gitkeep; job templates live ininventory/group_vars/aap/controller_templates.yml. Directory removed.- The execution environment was given as
v1.1.0in three places andv1.0.0in a fourth. It isv1.2.0, since #324 added the helm binaryplaybooks/portal.ymlcannot run without. - The Windows golden image was described as unbuilt -- "until it lands,
quay_windows_imageis a placeholder and the link refuses to run". All three environments carry a real published tag, proven end to end on 2026-09-06 (#257). - "Phase 3 will drive this same module from AAP; until then it is run by
hand" -- contradicted by the README's own table three sections earlier,
and by
Linux Day 1 - 1 Provisionexisting. - "verified on AAP 2.6", where the platform is 2.7 (#101).
edgewas missing from six enumerations that said "both environments".
- Windows was effectively invisible. The AAP section listed 2 Linux job
templates. The automation defines 32 live job templates and 4 live
workflows, including the entire
Windows Day 1andWindows Day 2stories. That is now Running from AAP. - About four sections restated
CONTRIBUTING.mdat greater length -- secrets, the leak audit, the skill/playbook contract, verify-in-EE. Deleted rather than moved: two copies of a rule is how one of them goes stale, which is the whole lesson of the six defects above. - The skills tables stay in full. CI enforces that every skill appears there, and it is the repo's real index -- verified at 20 of 20.
Added -- terraform/ocpvirt/README.md (#423)
- The 145 lines of module reference the README carried: sizing, running it by hand, SSH, HTTP, Cockpit, Windows. Module docs belong with the module.
- Three more stale facts surfaced while writing it, all from #348 and #389:
- Tier sizes. The README said
largeis "6 GiB rather than 8".tiers.yamlsays 4 CPU / 16 GiB.smallis 2/4 andmediumis 2/8. available_memory_gbwas given as 67; the default is 63, measured on sandbox with Automation Orchestrator installed (#118, #141).- Outputs were named
web_url,cockpit_url,ssh_command. They are plural --web_urls,cockpit_urls,ssh_commands-- since VMs come in counts (#389).
- Tier sizes. The README said
- The
*-1cpu-2gbtier names are retained aliases and no longer describe the shape.large-2cpu-6gbprovisions 4 CPU and 16 GiB. Said once, explicitly, so nobody "helpfully" restores the old numbers. The same tables in the docs repo are ericcames/sales.demos-docs#14.
Fixed -- three documentation claims that had gone stale (#423)
secrets.yml.exampleis not "the only.examplefile in the repo", and has not been since 2026-08-09 --terraform/ocpvirt/terraform.tfvars.exampleshipped with the module in #28 and is tracked. It is legitimate for the same reason the other one is. The rule against proliferating them stands; the count stated as a fact is what went stale, silently.controller_execution_environments.ymlsaid this object would move todemos/ocpvirt/"when it gains a loader in #4". #4 shipped and it never did -- dispatch readsgroup_varsimplicitly, so every AAP object landed ingroup_vars/aap/. The comment now records what happened rather than a prediction that expired.assets/aap-branding/README.mdsaidedgehad no badged logo, which was true when it was written and stopped being true four commits later: #428 added the purple one. Corrected to name all three colours and to say what must move together when a fourth environment appears -- a colour inenv_colors.py, a generated pair inassets/aap-branding/, and agateway_settings.yml.check-env-logos.pycatches the last two automatically and already validates the edge pair #428 added; it cannot catch a missing colour.
Added -- env-urls.yml credentials and secrets guard (#429)
generate-env-urls.py --with-credsdecrypts the vault and includes usernames (fromconnection.yml) and passwords (fromsecrets.yml) in the gitignoredinventory/env-urls.yml. Full cheat sheet: AAP, OCP, Linux VM, and Windows VM credentials per environment.check-no-secrets.shnow verifiesinventory/env-urls.ymlis not tracked and is covered by.gitignore, the same two checks that protectsecrets.yml. With--with-credsthe file holds plaintext vault credentials, so the guard must cover it.
Added -- edge AAP environment-badged logo (#426)
- Edge gets a purple sign-in logo (
#6753AC), matching the green (sandbox) and red (demo) convention.gateway_settings_edgesetscustom_logovia the samefilelookup pattern. env_colors.pynow includes all three environments.- Browser extension
colors.jsonregenerated to include edge.
Added -- gitignored environment URL reference file (#426)
utilities/generate-env-urls.pyreadsopenshift_apps_domainfrom each environment'sconnection.ymland writesinventory/env-urls.ymlwith every product URL (AAP, OCP Console, OAuth, AO, Portal) per environment. Avoids burning MCP tokens rediscovering Route hostnames every Claude session.inventory/env-urls.ymlis gitignored. Regenerate after repointing an environment:python3 utilities/generate-env-urls.py.--checkmode exits non-zero if the file is missing or stale, suitable for a preflight check in skills.
Changed -- docs move to sales.demos-docs; runtime branding gets its own home (#422)
docs/is gone from this repo. Talk tracks, run sheets, design plans and documentation images now live only in sales.demos-docs, which absorbed them in that repo's #12. They existed in both repos with nothing keeping them in step, and 20 of the 35 shared files had drifted.- Tracked content drops from 4933 KB to 1883 KB, a 62% cut. The point is
that documentation churn no longer enters this repo's history at all, so AAP's
SCM project sync stops fetching image churn it never needed. This speeds up
subsequent syncs, not the initial clone -- git history keeps the blobs, and
.gitis unchanged at 14 MB. Worth doing for the first reason, not the second. assets/aap-branding/is new, and it is not documentation.inventory/group_vars/<env>/gateway_settings.ymlreadslogo-<env>.png.b64through afilelookup at playbook run time, including from AAP's SCM checkout, andmake-env-logo.pyreadsaap-logo-white.svgas its source artwork. Six files, 92 KB, moved withgit mvso history follows them.aap-logo-white.svgis why the directory exists. It is byte-identical to the copy in the docs repo, so a sweep of "images already duplicated over there" would have deleted it andmake-env-logo.pywould have stopped working with nothing to explain why. It only looks like a screenshot. Leaving six such files behind in a directory nameddocs/images/, immediately after deleting 21 documentation images from it, is how that happens six months later.- Every reference repointed:
ROADMAP.md,CLAUDE.md,README.md, three skills, four playbooks, threegroup_varsfiles,secrets.yml.example, and the 14utilities/notebooklm-sources.txtentries -- which name a repo per line and would have failed the run, not degraded it, since the collector exits 1 on a manifest file that does not exist.
Added -- the generated env logos are verified for the first time (#422)
utilities/check-env-logos.py, wired into thegenerated-filesCI job. Asserts eachlogo-<env>.png.b64really is the base64 of the.pngbeside it, and that everygateway_settings.ymllookup path resolves.- Nothing checked either half before. The
generated-filesjob already says why that matters, aboutcolors.json: "A committed generator output that nothing verifies is a copy waiting to drift." The logos were exactly that. Replace the PNG, forget the sidecar, and AAP serves the old logo with git looking correct andconfig.ymlreportingchangedas it always does. - It deliberately does not regenerate the PNG to compare. That needs Pillow,
ImageMagick with librsvg and the Red Hat Display font, and font rasterisation
is not byte-reproducible across machines -- a regenerate-and-diff check would
fail on a fontconfig change rather than on real drift. Same reason
check-docs-artifacts.pyskipsdemo-page.png. Base64 is deterministic, so the half that can be checked exactly, is. - Proven in both directions before merging: passes clean, and fails on a one-character change to a sidecar.
Changed -- the docs gate now works across the repo boundary (#422)
utilities/check-docs-artifacts.pytakes--docs-root, and CI checks outericcames/sales.demos-docsto point it there. The script stays here, besiderender-demo-assets.pyand thelinux_configurerole it reads templates from; only the markdown moved. One copy, invoked from both repos.- This preserves the direction #85 was opened for: edit
motd.j2and it fails here, at the moment the template changes. The honest cost is that such a change now needs its paired docs PR merged first, and the job says so when it fails. - The mirror job in the docs repo -- a talk-track edit failing on that side -- needs this flag to exist first, so it lands in a follow-up there.
utilities/render-demo-assets.pytakes--out, defaulting to../sales.demos-docs/docs/images/demo-page.png, and skips the screenshot with a clear message when that checkout is absent rather than failing. The text renderingcheck-docs-artifacts.pydepends on never touches the PNG, so the gate still works with no docs clone at all..claude/skills/sales-demos-talk-track/SKILL.mdtakesSALES_DEMOS_DOCS, defaulting to../sales.demos-docs, and fails early with thegit clonecommand if it is missing. The skill stays in this repo --CLAUDE.mdsays never to send someone to another repo's skill, and the docs repo has none.renderer-matches-roleis untouched: it compares the script againstplaybooks/roles/linux_configure, both local, and never readdocs/.- Still 8 required checks, none renamed.
Changed -- collections-sync is now sales-demos-collections-sync (#419)
- It was the one repo-wide skill without the
sales-demos-prefix, and it collided withimage.builder.pipeline's skill of the same name. Renamed.claude/skills/collections-sync/to.claude/skills/sales-demos-collections-sync/, matching every other repo-wide maintenance skill here --sales-demos-ee-build,sales-demos-mcp,sales-demos-verify-ee,sales-demos-first-time. sales-demos-first-time/SKILL.mdcontradicted itself, which is what made this more than cosmetic. Line 15 states "Thesales-demos-prefix keeps these unambiguous when other skills happen to be loaded on the same machine" -- and the same file then invoked/collections-syncwithout it, four times.- The failure it prevents is the silent kind. Skills are discovered from the
directory the agent starts in, so
/collections-syncresolved to whichever repo the session began in, and the two skills pin different dependency sets. Nothing in the output said which one ran. Reachable in practice since #418 documented working across both repos. - Nothing automated invoked it, verified before the rename: no playbook, no
inventory or AAP config, no CI job, no
.claude/settings.jsonentry. The 14 references were documentation, one Python error string, and a comment incollections/requirements.yml. All updated; no unprefixed reference remains. - The factory's own
collections-syncis deliberately left alone -- that repo has no prefix convention, and after this rename the two names no longer collide.
Changed -- Getting started now segments by audience (#418)
- Getting started served one audience of three, and the largest one first hit
a clone command it does not need. An SE presenting a demo needs a run sheet,
published at the docs site; they were landing on
git cloneand a prerequisites table about vault passwords.## Getting startednow opens with three doors -- presenting a demo (no clone), running or changing the automation (the previous content, commands unchanged), and working across both repos.sales.demos-docsalready had this shape and is the model. - The cross-repo working shape existed only in
CLAUDE.md. A new reader openingREADME.mdhad no way to learn that.mcp.jsonis project-scoped, so a session spanning this repo andimage.builder.pipelinemust start here -- the factory repo has no MCP servers at all. Promoted to a### Working across the factory and the platformsubsection. - "Clone all three and start an agent in sales.demos" was considered and
rejected: it does not work. Skills are discovered from the directory the
agent starts in, so
image.builder.pipeline's skills --first-time,dev-workflow,rhel9-containerdisk,windows-image-build-- are not reachable from a session started here, and typing/first-timewould get nothing. The new subsection says so explicitly rather than leaving it to be discovered. It would also have contradicted "These are links, not a workflow dependency. This repo stays self-contained" further down the same file. - The by-hand path is now stated as the fallback it has to be, since not
every reader has Claude Code.
sales-demos-first-time/SKILL.mdis almost entirely runnable shell; the README says that plainly instead of the weaker "reads perfectly well as a checklist". Claude Code stays named as the primary path -- the wording deliberately does not become "your favorite AI agent", which would be false: other agents do not discover.claude/skills/. README.mdnever linked toCONTRIBUTING.md. A contributor who arrived at the second door had no path to the branching and PR rules. Added.
Changed -- Community Standards parity and a value-first README opening (#416)
- The Code of Conduct was not detected as the Contributor Covenant, and the
reason was a missing contact.
CODE_OF_CONDUCT.mdwas a 41-line trim of Contributor Covenant 2.1 with the Enforcement Guidelines ladder removed and no enforcement contact -- it said reports "may be reported to the community leaders responsible for enforcement" without saying how. GitHub's community profile returned"code_of_conduct": {"key": "other"}as a result. Replaced with the full 128-line CC 2.1 already carried byimage.builder.pipeline, contactames@redhat.com, so all three repos are byte-identical here. - Blank issues bypassed the templates. Added
.github/ISSUE_TEMPLATE/config.ymlwithblank_issues_enabled: falseand contact links to the docs site, the security policy, and the contributing guide.bug_report.mdandfeature_request.mdexisted but GitHub offered "Open a blank issue" beside them. - The README opened with a stale status line. The blockquote said "Two use
cases: OpenShift Virtualization, and Private Automation Hub as code" while
the README's own
## Use casestable listed three and the docs site published four. Replaced with a value-first executive summary and an at-a-glance table (For / Produces / Run it / Status).## Getting startedkeeps its position immediately below. - Two enumerations in the body would have contradicted the new summary.
## Use caseswas missing Edge / Single Node OpenShift, and## Environmentsstill offeredsandboxanddemoonly -- the same recurring class as #405 and #414, in the file a first-time reader opens first.edgeis now described there as what it is: persistent bare-metal SNO, local DNS, no expiry, reached with--limit edge. - Set repository topics, which were unset.
Fixed -- MCP server enumerations still said five, and omitted openshift-edge (#414)
openshift-edgehas been a committed server since #375, but nothing that counts the servers was updated. Measured 2026-09-09:claude mcp listreturns six connected servers -- threeopenshift-*(sandbox and edge read-write at 25 tools, demo--read-onlyat 16), twoaap-*, andgrafana. The edge server is not merely configured:namespaces_listreturns a live SNO runningopenshift-cnv,openshift-compliance,aapandgrafana-alloy.- This is #405 one layer up. That issue fixed the four operator-facing messages that offered two environments; these are the agent instructions and the demo docs that count the servers.
- The demo docs were the sharp end.
server-inventory.mdopens by telling the presenter its tables are "the same format Claude Code renders when asked 'show me the MCP servers'", then asserts "Five is the whole list ... complete rather than abridged." Ask the question the doc invites and the screen says six -- a completeness claim disproved live, in front of the customer, by the tool the doc points at. - Updated
CLAUDE.md,ROADMAP.md,.claude/skills/sales-demos-mcp/SKILL.md(frontmatterdescriptionincluded -- its TRIGGER clause named the servers), and all six affected files underdocs/demos/mcp-servers/: the at-a-glance and condensed tables, the mermaid diagram, both spoken talk-track lines, the run-sheet arc, and the "Where the words come from" source table. openshift-edge's tool listing is stated as identical, not duplicated. Same binary, samecore,config,kubevirttoolsets, no--read-only, so the 25-row table is not repeated -- if the two ever differ, one of the three.mcp.jsonentries has drifted.- Three line-number citations in
talk-track.mdbecame section-name citations. They pointed intoSKILL.md, which this change edits, so they would have gone stale on merge -- a footgun the source table exists to prevent. - The absence of
aap-edgeis now documented rather than left to be noticed.edgeruns AAP, so a presenter who counts will ask. It is not built:make-aap-mcp.shtakes onlysandboxanddemoand defaults anything that is notdemoto write scope, so addingedgeis a posture decision, not a usage-line fix -- the same call #405 made about that script. The talk track now tells the presenter to say so plainly. docs/plan/platform-addons-plan.mddeliberately left alone. It records two OpenShift servers as of the date it was written. Plan docs are design records; updating one would rewrite history rather than correct a stale instruction.
Fixed -- roadmap environment and memory references (#410)
ROADMAP.mdnow documents all three live environments:sandboxanddemoare RHDP clusters, whileedgeis a persistent bare-metal SNO on a NUC.- Updated the documented memory budgets: 63 GiB for the RHDP environments
and 50 GiB for
edge.
Fixed -- four operator-facing messages still offered two environments, not three (#405)
edgehas been a real target since it was added, but every message that enumerates the choices predated it.utilities/make-kubeconfig.shsaidusage: ... <sandbox|demo>while.mcp.jsonalready pointsopenshift-edgeat.kube/edge.kubeconfig-- the script was not merely permitted to takeedge, it was already the documented way that MCP server gets its credential. Same staleness inutilities/check-kubeconfig.sh,playbooks/teardown.yml(-e target_env=<sandbox|demo>) andplaybooks/tasks/assert_target_environment.yml.- The assert message contradicted itself out loud: it reported
This run targets 3 environments (sandbox-local, demo-local, edge-local)and then offered--limit sandboxor--limit demo. Verified by runningprobe_env.ymlwith no--limit. - The two shell scripts now derive the list from
inventory/group_vars/rather than hardcoding a third value, reusing the idiom already six lines below in each -- the unknown-environment error path. A fourth environment cannot re-stale them, and the usage line and the error can no longer disagree. The two Ansiblefail_msgstrings name all three literally; deriving inside a failure message is not worth the indirection. check-kubeconfig.shgained the repo-root anchor its sibling already had. Every path in it is relative, so it only ever worked from the repo root; the derived usage line printed<>from anywhere else, which is worse than the stale string it replaced. Caught by running it from/tmp, not by reading it.- Two look-alike sites deliberately left alone.
utilities/make-aap-mcp.shcarries the same stale string, but there is noaap-edgeserver yet and the script defaults anything that is notdemoto write scope -- a posture decision that belongs to its own change, not to a usage line.utilities/make-env-logo.py'ssandbox or demohelp is accurate:utilities/env_colors.pyhas noedgekey, so--env edgeexits with "unknown env". Supporting it means choosing a third badge colour.
Added -- edge/SNO demo docs (in-repo mirror) (#404)
docs/demos/edge-sno/— five-file demo directory for the edge / Single Node OpenShift use case, following the same template as the other three demos.- Covers Phase 3 (this repo's responsibility: platform config, AAP CaC, the demo itself) and links to the published full guide for the three-phase flow including ISO build.
- Added row to
docs/demos/README.mduse-case table.
Fixed -- the portal launcher sent vm_count as a string and AAP refused it (#400)
Self-Service - Request Linux/Windows Servercould not launch anything. Measured on sandbox, job 565:400 {"variables_needed_to_start": ["Value 2 for 'vm_count' expected to be an integer."]}.vm_countis anintegersurvey question on both Day 1 workflows.- The filter is not what decides the type -- the template form is. A quoted
scalar template is rendered to text, so
vm_count: "{{ x | int }}"yields'2'however it is filtered. Only a value that is entirely one native expression keeps its Python type.extra_varsis now built as a single dict template. - #242 diagnosed this correctly and then applied the fix in the shape that
cannot work, which is why the comment now carries all three measured forms
rather than just the right one. Second time this trap has been hit here: the
first was a Kubernetes
IntOrStringport, where a string means a named port, so nothing errored and a readiness probe simply never passed.
Added -- self-service portal entry points (#242)
- Two launcher job templates,
Self-Service - Request Linux ServerandSelf-Service - Request Windows Server, each firing one of the existing Day 1 workflows via the newplaybooks/launch_workflow.yml.Self-Service -is a new family prefix under the #300 taxonomy, sorting as its own block. - The portal cannot surface workflows, and this is the answer to that.
Measured 2026-09-08:
portal.ymlwritescatalog.providers.rhaap.production.sync.jobTemplatesbecause it is the only key there is, and the deployed plugin bundle in the runningrhaap-portalpod (chart 2.1.0) contains 200 occurrences ofjobTemplatesand zero ofworkflowJobTemplates/WorkflowJobTemplate. There is no config key to turn on. dc1.azure reached the same conclusion the same way, andlaunch_workflow.ymlis ported from its solution. - The launchers fire the workflows an SE already uses. #242 as written
specified a second pair of provisioning workflows; every reason it gave had
expired -- #238 made
provision_vm.ymldispatch onhypervisor, so anything chaining Provision is already multi-hypervisor, and #300/#340 split the single workflow into the Linux and Windows pair these launch. One implementation, two entry points, so a fix to the chain reaches both. hypervisoradded to both Day 1 workflow surveys, and it is load-bearing. A trigger handsextra_varsto the WORKFLOW, which feeds them to its nodes; a survey-enabled workflow rejects any extra_var that is not one of its own questions with400 Variables ... are not allowed on launch(dc1.azure AB#91). Without the question the launcher could not pass a hypervisor at all.ask_variables_on_launchstays off -- #243 is what will need it, forticket_number/ticket_sys_id/ansible_eda.- New
self-servicelabel, a fifth axis (entry point) incontroller_labels.yml. On the launchers only, not the workflows: the workflows now serve both audiences, which is the point of reusing them. - No new credential.
Sales Demos - Env Secretsalready injectsaap_password, so unlike the dc1.azure original this needs no second copy of the admin password in a "Red Hat Ansible Automation Platform" credential. ansible.controller.workflow_launch, a deliberate exception to theansible.platformpreference.ansible.platform2.7.20260604 ships 22 modules and none of them launch anything; launching is controller-domain. Already pinned at 4.8.0 and present in the EE, so no rebuild.
Fixed -- the vm_count ceiling was 10, but only 1 or 2 is buildable (#397)
- Every
vm_countconstraint is now1..2, in all six places that carry one: theLinux/Windows Day 1 - 1 Provisionsurveys, theLinux/Windows Day 1 - 0 Workflowsurveys, the assert inplaybooks/provision_vm.yml, and the variable validation interraform/ocpvirt/variables.tf. They move together on purpose -- a survey offering 2 while Terraform validates 10 is the same split this closes. - 10 was a guard rail set above anything the platform can build. #389 chose
it so "a typo in a survey box cannot ask for 100 VMs and spend a minute being
refused" -- right in kind, wrong in degree. A
largeguest is 16 GiB against anavailable_memory_gbof 63, so three already exceed the budget. Eight of the ten values the dropdown offered had no outcome but the refusal the cap exists to pre-empt. - The capacity checks are untouched.
locals.tf's precondition andprovision_vm.yml's cluster-wide query still do the real enforcement; this only moves the guard rail down to where the demo lives. - Prose that stated the old number moved with it -- four copies of "Size of
the farm, 1 to 10", the
fail_msginprovision_vm.yml, the "CAP OF 10" comment invariables.tf, and the survey table in.claude/skills/ocpvirt-provision/SKILL.md. A constraint whose comment still says 10 is a constraint the next reader argues with.
Added -- Windows Day 2 operations (#241)
- Break/fix compliance demo. Four new AAP templates: Break Compliance
(deliberately violate CIS 2.3.6.6), Compliance Scan (re-run the verifier),
Fix Compliance (restore the control), and a
Windows Day 2 - 0 Break Fixworkflow that chains the four steps. The break setsRequireStrongKey=0; the fix restores it to 1. Both are single-task playbooks, not roles. - CIS 2.3.6.6 added to
windows_compliance.RequireStrongKeywas not previously verified. The check count goes from 27 to 28. This control is the one the break/fix demo targets, so it appears in the Day 1 report (passing) and in the Day 2 broken scan (failing). - Day 2 patching template.
Windows Day 2 - Patchre-uses the existingpatch_windows_vm.ymlplaybook with a report-only default (searchedinstead of Day 1'sone), aimed at drift detection rather than applying updates during a demo. - SMB check/disable.
Windows Day 2 - Check SMBreads SMBv1 status and optionally disables it. Survey-driven (check/disable), no new role. - .NET patch report.
Windows Day 2 - .NET Patch Reportreads .NET Framework versions and patches from the registry. Read-only. day-2label created incontroller_labels.yml, completing the phase axis anticipated since #300.
Fixed -- the cluster-wide memory budget check silently totalled zero from AAP (#391)
playbooks/tasks/sum_demo_vm_memory.ymlreturned 0 GiB / 0 VMs from every AAP job template, whatever was actually running. The cluster-wide budget assert inprovision_vm.ymlwas therefore evaluating0 + tier <= budgetand passing for that reason alone -- the same shape of failure as #334, which is the issue that created this shared file, with a different cause.- Neither
kubernetes.coretask carried anenvironment:block, and the file's two callers do not agree about where credentials come from:probe_env.ymlsuppliesK8S_AUTH_*at PLAY level, so it worked there;provision_vm.ymlhas no play-level environment --terraform_ocpvirt.ymlputs one on each task instead -- so it supplied nothing. Both reads failed withCould not create API client: Invalid kube-config file,failed_when: falseswallowed it, and the total came back0.0. - Measured, not inferred. Sandbox job 522, with three VMs holding 48 GiB
running at the time, printed
0.0 GiB already held by 0 demo VM(s). The same file with the environment supplied totals48.0correctly. - This is the third file to learn the rule --
terraform_ocpvirt.ymlcarries the same banner for #313 and #315. The environment now lives in the shared file rather than at the call sites, so a third caller cannot inherit the bug by not knowing about it. failed_when: falsestays, and a new assert makes it safe. It has to stay:probe_env.ymlruns against clusters with no OpenShift Virtualization, where a missing CRD is a legitimate zero. What it must not do is turn "I could not look" into that same zero.resourcesseparates the two exactly, with no message matching -- measured on sandbox 2026-09-09: a missing CRD returnsresources: [](defined), while missing credentials leaveresourcesabsent entirely. So a zero is now a measurement rather than the absence of one.- Both
success_msgandfail_msgguard their lookups with| default([]). Ansible templates both before choosing one, so a bare_demo_vms.resourcesin the success message raised a templating error in precisely the case the assert exists to explain -- the guard fired but printed nothing useful.
Not caused by #389. Verified against 76955ff~1: neither the task file nor
the caller carried the environment before that change either. #389 only made it
visible, by being the first run to happen while unrelated demo VMs were already
present.
Documentation -- correct the LVMS prerequisite comment (#387)
install_lvms.ymlsaid LVMS uses unallocated disk space; it discovers unused block devices. Auto-discovery ignores devices with children, so/dev/sdais ineligible however the root partition is sized -- free space behind the last partition is invisible to it. The installer kit reserves partition 5 (labellvms) unformatted; that is an explicit partition, not leftover space.
Added -- server farms: vm_count and role-based naming (#389)
- One workflow launch can now build up to 10 VMs.
vm_count(1-10, default 1) andvm_role(web/db/app, defaultweb) are new survey questions on both provision templates and both Day 1 workflows.vm_count=1is indistinguishable from the previous behaviour. #248 (F5 rolling patching) needs the pool this creates. - VM names are now
{role}-{os}-{index}--web-win-1,db-lnx-2-- and that is the whole naming chain.terraform/ocpvirt/locals.tfbuildslocal.vm_namesand every Service, Route, in-cluster FQDN, URL and AAP host name derives from it, so the formula lives in exactly one place. - The name says what the machine is FOR, not how big it is. The old
sd-win-largewas infrastructure sizing, which tells you nothing about the workload and collides the moment you want two. The tier did not disappear -- it moved to thevm_size_tierAAP host variable and thesd1.*instancetype label, where sizing belongs. A newsales-demos/rolelabel makes a farm selectable without parsing names. tier_windows_hostnameis deleted. It was a hand-maintained map of tier to NetBIOS-safe hostname, and it had to exist becausesd-win-medium-1cpu-4gbdoes not fit in 15 characters. The computed name fits by construction:vm_roleis capped at 8 characters, so the longest name is{8}-win-{2 digits}= 15 exactly.- AAP host names keep the #354 hex and gain the VM name --
{role}-{os}-{index}-{hex}-{namespace}.{apps_domain}. There is now onerandom_idper VM rather than one per state: a farm sharing a hex would put every member on the same Host Metrics row, which is the exact defect #354 fixed. - The Terraform inventory outputs are LISTS, one entry per VM, and the
per-VM
web_url,cockpit_urlandssh_commandmoved inside them. A farm has one Route each, so the URL has to travel with the host it belongs to -- else every member gets host variables describing the first one, and the demo page, the compliance report link andcheck_*.ymlall agree on the wrong machine.
Changed -- Terraform state is keyed per role as well as per OS (#389)
secret_suffixis now<env>-<os>-<role>.var.vm_rolefeeds every resource name, so adbapply against awebstate would find everything renamed and plan the RUNNING WEB FARM for destruction -- the same failure per-OS state fixed in #301, one level down.- A teardown must be given the role it was built with, or it inits an empty
state, destroys nothing, and still reports success. Both teardown templates
pin
vm_role: webto match the provision default, andask_variables_on_launchis already on for anything else. - This is not backward compatible with pre-#389 state, deliberately. An
environment holding
<env>-<os>state has VMs the new key cannot see. Tear down with the old code before deploying this. Verified 2026-09-09: bothsandboxanddemohold zero demo VMs, so their remaining<env>-<os>state secrets are empty and harmless.
Changed -- Terraform outputs renamed to their plural, list forms (#389)
web_url->web_urls,cockpit_url->cockpit_urls,ssh_command->ssh_commands. Renamed rather than kept as a scalar pointing at[0], because a scalar that silently means "the first VM" is a trap: every consumer would keep compiling and keep being wrong about a farm.terraform output -raw web_urlbecomesterraform output -json web_urls; the docs and skills that quoted it now iterate the list.demo_vm_excludeintasks/sum_demo_vm_memory.ymltakes a LIST. A farm converges several VMs at once, and excluding only the first would count the rest twice and refuse to re-run against a farm that is already standing. A bare string is still accepted.- The memory budget multiplies the tier by the count, in both the Terraform
precondition and the cluster-wide check in
provision_vm.yml. - A new precondition enforces the NetBIOS budget.
vm_role's own validation cannot seename_suffix, which spends from the same 15 characters, so the combination is checked at plan time. Windows truncates an over-long ComputerName silently; failing costs a message, truncating costs an afternoon. - The name formula exists twice, and the copies are tested rather than
trusted.
provision_vm.ymlhas to predict the names before Terraform runs, because the memory check runs first. A new assert compares the prediction against what Terraform actually built, on every run including runs that create nothing -- the same move as check 2 inutilities/check-no-secrets.sh. web_urlis now declared a host variable inutilities/check-secrets-example.py. It was only ever invisible to that checker becausetasks/terraform_ocpvirt.ymlalsoset_fact'd a singleweb_url; with the fact gone, the AAP host variable written byregister_hosts.ymlis the only definition, and the scanner does not read host-variable blocks. It is not a credential.
Changed -- demo VMs get instance-unique AAP host names (#354)
- Each provisioned VM now gets a unique AAP host name in Route-style format:
<hex>-<namespace>.<apps_domain>(e.g.a3f8b2-sales-demos-sandbox.apps.cluster-kbjvc.dyn.redhatworkshops.io). Previously, the host name was the tier-based in-cluster FQDN, so Host Metrics merged all VMs of the same tier into one row. - K8s object names stay tier-based (
sd-lnx-small,sd-win-large) for Terraform convergence. The unique ID comes from arandom_idresource that persists in state untilterraform destroy, then regenerates on the next build. - Teardown cleans up Host Metrics entries via the controller API (best-effort, non-fatal). Deregistration covers both the new unique name and the old FQDN for migration safety.
- Added
hashicorp/randomprovider (~> 3.6) to the OCP Virt Terraform module.
Fixed -- edge pointed at the demo cluster's domain (#385)
inventory/group_vars/edge/connection.ymlsaiddemo.internal.ames.netin three places --aap_hostname,openshift_api_url,openshift_apps_domain. The NUC cluster isedge.internal.ames.net; the values were a copy from thedemoenvironment. Verified against the live cluster: the ingress domain isapps.edge.internal.ames.netand the API isapi.edge.internal.ames.net:6443.- This broke the
openshift-edgeMCP server outright --lookup api.demo.internal.ames.net ... no such host-- so edge questions fell back to shelling out with an explicitKUBECONFIG, the habit the "ask the cluster over MCP" rule exists to prevent. demois a real, live environment, so this failed closed only because the home dnsmasq has nodemo.internal.ames.netzone. A stale hostname that resolves to the wrong cluster would have been far worse than one that does not resolve at all.
Removed -- the falsely-labelled Windows image is gone from Quay (#358)
quay.io/zigfreed/win2k22-cis-l1-golden:20260907-0516has been deleted. It carriedcom.redhat.cis.level=L1on media measuring 0 of 10, in a private repository other SEs consume. Verified after deletion:skopeocan no longer resolve it, the repository holds only20260908-1853, and that tag still resolves withcis: L1on media measured at 10 of 10.- Nothing referenced it operationally -- both environments and the cluster
DataSource were already on
20260908-1853. Every surviving mention in the docs recounts it as history, which is where the record belongs.
Fixed -- the compliance report under-reported a hardened guest (#382)
- Rule 18.9.20.1.1 was checked at
HKLM:\SOFTWARE\Policies\Microsoft\Windows. The CIS role writes it to...\Windows Nt\Printers; the path omitted bothNtand\Printers, so the value could not be found on any machine. - The guest is 27 of 27, not 26 of 27. Read from
sd-win-large's own exported hive: the old path is<VALUE ABSENT>and the correct path is1. The single "not configured" line in the published report was the report being wrong, not the guest. The demo now shows 100%. - This was the THIRD copy of one bug -- the same wrong path shipped in
utilities/inspect-golden-image.py(#370) andimage.builder.pipeline'sverify_cis_disk.py(ibp#93), and survived both fixes. When a control definition is wrong, grep the value name across every reader before closing. - It survived for the same reason each time: a control that reads "absent" on unhardened media proves nothing about whether its path is right, and every report before #358 closed was taken from an unhardened guest.
Documentation -- correct a stale CIS L1 claim in the plan doc (#358)
docs/plan/ocpvirt-demo-plan.mddescribed20260907-0516as "CIS L1 hardened" in a current-state observation table. That tag measures 0 of 10 and is superseded by20260908-1853. It was the last place in the repo still asserting the false claim as fact rather than recounting it as history, which made it the most dangerous line left.- The #358 section now reads as closed: the heading, cause 2, and a new cause 3 for the WinRM defect (#377), plus what the current image actually measures -- 10 of 10 on the media, 10 of 10 on the booted guest's disk, 26 of 27 (96%) on the running clone.
- Kept the live capability note rather than deleting it with the stale
warnings:
utilities/inspect-golden-image.pyis still the per-new-tag check before linking, and the producer's own gate is a second independent measurement, not a replacement for it.
Changed -- the Windows demo can show its compliance report again (#358)
- A clone of
win2k22-cis-l1-golden:20260908-1853scores 26 of 27 controls compliant (96%) -- 0 non-compliant, 1 not configured. Measured 2026-09-08 on a guest verified to have been rebuilt (VM and DataVolume both created 21:10:11Z) from the DataSource serving that image. The five-nodeWindows Day 1 - 0 Workflowcompleted green in 19.7 minutes. sysprep /generalizestrips nothing. Read directly off the booted, sysprepped guest's own disk: 10 of 10 controls that cannot be set on a clean install. This was the leading suspicion for two days of #358 and it is now measured and wrong. It also unblocksimage.builder.pipeline#87and#88, which were gated on exactly this question.- The four demo docs stop coaching around the problem (reversing #365/#366):
the run sheet no longer says to skip the compliance node, the talk track gets
its third item back,
architecture.mdstates the hardening as verified rather than unconfirmed, and "CIS Level 1 hardened" returns toobjections.md. - Every 33% reading in this issue came from guests cloned from unhardened
media. Three defects of one shape had to be fixed first, each a status
trusted instead of the artifact measured: #364 (DataSource Ready vs which
image),
image.builder.pipeline#92(creates:-- the file exists vs is current), and #377 (a guest that could never configure its own WinRM).
Fixed -- a CIS-hardened guest could never configure its own WinRM (#377)
FirstLogonCommandsneeds a logon, and a CIS L1 image is built to prevent one. Measured on the guest's own disk:legalnoticecaptionis set to the DoD consent banner and the policy keyPolicies\System\disablecadis0, so CTRL+ALT+DEL is required. Either alone blocksAutoAdminLogon, so the clone boots to a banner and waits for a click that never comes.- The consequence was a guest unmanageable for life. Neither first-logon
command ran:
LocalAccountTokenFilterPolicywas absent, and the machine store held exactly one certificate -- still the build-time thumbprint the HTTPS listener points at. Port 5986 answered and reset without presenting a certificate, and every Day 1 node past Provision failed. - The WinRM setup now runs from the
specializepass, which executes as SYSTEM with no logon. It stagesSetupComplete.cmd, Windows' documented hook that runs at the end of Setup -- still as SYSTEM, still before any logon prompt, with the ComputerName already final. The script drops the stale HTTPS listener, mints a certificate for the new machine name, rebinds, opens 5986 through the CIS-enabled firewall, and setsLocalAccountTokenFilterPolicy. FirstLogonCommandsis kept and annotated, not deleted. It still works on unhardened media, so removing it would drop a working path before the replacement is proven.- A precedence trap worth remembering:
Winlogon\DisableCADis1, set by the build, while the policy keyPolicies\System\disablecadis0. Policy wins. Reading only the first says CTRL+ALT+DEL is not required. - An earlier theory blamed CIS 18.5.1 (
AutoAdminLogon = 0). That is wrong -- the unattend'soobeSystempass overrides it, and the guest really does haveAutoAdminLogon = 1,DefaultUserName = demoadmin. Recorded so it is not re-derived.
Added
playbooks/install_compliance.yml(#376). Installs the Compliance Operator on any environment. Follows theinstall_cnv.ymlpattern: own playbook, idempotent, waits for the ProfileBundle to be VALID.playbooks/extract_cis_remediations.yml(#376). Applies aScanSettingBindingforocp4-cis-node, waits for the scan, and writes eachComplianceRemediationthat produces a MachineConfig as a static YAML file. Output feedsimage.builder.pipelinePhase 5.3 (Day 0 CIS L1 manifests for the SNO installer kit).playbooks/install_lvms.yml(#376). Installs the LVMS operator and creates anLVMClusterwith thin provisioning for clusters that have unallocated disk space (e.g. a SNO built with limited root partitioning).edgeenvironment for on-prem SNO on a NUC (#373). Third inventory environment alongsidesandboxanddemo. Bare-metal Single Node OpenShift with AAP 2.7, OpenShift Virtualization, and Compliance Operator, produced byimage.builder.pipelinePhase 5. Target with--limit edge.openshift-edgeMCP server (#373). Read-writekubernetes-mcp-serverfor the edge cluster, matching the pattern ofopenshift-sandboxandopenshift-demo. Kubeconfig at.kube/edge.kubeconfig(gitignored).edgestubs insecrets.yml.example(#376). The example file now includes theedgeenvironment underenv_secrets, matching the pattern ofsandboxanddemo.
Changed -- both environments repointed to a MEASURED CIS L1 image (#358)
quay_windows_image->win2k22-cis-l1-golden:20260908-1853in bothsandboxanddemo. This is the first Windows golden image whose CIS L1 label was earned rather than asserted: the producer's publish read the hardening off the very qcow2 it packaged and returned 10 of 10 non-default controls (image.builder.pipeline#92, gate corrected in its #93/#94).- The Sep 7 build never failed.
win2k22-build's disk on the sandbox cluster had been a hardened, sysprepped golden image since 2026-09-07 -- one sysprep run,05:01:04-05:02:13, matching that VM's own shutdown.ibp#91was a publish that packaged a stale Sep 5 qcow2, not a build that failed. The hardened media simply never got out. - The retired tag
20260907-0516carried no hardening at all (0 of 10) and has since been deleted from Quay (2026-09-08). This entry first said it would stay "as a permanent record of the defect"; that reasoning confused two different things. Tags here are immutable, which forbids overwriting one -- it never required keeping one whosecom.redhat.cis.level=L1label was a false claim on media other SEs can pull. The record lives in #358, ibp#91 and this changelog, where it costs nobody a mislabelled image.
Fixed -- a control the reader could never find (#370)
utilities/inspect-golden-image.pycheckedDisableWebPnPDownloadat\Policies\Microsoft\Windows, but the CIS role writes it toHKLM:\SOFTWARE\Policies\Microsoft\Windows Nt\Printers(rule 18.9.20.1.1,level1-memberserver, enabled). The value could not be found on any machine.- It was invisible because every run so far read unhardened media, where the
honest answer and the bug are both
VALUE ABSENT. Caught inimage.builder.pipeline#93the first time genuinely hardened media was measured, which returned 9 of 10 instead of 10. - #358's conclusions are unaffected --
win2k22-cis-l1-golden:20260907-0516carries no hardening by any measure. The bug could only ever under-report a hardened image. - The other nine controls are now validated in both directions:
OKon hardened media,ABSENT/WRONGon the unhardened Sep 5 disk. Only this one had never been seen passing.
Documentation -- the producer-side root cause of #358, once it was known (#358)
docs/plan/ocpvirt-demo-plan.mdcause 2 now records the mechanism, which turned out not to be the one first proposed. The guess was that the producer's publish exported a stale PVC; the cluster disproves it --win2k22-build-rootwas created by the Sep 7 build VM from a blank source and carries that VM's ownkubevirt.io/created-byUID, so the export selected the right volume.- The stale artifact was on the operator's laptop. The producer's qcow2
conversion was guarded by
creates: disk.qcow2while its cleanup deleted only the two larger intermediates, so a qcow2 survived between runs: the Sep 7 publish downloaded the fresh disk, expanded it, skipped the conversion, deleted the fresh copy, and packaged the Sep 5 one. Its size, 9307619328 bytes, is in the Sep 7 run's own publish record and matches the disk pushed on Sep 5 as the deliberately unhardenedwin2k22-golden:20260905-2217. creates:asks whether an output EXISTS, never whether it is CURRENT -- the same shape as cause 1 here, where Ready stood in for which image.- Fixed upstream in
image.builder.pipeline#92. This changes nothing this repo does: the consumer verifies the media it is handed regardless of the producer's gate, which is the whole point of two independent measurements. #358 stays open until a genuinely hardened image is published.
Added -- read the CIS hardening OFF the published image, offline (#358)
utilities/inspect-golden-image.pypulls a Windows golden containerdisk, extracts the qcow2 from its singleFROM scratchlayer, carves the Windows volume, and readsSOFTWAREandSYSTEMstraight out of/Windows/System32/config-- then reports whether the CIS controls are actually there. Exit1means the image does not carry what its label claims.com.redhat.cis.level=L1is the producer's INTENT, not a measurement.publish_windows_containerdisk.ymldefaults tocis_level=L1and thewin2k22-cis-l1-goldenrepo name, so the label records what the operator meant and nothing reads the media back. This does read it back.- It answers a question no cluster can. Scanning a running guest cannot distinguish "the image was never hardened" from "something stripped it after boot" -- #358 spent two rebuilds on exactly that ambiguity.
- The controls it checks are deliberately only ones impossible on a clean install. #358's original evidence was ambiguous precisely because nine "compliant" controls were stock Windows values, so a check that can pass on a default install is worthless here.
- It also prints provenance -- how many
sysprepruns the disk records, and when. That is what caughtimage.builder.pipeline#91:win2k22-cis-l1-golden:20260907-0516holds a disk sysprepped exactly once, on 2026-09-05, published two days later. - No root, no libguestfs:
qemu-imgandntfsprogsare already present on a Fedora workstation;regipycomes from pip. A check that needssudois a check nobody runs. - Documented in the
ocpvirt-windows-imageskill as a per-new-tag step before linking (tags are immutable, so one verification holds for ever), and the whole two-cause story is written up indocs/plan/ocpvirt-demo-plan.md.
Fixed -- demo docs coached a CIS L1 claim the report contradicts (#365)
run-sheet.mdwalked a presenter into opening the Windows compliance report and narrating "sixteen exceptions, and every one has a name against it." That narration only works if the page shows a hardened guest. It currently shows 9 of 27 controls compliant (33%), 7 non-compliant and 11 not configured, so a presenter following the script opens a page that says the opposite of what they just said.- Blocking warning added at the Windows compliance node in
run-sheet.md: skip the node in the narration, do not openreport.html. The workflow still runs it and still goes green. talk-track.mdoffered the compliance percentage as a substitute third admission, described as "a demonstration that the hardening took and is still in place". That sentence is currently false, so the substitution is withdrawn and the beat stays at two items.architecture.mdstated the published image is "CIS L1 hardened and generalized" as settled fact; it now says built to be hardened, with the gap named.objections.mddrops "CIS Level 1 hardened" from the spoken answer and describes the build rather than the guest.- This is not a retraction of the compliance node's design. The node verifies rather than asserts, and the first time it ran against a real guest it caught the platform's own claim being wrong -- that framing is correct and is kept. Every warning cites #358 so it is obvious when it is stale.
Fixed -- repointing the Windows image tag was a silent no-op (#358)
- The demo guest was booting
win2k22-golden:20260906-0300, the repo the producer publishes its UNHARDENED build to, whileconnection.ymlhad saidwin2k22-cis-l1-golden:20260907-0516since #294.Windows Day 1 - 4 Compliance Scanscored it 9 of 27 CIS controls, and all 9 were Windows defaults -- because none of the hardening was ever on the media. - The dates settle it without a measurement. The backing PVC finished
importing at 2026-09-06 03:20;
skopeo inspectputs the L1 image's creation at 2026-09-07 05:26. A PVC cannot hold an image that did not exist when it was populated. - Root cause:
link_windows_image.ymldecided whether to import from whether the DataSource was Ready, never from which image it served. Once the first import succeeds a DataSource is Ready for ever, so a changedquay_windows_imagepatched the HCO cron template -- which imports nothing on a private registry (#224) -- then skipped the DataVolume, skipped the DataSource repoint, passed a verification whose only questions were "Ready?" and "Bound?", and printedDataSource win2k22 is Ready [...] terraform -var os_type=windows can now boot. - The import decision is now identity, not readiness. The existing DataVolume already records the URL it imported, so the cluster is asked what it holds rather than told what it should hold; a mismatch deletes and re-imports, because a DataVolume's source is immutable and cannot be edited in place.
- And the identity is asserted on every run, including runs that import
nothing -- the run that decides there is nothing to do is exactly the run
that had to be able to fail. Ready and Bound were both true of the wrong
image for two days. Same reasoning as check 2 in
utilities/check-no-secrets.sh. CLAUDE.md's repoint procedure and theocpvirt-windows-imageskill's verification section both said to checkReady=True. Both now say why that is not the check, and give theoc get datavolume -o jsonpaththat is.- Withdraws the
sysprep /generalizehypothesis recorded on #358. It never explained the absentHKLM\SOFTWARE\Policies\...values -- the producer writes those directly withwin22cis_ansible_remediation: true/win22cis_create_gpos: false-- and reading the wrong image explains the whole pattern with nothing left over. Whether generalize survives hardening is now an open question to measure once the correct image is imported.
Changed -- windows_configure published 15 KB in 3m 43s of WinRM overhead (#361)
- On Windows the round trip IS the cost, and the role was shaped as if it were not. Measured on sandbox, job 436, per task:
| Task | Time | What it does |
|---|---|---|
| Publish the demo page | 57s | writes one ~5 KB HTML file |
| Publish the product logos | 48s | copies two SVGs |
| Publish facts.json | 58s | writes one ~1 KB JSON file |
A connection, a PowerShell process, a module payload and a result, three times
over, to move about 6 KB.
- This is the opposite of the Linux roles' economics, which is why
linux_configure is not written this way and why copying its shape task for
task was the wrong instinct. Over SSH with pipelining these are milliseconds.
- The three now render into a staging directory on the controller and ship
in one win_copy. Expected saving ~2m 30s of a node someone is watching.
- The templates are unchanged -- only where they are rendered moved, not how.
Verified by staging the real templates offline: five files, 19.9 KB,
facts.json schema still identical to the Linux one key for key, and the
KVM (guest) virtualization normalisation still firing.
- Line endings changed from CRLF to LF, deliberately. win_template
defaults to newline_sequence: "\r\n"; ansible.builtin.template defaults
to "\n". Kept LF because a browser and jq do not care, modern Notepad has
handled LF since 2018, and facts.json is meant to be comparable with the
Linux guest's copy -- which is LF. Recorded rather than discovered later.
Added -- the Windows demo performance budget is written down (#360)
- A sub-10-minute Windows demo was chased and deliberately abandoned, and the
arithmetic is now in
docs/plan/ocpvirt-demo-plan.mdso it is not re-chased:
provision 50s + sysprep 6m30s + update scan 2m30s + compliance 2m41s + check 41s
= 12m 42s before configure does anything at all
~15m 40s cold is the accepted target.
- Two findings that change what is worth optimising, both measured rather
than reasoned about:
- On Windows the round trip IS the cost. Writing a 5 KB HTML file takes 57
seconds. Task count matters more than what the tasks do — the opposite of
the Linux roles, and the reason copying linux_configure task-for-task was
wrong. Rule of thumb now recorded: budget ~45 seconds per task in any
roles/windows_*.
- Sysprep first boot is a hard floor of ~6m 30s, versus 26.7s against an
already-booted guest. Nothing in this repo shortens it; it is why Linux
manages 9m 9s and Windows cannot.
- Baking patches into the golden image saves ~0 demo minutes, which is
counterintuitive enough to be worth the paragraph it now gets. The ~2m 30s is
the Windows Update scan, and the scan costs the same whether it finds forty
updates or none — established from the VM CPU and Network I/O panels of this
repo's own Grafana dashboard. Do it for correctness, not speed.
- The run sheet now tells a presenter the real number, that the sysprep wait is
good material rather than dead air, and that Windows Day 1 - Repair against
an existing guest is the ~7-minute option when only ten minutes exist.
- talk-track.md's "Where the words come from" table gains four rows, one per
new timing claim.
Fixed -- the Patch survey offered a mode the role does not have, and not the one it does (#340)
config.ymlfailed against sandbox. AAP rejected the survey outright:
error: Failed to update survey: Default choice must be answered from the choices listed.
- #357 set the default to
onewithout addingoneto the choices list, which still readsearched / downloaded / installedfrom the first draft. AAP validated it correctly and refused the whole survey. downloadedis removed at the same time, because the role has no branch for it. Choosing it did exactly whatsearcheddoes. That is a decorative survey option -- precisely the defect this role was ported to fix, since the original wrote to a variable namedpatchesthat the role never read. Three modes now, each doing something distinct.- The error was hidden by
no_log. The failing task reported only "the output has been hidden due to the fact that 'no_log: true' was specified"; re-running withaap_configuration_secure_logging=falseproduced the real message in one line. Worth knowing before debugging a silent config.yml failure again.
Changed -- demo VMs default to the large tier, on every provisioning entry point
vm_size_tiernow defaults tolargeonLinux Day 1 - 1 Provision,Windows Day 1 - 1 Provision,Linux Day 1 - 0 WorkflowandWindows Day 1 - 0 Workflow-- all four, so no path still lands onsmall.- These are demo machines and the thing being demonstrated is speed, so the default should not be the tier that makes every step slower. Picking a smaller tier stays one click away for when the smaller tier is what you are showing.
- Verified it fits before changing it, rather than assuming:
largeis 4 vCPU / 16 GiB intiers.yaml, and both environments setavailable_memory_gb: 63, so a large Linux and a large Windows co-exist with room to spare.
Fixed -- the Patch survey default disagreed with the role, and the survey wins (#340)
- Shipped in #356 with
windows_patching_statedefaulting toonein the role andsearchedin the job template survey. A survey default always outranks a role default, so the behaviour was report-only while the role, the CHANGELOG and the docs all said it installs one update. - Caught by reading the job output rather than the code. Job 435 printed
Mode: searchedagainstwindows_patching_state: oneindefaults/main.yml. Nothing else would have found it: both values are valid, both lint clean, and the node passes either way -- it just quietly does less than it claims. - This is the failure mode this repo already warns about, in the very role
it was introduced to: the ported
windows_patchinghad a survey writing to a variable the role never read. Same class of defect, one release later. Survey variable names AND their defaults are the contract. - The template description also still promised "Security and Critical only by default, so a live demo is not a 30-minute node", which was written before the 45-minute timeout failure was measured. Corrected to say what it now does.
Added -- Windows Day 1 is a complete family: workflow, templates, skill, docs (#340, part 3 of 3)
- Closes #340. Windows now has the same eight-object day 1 family Linux has:
Windows Day 1 - 0 Workflow, five numbered steps, an off-chainRepair, and the1 Provision/Teardownpair that already existed. The roles and playbooks landed in part 2; this is what makes them reachable from AAP.
Windows Day 1 - 0 Workflow
Windows Day 1 - 1 Provision existed
Windows Day 1 - 2 Patch new
Windows Day 1 - 3 Configure new
Windows Day 1 - 4 Compliance Scan new
Windows Day 1 - 5 Check new
Windows Day 1 - Repair new
Windows Day 1 - Teardown existed
- The
0in the workflow name was measured, not assumed, which the issue asked for explicitly./api/controller/v2/unified_job_templates/?order_by=nameon the live sandbox returnsLinux Day 1 - 0 Workflowahead ofLinux Day 1 - 1 Provision-- Postgres ignores punctuation at the primary collation level, so a bareWindows Day 1 - Workflowwould have sorted last in its own family (#303).Cluster Day 0is bare only because its name already prefixes every template in that family. - The
ocpvirtlabel goes on Provision and Teardown only. Those two run terraform; the five guest-facing steps reach the VM over WinRM and know nothing about the hypervisor. Same line the Linux family draws. - The
Windows Day 1 - 2 Patchsurvey exposeswindows_patching_categoriesandwindows_patching_reboot-- the exact variable names the role reads. The role this was ported from kept both invars/, where a survey cannot reach them, and wrote to a third name the role ignored; the names are the contract, and part 2's move todefaults/is what makes this survey real. Windows Day 1 - 5 Checkcarriesuse_fact_cache: true, matching its Linux sibling, so gathered facts appear on the host in AAP.
The skill dispatches on OS rather than growing a sibling
.claude/skills/ocpvirt-demo/SKILL.mdnow opens by asking which OS, with a table of the seven things that differ (group, template, credential, transport, step 1, web server, compliance method) and an MCP call to ask the cluster when it is not obvious. A near-identicalocpvirt-windows-demosibling would have drifted from this one within a release.- Its "Windows" section used to say there is no Windows configure path and
that
windemowas referenced by zero playbooks and zero job templates. Both were true when written and are now false.
Docs corrected, including things that were already stale
- The survey is one question, not two.
architecture.md,run-sheet.mdandtalk-track.mdall still showed anos_typedropdown withlinux · windows · both, removed in #300/#301, and two of them still showed the legacysmall-1cpu-2gbtier names. The replacement text explains why it went: with one Terraform state per environment, pickingwindowsplanned the running Linux VM for destruction. That is a better beat than the dropdown ever was. - Three places told a presenter to admit Windows does not work.
run-sheet.md's honest-bits list,talk-track.md's beat 7, andobjections.md's "Does this do Windows?" all described a guest that stops at the OOBE screen. Fixed and verified since #234/#257. An admitted limitation that turns out to be stale costs the credibility the admission was meant to buy, so these are rewritten rather than softened -- and beat 7 gets a replacement third item that is true: the compliance percentage is over the controls checked, not the benchmark. talk-track.md's "Where the words come from" table gains nine rows, one per new claim, including who owns each of the sixteen compliance exceptions. Every path in that table was checked to exist before commit.architecture.md's object table gains the five templates, the workflow, the Windows Machine and Env Secrets credentials, and the Windows nightly teardown schedules -- names verified againstcontroller_schedules.yml, not assumed.ROADMAP.mdphase 4 said "Not started" and #5 has been closed since the Linux chain shipped. Corrected in passing, and a4Wrow added for the Windows half.
Fixed while verifying: the patching default could never finish
- A full Windows Update install does not fit in a workflow. Job 427 on
sandbox, building a
largeguest from the golden image, ran 76 async polls over ~39 minutes and then died on the role's own 45-minute ceiling:
ASYNC FAILED ... "msg": "timed out waiting for module completion"
PLAY RECAP: ok=2 failed=1
- So part 2's default -- install every Security and Critical update -- was not merely a slow demo node. It was a workflow that could not complete on a freshly built guest, and it failed at node 2 with the Route still 503.
- Phases separated using this repo's own Grafana dashboard (the VM CPU and
VM Network I/O panels deployed by
/sales-demos-dashboard): search ~2 min, download ~4 min with network at ~4.5 MB/s, then 30+ minutes of pure install with the network flat at zero and ~3 of 4 cores pegged. windows_patching_statereplaces the all-or-nothing behavior, defaulting toone: search (fast), then install a bounded number of the updates found. A real change in the demo rather than a report, without the intermission.searchedandinstalledare the other two modes, both on the survey.- The pick is deterministic and prefers small updates, pushing cumulative
and servicing-stack updates to the back of the queue rather than removing
them -- if they are all that is pending, we still patch. This is honest about
its own limits: the Windows Update API returns no download size, so
onebounds the COUNT and cannot bound the DURATION. - The timeout went from 45 minutes to two hours, because 45 was not a ceiling, it was a tripwire -- the documented slow path could not fit under it.
- Selection logic verified offline against synthetic update sets: normal mix
(picks the Defender update over the cumulative), all-cumulative (falls back
rather than refusing),
install_count: 2, and no updates found (empty list, install skipped by itswhen). - Nothing short of a live run could have found this.
--syntax-check,ansible-lintand the laptop EE run in part 2 all passed on the broken default.
Fixed while verifying: the Route probe raced the router
check_windows_vm.ymlusedtimeout: 20, and the router's own backend timeout is about the same. Measured on sandbox against a guest whose port 80 was still blocked: the Windows Route returns a real 503, but only after ~20 seconds, because the CIS-hardened Public profile DROPS the router's SYN and the router waits out its backend timeout before answering. The Linux Route returns 503 in ~3 seconds for the same "nothing is serving" condition, because firewalld REJECTS and the router hears immediately.- Same verdict, very different latency -- and at
timeout: 20the outcome was luck. Sometimes the 503 arrived and the node failed correctly; sometimes the probe gave up first and landed in the "could not reach it, so this says nothing about the guest" branch, which does not fail. Flaky towards a false green on the single most likely failure the check exists to catch. - Raised to 60s (overridable via
check_windows_route_timeout), comfortably past the router's backend timeout, so the 503 always wins the race. - Only a live run could have found this. Both
--syntax-checkandansible-lintpass on either value, and the laptop-side EE run in part 2 never traversed a Route.
Verified
- All eight CI gates green.
config.ymlapplied to sandbox and all eight objects landed correctly: the workflow plus seven templates,ocpvirton Provision/Teardown/Workflow only, and the sort order confirmed against the live API rather than assumed.Windows Day 1 - 0 Workflowlaunched from AAP and its wiring is proven --provision -> patch -> configure -> compliance -> check, success nodes only. Node 1 built alargeguest from the golden image in 36 seconds (CSI fast-clone); node 2'swait_for_connectioncleared WinRM after sysprep in 6m 30s on a cold build and 26.7s against an already-booted guest.- Two design assumptions confirmed on a real guest, not inferred:
ansible_virtualization_typeand..._roleboth come back as the literal"NA", sowindows_configure's normalisation is load-bearing and the page would otherwise render "NA (NA)" -- the #160 bug the Linux role already hit. - Node 2 then failed on the patching ceiling, which is the defect written up
above. The chain stopped there rather than configuring a half-patched guest,
which is the
success_nodes-only design behaving correctly. - The 503 -> 200 payoff is NOT yet confirmed end to end. It cannot be until the corrected patching role is in the project the job templates run from -- AAP reads the SCM checkout, not a branch. The run is the first thing after this merges, and the result goes on #340.
- Also observed: a Linux VM present throughout a Windows provision was untouched -- separate state, as #301 intended.
Added -- Windows Day 1 roles and playbooks (#340, part 2 of 3)
- Everything between provisioning a Windows VM and it being useful. Three
roles --
windows_patching,windows_configure,windows_compliance-- and five playbooks:patch_windows_vm.yml,configure_windows_vm.yml,windows_compliance_scan.yml,check_windows_vm.yml, andrepair_windows_vm.ymlas animport_playbookwrapper mirroringrepair_linux_vm.yml. The job templates and workflow that drive them are part 3; this is the content they run. - Slot 2 is Patch, where Linux has Register. Windows has no CDN registration -- the golden image ships complete -- so the honest analogue of "entitle the guest to content" is "bring it current". It also puts any reboot before IIS is installed, which is the safe order.
- The chain turns
web_urlfrom 503 into a page, which part 1 made possible by giving the Windows VM a Route.
Three defects fixed on the port, not carried across
windows_patchingcame fromaap.dailydemo.windowswith its settings invars/, which outranks a job template survey -- so its patching survey could not change anything it appeared to control, and it wrote to a variable namedpatchesthe role never read. Both settings are indefaults/here.- Its category list read
CrticalUpdates, a typo.win_updatesmatches category names against what the Windows Update agent reports and nothing reports that, so the source role silently applied no critical updates at all: a green job that patched nothing. - The account half of
windows_account_createhadno_loginverted --trueon a harmless directory loop,falseon the password-bearing one. It also setPasswordComplexity: 0machine-wide without restoring it, which would fail CIS rule 1.1.5 on the very next node of the same workflow. The demo account here is off by default, is not put inAdministrators, and requires a 14-character password rather than lowering the bar to accept a short one.
The compliance role verifies; it does not scan, and says so
- OpenSCAP has no Windows agent, so there is no equivalent of
linux_compliance'soscap xccdf eval. What exists is a contract with the producer, cited by path:image.builder.pipeline/playbooks/vars/cis_profile.yml. The role reads 27 controls that file enables back off the running guest. The job template is named "Compliance Scan" to sit with its Linux sibling; the report says verification in its own title so the artifact cannot mislead. - The exception list is 16, and #340 said 4.
cis_profile.ymlsets fourwin22cis_rule_*: false-- but it also setswin_skip_for_test: true, which the vendored role's owndefaults/main.ymldocuments as skipping eleven further controls "even if they are set to true". Those eleven staytruein the profile, so reading only the explicit falses says they were applied. They were not. A check for18.10.89.2.1would have shown a red failure for something the producer deliberately skipped, in front of a customer, on a page the demo invites them to open. - The sixteenth is ours.
terraform/ocpvirt/main.tfsetsLocalAccountTokenFilterPolicyback to1in the sysprep unattend so NTLM works fordemoadmin, which is CIS 18.4.1 undone -- by this repo, at provisioning time. It is now the most interesting row in the report: the demo platform's own accepted risk, with a file and a line against it. - All reads -- registry gets plus one
secedit /export,changed_when: falsethroughout -- so the node is safe to re-run mid-demo. 27 controls in one WinRM round trip, because Windows remoting pays a per-task cost that SSH pipelining does not. - Output matches
linux_compliance'ssummary.jsonshape plusexceptions[].failcounts both wrong-valued and never-set controls -- filing "not configured" undernotcheckedwould have hidden a real deviation in the field a reader looks at first.
The check playbook proves the Route, not just IIS
- Two separate claims.
win_uritohttp://localhost/runs on the guest and proves IIS is serving -- and deliberately cannot prove the firewall, because loopback is not filtered.uridelegated to localhost traverses the Route, Service, pod network and guest firewall: the path a browser takes. dc1.azure'swebserver_manageonly ever made the first claim and called it a website check. - 503 fails the node; unreachable does not. IIS up plus a 503 is a specific, actionable failure. An execution node that cannot reach the Route at all says nothing about the guest, and failing there would blame the VM for the network.
Smaller things worth knowing
windows_configuremust open port 80 itself. The image is CIS hardened, so rule 9.3.1 has the Public profile on and 9.3.2 blocks inbound by default, and a KubeVirt NIC lands on Public. Without the rule IIS serves perfectly and the Route still returns 503 -- the exact confusion firewalld causes on Linux. A local rule works only because the producer skips 9.3.4.- A legal notice for Windows is new; neither source repo had one. Same
wording and same owner as
linux_configure's/etc/issue, vialegalnoticecaption+legalnoticetext. Both values are required -- the text alone renders nothing, silently. - Two logos, not three, and no Microsoft mark.
rhel.svgis dropped because the guest is not RHEL, and a Windows mark is not ours to redistribute. The guest OS is named in the headline and the facts table. facts.jsonis awin_template, notwin_copywithcontent-- the module's own docs say formatted content belongs in a template. Its schema is identical to the Linux one, verified key by key.powershell_improvementis deliberately not ported: its last task setsRequireStrongKeyto0, which is demo choreography, not hardening. In this chain node 3 would break a control node 4 flags immediately. If that break/fix beat is wanted it belongs in #241 as an explicit template.asynconwin_updatesis applied only when not rebooting, because the module's documentation says outright that "Async does not work when reboot=true"..ansible-lintgained mocks for the tenansible.windowsmodules; CI installs no collections, so an unmocked one fails syntax-check there while passing on a laptop.
Verified
- All eight CI gates green locally.
- The whole chain syntax-checks inside
sales-demos-ee:v1.2.0viautilities/run-in-ee.sh-- ansible-core2.16.19there against2.18.18rc1on the laptop. The EE carriesansible.windows3.6.1 (matching the pin), all ten modules, andpywinrm0.5.0;community.windowsis absent, confirming it could not have been used without an EE rebuild. - The collector's logic was executed, not just parsed, against synthetic
registry and
seceditdata: correct results for compliant, wrong-value, absent, non-numeric and out-of-rangebetweeninputs on both bounds, plus the degraded path whereseceditfails and the nine policy controls report as not configured while the report is still produced. - Every template rendered with representative facts: valid JSON, well-formed HTML, no Jinja leakage.
- Not yet run against a live Windows VM -- that is part 3's gate, once the job templates exist to launch it.
Fixed -- the sd1.* catalog could never be created: cpu.guest was a string (#352)
- Defect in #348. The namespace half worked; the catalog half failed for all three tiers, so no VM of either OS could be provisioned:
VirtualMachineClusterInstancetype "sd1.small" is invalid:
spec.cpu.guest: Invalid value: "string": spec.cpu.guest in body must be of type integer
- A value that crosses the templating boundary as its own YAML scalar comes
back a string, whatever its type in
tiers.yaml. Measured rather than reasoned about:
| form | inside Jinja | after assignment |
|---|---|---|
{{ item.value.cpu }} |
int |
str |
{{ item.value.cpu \| int }} |
int |
str |
So | int is not the fix -- the filter was never the problem, which is the
sharper form of a trap this repo had already recorded once for an
IntOrString port.
- Fixed by templating the whole definition as one expression, so no inner
scalar is separately templated and the dict keeps its Python types. Measured on
the same data: spec.cpu.guest -> int 4, spec.memory.guest -> str "16Gi".
- Verified against the live CRD before merging, not just linted. All three
types created from nothing on sandbox, and the server returns
"spec":{"cpu":{"guest":4},"memory":{"guest":"16Gi"}} -- unquoted integer.
- yamllint and ansible-lint passed on the broken form, because it is
valid YAML and valid Ansible; only the CRD rejects it, at apply time. Nothing
in CI executes this file. A live run was the only gate, which is the case
/sales-demos-verify-ee exists to make.
Fixed -- Linux teardown destroyed shared objects, taking a running Windows VM with them (#348)
- Tearing down Linux while a Windows VM was running would have destroyed the
Windows VM.
kubernetes_namespace.demoand thesd1.*instance type catalog were Terraform resources gated onmanage_shared_objects, true only for Linux -- so the Linux state owned them, andterraform destroydeletes what a state owns. Deleting a namespace deletes everything in it. - That is exactly what #301 introduced per-OS state to prevent. #301 stopped one OS planning the OTHER OS's VM for destruction; it did not stop one OS deleting the namespace that VM lives in. The nightly sweeps make the ordering routine -- both fire at 6 PM, and #301 split them precisely so each OS could be torn down independently.
- Found by the second symptom, not the first. After tonight's teardowns a
Windows provision failed with
insufficient Memory resources of 0 provided by VirtualMachine, preference requires 2Gi-- which names memory because thesd1.largeinstancetype was gone, so the VM got none. Measured:VirtualMachineClusterInstancetypeheld only Red Hat'scx1./d1./m1./n1./o1./rt1./u1.series, nosd1.*at all. The namespace was gone too, which the provision's own guard revealed by reportingchangedrather thanok. - Both now belong to Ansible, in
playbooks/tasks/ensure_shared_objects.yml, run for both OSes before Terraform.kubernetes.core.k8swithstate: presentadopts an object that already exists -- the thingkubernetes_manifestcannot do, which is what forced the single-owner model in #309/#311 in the first place. Teardown leaves them alone, like CNV and the boot-source DataSources. var.manage_shared_objectsis retired, and with it the asymmetry that made Linux the privileged OS. Thewhen: provision_os_type != 'linux'guard on the namespace task is gone: there is one creator again, so it needs no gate. That guard's own comment predicted this fix -- "it wants the same fix, moving shared scaffolding to environment scope, not another special case."- New
terraform/ocpvirt/tiers.yamlis the single tier catalog, read by Terraform throughyamldecodeand by the new task file throughfrom_yaml. Moving the creator to Ansible put the specs in reach of two languages, and two copies of a value that must agree is how #334 and #342 both happened inside a week. Neither language owns a copy.locals.tf's four hardcoded maps are gone. - Only applies to a state that no longer holds these objects.
terraform destroydestroys what is in STATE, so an environment whose state still contains them will still remove them once. Both environments were fully torn down before this landed, so both states are already clean.
Fixed -- pinning ansible.windows broke AAP project sync (#346)
- Regression from #339.
config.yml --limit sandboxfailed at the project update, which blocks every job template using theSales Demosproject. hub/approved-collections.ymlis generated fromcollections/requirements.yml, and #339 added a pin without regenerating it. Since #69, sandbox resolves project-sync collections from PAH's curatedapprovedrepository; an AAP project update runsansible-galaxy collection install -r collections/requirements.ymlagainst it, so a pin that is not curated cannot resolve.- The file documents this exact failure in its own header -- "THE DEPENDENCIES ARE NOT OPTIONAL... Seeded with the direct pins alone, this repository failed the first real #69 run" -- which is what makes the miss annoying rather than surprising. The generated header count was wrong too: 10 collections claimed against 10 pins.
- Regenerated with
refresh-hub-requirements.py --write-approved(11 collections, no new transitive dependencies) and curated into the live hub: "Added 1, removed 0", repository verified equal to the file.config.ymlagainst sandbox is green again. - The collection was always available --
--audit-pinsreportsok ansible.windows -- pinned 3.6.1, certified floor >=3.6.0. It only needed curating intoapproved. - CI did not catch this and arguably should have. The
generated-filesgate does not cover this pairing, which is the one where drift takes an environment down rather than merely going stale. Left for its own change rather than widened here; noted in #346.
Added -- Windows gets a web Route, so it has a demo payoff (#340, part 1 of 3)
- The Linux demo's whole point had no Windows counterpart.
create_web_routewas gated oncreate_linux, soos_type=windowsproduced no Service, no Route and a nullweb_url. IIS could be installed and would serve nobody outside the cluster; there was no 503 -> 200 beat to show. kubernetes_service.windows_webandkubernetes_manifest.windows_web_route, mirroring the Linux pair including the #45 edge-TLS block -- without it Chrome auto-upgrades to HTTPS, finds no matching TLS route, and the demo reads as broken.- The gate is now two gates,
create_linux_web_routeandcreate_windows_web_route, because the OSes are not symmetric: Cockpit is a RHEL web console and stays Linux-only. There is no Windowscockpit_url; RDP is already published on the headless Service and is not HTTP. web_urlresolves per-OS rather than gaining a sibling output. One state builds one OS since #301, so it is never ambiguous, and every downstream consumer -- the host var, theset_stats, the demo page, the compliance report link, the check playbook -- keeps reading one name. A secondwindows_web_urlwould have forced each of them to learn which OS it was looking at.- The Windows host carried 5 host vars against Linux's 12. Added
web_url,golden_image_source,golden_image_cis_levelandenv_name, which the Windows roles in part 2 read; without them each role needs its own fallback and the Windows page would disagree with the Linux one about what it knows. - Added the Windows
set_statsblock inprovision_artifacts.yml, which had a Linux one and no counterpart. - Corrected four stale strings still offering
os_type=both, removed in #301:variables.tf,terraform.tfvars.example(also still showing only the legacy tier names), and two inocpvirt-demo-plan.md.
Added -- ansible.windows pinned, and no EE rebuild was needed (#339)
- Pinned at 3.6.1, the prerequisite for the Windows Day 1 chain (#340).
- The EE rebuild this was expected to need does not exist. Measured against
the published image AAP runs today,
quay.io/zigfreed/sales-demos-ee:v1.2.0, and against the base digest it was built from -- not assumed:
ansible/windows 3.6.1 at /usr/share/ansible/collections
community/windows ABSENT
pywinrm 0.5.0 (plus requests_ntlm 1.3.0, pyspnego 0.12.1)
So no v1.3.0, no re-proving Linux Day 1 - 0 Workflow on a new image, and no
pywinrm verification step. controller_templates.yml:628-641 and
CHANGELOG.md's #301 entry both said a Windows template "would need a
collection bump and an EE rebuild". The first half was right.
- The pin still earns its place. The laptop had 3.0.0 against the image's
3.6.1 -- exactly the drift the ansible.hub pin was added to stop, in the same
file, for the same reason: "a laptop resolving different code from the EE is
exactly the drift this file exists to stop."
- community.windows is deliberately not pinned, and the measurement gave a
second reason on top of the module-level one. It is absent from both images, so
it is the one collection that would have forced a rebuild -- the risky step
would have been self-inflicted, caused by a collection nothing needs.
ansible.windows carries every module the chain uses, including
win_user and win_audit_policy_system. The sole draw was
win_security_policy, and both callers went away.
Fixed -- teardown still carried #295's inverted insecure flag (#342)
- Every teardown failed on both environments -- sandbox jobs 377 and 380,
demo job 121 -- with the error #295 already has a name for:
tls: failed to verify certificate: x509: certificate signed by unknown authority. - #295 fixed one of two copies.
#238split provision and teardown into separate task files and duplicated the then-buggy ternary into both.38ff322then changedterraform_ocpvirt.ymlandCHANGELOG.mdand nothing else --teardown_ocpvirt.ymlexisted at that commit with the identical expression at line 186 and was left alone.openshift_validate_certsdefaults tofalse, sonot falseistrue, and teardown'sternary('false', 'true')picked'false': TLS verification on, against RHDP's self-signed certs. - It hid behind two flags that were right. The kubeconfig
insecure-skip-tls-verifyand the-backend-config=insecure=in the same file both use| lowerand are correct, soterraform initand "Read the Terraform outputs BEFORE destroying" succeed and only "Destroy the VMs" fails. That reads like a cluster or certificate problem, not a code defect. - Fixed by deleting the ternary rather than swapping it. Both files now use
the
| lowerform the correct flags already used, so all six TLS flags across the two are one idiom and an inverted ternary cannot be written where there is no ternary. Verified equivalent to the correct ternary forvalidate_certstrue, false, and undefined; the provision change is behaviour-identical. - The same lesson as #334, one file over. "One copy, because two copies of this expression is how the two consumers got it wrong identically" -- written about the VM-memory sum, and true again here. The remaining duplication is now a shared idiom rather than a shared expression, which is weaker; a single resolved fact read by both would be stronger still.
- Cost of the miss: both environments had a Linux VM up that could not be torn
down, and the nightly sweeps would have failed the same way and left them
running -- exactly what
controller_schedules.ymlexists to prevent, and more so since #301 gave each OS its own sweep. Note the guests are unregistered from the CDN and Insights before the destroy, so a VM surviving a failed teardown has already released its subscription.
Fixed -- the Windows credential carried the Linux password (#338)
Sales Demos - Windows Machinewas fedenv_secrets[<env>].linux_admin_password. #305 split Windows onto its own per-environment key and every other consumer moved with it -- Terraform sets the guest password fromwindows_admin_passwordthrough the sysprep unattend, and the Env Secrets credential already shipped it -- but this one line did not.- The lengths prove it was wrong, not merely untidy.
linux_admin_passwordis deliberately 8 characters;provision_vm.ymlasserts Windows needs 14 or more because CIS L1 for Windows Server 2022 rejects anything shorter. The credential held a password six characters below the guest's own minimum, so it cannot have been the password sysprep set. - Nothing caught it because it is attached to zero job templates. There is no Windows content to run yet -- that is #340. The failure it would have produced is a WinRM authentication error, which reads like a listener or firewall problem rather than a wrong password, so fixing it before anything consumes it removes a debugging session that would have looked like an infrastructure bug.
- No
| default(''), matching the Linux Machine credential and unlike Env Secrets: a missing password should failconfig.ymlloudly rather than create a credential that authenticates as nobody. utilities/check-secrets-example.py's note still explained the key's absence by saying it was "fed fromlinux_admin_passwordrather than from a key of its own" -- true when #201 wrote it, false since #305. Corrected rather than left asserting the thing that stopped being true.
Fixed -- the probe reported "across 0 VM(s)" while counting 20 GiB (#336)
Demo VMs now: 20.0 GiB across 0 VM(s)— the memory was right, the count was not. #334 replaced the inline VM read with the shared task file but left the report line referencing the register it deleted, soprobe_demo_vmswas undefined and| default([]) | lengthquietly returned 0.- Uses
demo_vm_countedfrom the shared file, and now names the VMs it counted rather than only totalling them. - Cosmetic, but exactly the shape of the bug it was reporting on: a figure that silently reads zero because the thing behind it is not there.
Fixed -- both memory checks totalled zero because the memory is not in the VM (#334)
- The #332 fix ran and returned
Demo VMs now: 0.0 GiB across 2 VM(s). It found the VMs and counted nothing. - These VMs are instancetype-based.
terraform/ocpvirtsetsspec.instancetype: {name: sd1.<tier>}and the guest memory lives in theVirtualMachineClusterInstancetype.domain.memoryis genuinely absent anddomain.resourcesis{}, so the obvious read and its obvious fallback both find nothing. Measured:sd-lnx-large->sd1.large->16Gi. - #332's reasoning was the bug. It argued reading the VM spec was more robust than a tier lookup "so a hand-built VM is still counted" -- when the spec was the one place the number was not.
- The worse half:
provision_vm.yml's guard has never worked. It carried the identical expression, so_existing_gbhas been 0 since #301 and the assert has evaluated0 + tier <= budget-- passing by construction, not because the cluster had room. That check exists precisely because per-OS Terraform states cannot see each other; it was not doing the job it was added for. Nothing broke only because the real totals would have fit anyway. - New
playbooks/tasks/sum_demo_vm_memory.yml, included by both callers: resolves each VM's instancetype to its guest memory, honouring inline memory first so a hand-built VM still counts. One copy, because two copies of this expression is how the two consumers got it wrong identically. provision_vm.ymlnow prints the budget check on success, naming the VMs counted. A guard that never shows its working cannot be caught being wrong.
Fixed -- probe_env double-counted the running demo VMs (#332)
- Found by running
Cluster Day 0 - Probe Capacityon sandbox with two demo VMs up: it recommendedavailable_memory_gb = 42against a committed 63. - The committed 63 was correct. Reproducing from the probe's own numbers
lands on it exactly:
50.95 free + 20 held by demo VMs - 8 margin = 62.95. available_memory_gbis the budget for all demo VMs -- both consumers read it that way -- but the probe recommended free-by-requests minus a margin, and free-by-requests already has those VMs subtracted. So it subtracted them twice.- It was self-compounding. Probe with VMs up, set the lower number, provision more, probe again, get lower still. Every pass ratcheted it down.
- The playbook now reads the demo VMs (same
app.kubernetes.io/part-of=sales-demosselectorprovision_vm.yml's check uses, so the two agree on what counts) and adds their guest memory back. Memory comes from the VM spec rather than a tier lookup, so a hand-built VM is still counted. - The report now prints both figures and names which is which: the budget, and the headroom on top of what is running, with an explicit "do not copy it" on the second.
- The trap was one we had just baited.
probe_env.ymlsays it is safe to run mid-demo and #330 added aread-onlylabel saying the same. The measurement always was safe; the recommendation was not, and nothing said so.
Added -- Cluster Day 0 templates and workflow (#330)
- Three templates --
1 Install OpenShift Virtualization,2 Verify Environment,Probe Capacity-- plus aCluster Day 0workflow chaining install, RHEL 9 image link, and verify. - The subject of Day 1 is the VM; the subject of Day 0 is the cluster. The prefix extends the existing scheme rather than inventing one.
- The workflow is named bare, and that asymmetry is deliberate.
Linux Day 1 - 0 Workflowneeded its0because "Workflow" sorts after "Teardown"; here the name is a prefix of its family and sorts first for free. "Fixing" it into consistency would push it to the bottom -- what #303 undid. - It crosses families on purpose: the middle node is a
Golden Imagetemplate, because verify builds a real VM from a boot source and would report a false failure before the image is linked. Windows linking is excluded -- it is the slow import and a Linux-only environment should not pay for it. - The survey is two questions, deliberately. Nearly every Day 0 input is
auto-detected (
cnv_storage_class: ""discovers the class), and a survey padded with defaults nobody changes makes automation look arbitrary rather than considered. - New labels
cluster,day-0, andread-only.read-onlyis a fourth axis and earns it:probe_env.ymlis safe to run mid-demo, and domain, phase and hypervisor cannot say so. "Can I run this right now without breaking what is on screen?" is the question an SE actually asks. - All three are
hosts: aapwith zero shell-outs, verified by grep -- the check #324 skipped when it shipped a template for a playbook that needed a helm binary the EE did not carry.
Documented -- what stays on the laptop, and why (#330)
architecture.mdnow names the three exceptions rather than leaving someone to hunt for templates that cannot exist: the EE build (podman plus the Red Hat offline token #22/#68 keep to a single copy),config.yml(it creates the templates), andsync_hub/curate_hub(same token).- The Domains chips are label filters held in per-user browser state.
Measured: no
/domains/endpoint on the controller, gateway or/api/v2/, nothing insettings/, but?labels__name=linuxreturns 9. Soconfig.ymlcannot set them and each person configures their own via the wrench icon -- worth knowing before searching for a config-as-code knob that does not exist.
Fixed -- the portal job template can now actually run from AAP (#324)
AAP Ecosystem - Install Self-Service Portalfailed ten seconds after every launch for two independent reasons, and fixing either alone only moved the failure to the other.- EE v1.2.0 adds the helm 3.21.4 binary.
kubernetes.core.helmwraps the helm CLI rather than talking to the API, and the EE carried terraform and nothing else. - Pinned to 3.x, not latest, deliberately. Helm's latest is 4.2.4, but
kubernetes.core.helmdeclaresrequirements: helm >= 3.0.0and its newest options gate on 3.16/3.17 -- it is written against the 3.x CLI and 4.x is a breaking major. - The archive is not shaped like terraform's: terraform ships a flat zip,
helm a tar.gz nesting the binary under
linux-amd64/, so extraction needs--strip-components=1. Verified by listing the tarball, not inferred. portal.ymlnow synthesises its kubeconfig fromopenshift_api_url/openshift_api_token, which it already asserted were present. It previously read.kube/<env>.kubeconfig-- gitignored, so AAP's checkout never had one. Always synthesised, with no fallback: one code path, rather than an AAP path exercised only in production. Wrapped inblock/alwaysbecause the file holds a bearer token, so it is removed even when Helm fails.utilities/build-ee.shverifies helm as UID 1000 beside terraform -- a binary that works as root and not as the job user would pass the Containerfile's own check and fail in front of a customer.v1.1.0stays mirrored as the rollback; reverting is one line incontroller_execution_environments.yml.- Build verification: terraform still v1.15.8, helm v3.21.4 as UID 1000, all nine collection pins matched with no drift, no credential in the image.
Removed -- the one-time Terraform state migration (#301)
- Both environments have now run it, which was #301's stated condition for deleting it: sandbox and demo on 2026-09-07.
- Verified adoption, not recreation. sandbox's Linux VM kept UID
265f3eea-e062-4947-83c0-b4d79d8809f7and its original creation timestamp across the migration -- a destroy/recreate would have looked identical in a VM listing, which is why the UID was captured beforehand. - A new environment needs nothing here: it has no legacy state to adopt, and
Terraform creates
<env>-<os>on first apply. - The comment left in its place records how to adopt a legacy secret by hand, since the code that did it is gone.
Documented -- the portal job template cannot run from AAP (#324)
AAP Ecosystem - Install Self-Service Portal, added in #300, fails ~10s after launch. Two independent blockers: the SCM checkout has no.kube/<env>.kubeconfig(gitignored, laptop-generated), and the EE has nohelmbinary --kubernetes.core.helmwraps the CLI.- Kept and labelled rather than removed, so the limitation is visible in the AAP UI instead of being a silent ten-second failure.
- Why #300 got it wrong: the template was justified on
hosts: aapplus credentials, matching Install Automation Orchestrator. But AO and Install MCP Server usekubernetes.core.k8s, which reaches the API throughK8S_AUTH_*;portal.ymlshells out to a binary. What a playbook executes is part of whether it can run from AAP, not just what it targets -- and/sales-demos-verify-eeexists to catch precisely that.
Fixed -- the namespace guard fought Terraform for ownership (#325)
- The first
Linux Day 1 - 0 Workflowon demo failed:Error: namespaces "sales-demos-demo" already exists. - The idempotent guard added in #311 created the VM namespace in Ansible moments
before Terraform tried to create it. Two creators of one object, which
kubernetes_namespacecannot resolve because it has no way to adopt. - Invisible on sandbox by luck: that environment's state already contained the namespace from before the per-OS split, so Terraform never planned to create one. demo's adopted state held no VMs and no namespace, so the conflict surfaced on the first run.
- Gated on
provision_os_type != 'linux'. The Linux state owns shared objects; the guard is now what it should always have been -- a fallback for the one case Terraform does not cover, rather than a second creator racing it. - The residual gap is the same one #309 already records. An environment that
provisions Windows first gets its namespace from the guard, and a later Linux
run then finds Terraform unable to create what exists. Identical shape to the
sd1.*catalog wart, and it wants the same fix -- shared scaffolding moved to environment scope -- not another special case.
Fixed -- demo never had a RHEL 9 golden image reference (#322)
Golden Image - Link RHEL 9 CIS L1failed on demo withquay_rhel9_image must name a real published containerdisk. demo'sconnection.ymldeclaredquay_windows_imagebut notquay_rhel9_image.- The playbook was right and the assert did its job; the environment was simply never told which image to link.
- Diffed the two
connection.ymlfiles rather than fixing the one key the error named: this was the only difference, so there is no second one waiting.
Fixed -- the EE mirror referenced a registry the repo does not create (#320)
config.yml --limit demofailed mirroringsales_demos_eeinto Private Automation Hub.hub_ee_repositories.ymlreferencedRed Hat Quay.io, which does not exist on demo -- it has only the pre-#186quay_ioobject.- This reverses #186. That change deleted
hub_ee_registries_allon the premise that the stock registry is "provisioned by the installer on every RHDP environment". The premise is false, and the failure is not cosmetic: the mirror stays empty and every job template later fails to pull its execution environment. - #186 was not wrong about the facts, it was wrong about the trade -- it called the second entry "duplication", which is cosmetic, against an environment that cannot run any job at all. A repo that depends on an object it does not create is guessing about someone else's installer.
- Named
quay_iobecause the API restricts user-created registry names to alphanumerics and underscores; the stock name contains spaces and a dot because the installer is exempt from its own validation, so matching it is not even possible. Idempotent on demo, which already has it. - No
indexorsynckeys, deliberately:hub_ee_registry_indexandhub_ee_registry_syncdispatch off the same variable and are gatedwhen: item.index|sync | default(false). Registry-level sync would index the whole of quay.io. Mirroring stays at repository level.
Added -- job templates for observability and golden-image linking (#318)
- Two new families extending the #300 taxonomy:
AAP Observability -(1 Deploy Alloy,2 Deploy Dashboards) andGolden Image -(Link RHEL 9 CIS L1,Link Windows 2022 CIS L1). Four playbooks that already worked from a laptop had no AAP path at all. - Observability is numbered because it is a chain -- dashboards pushed at a
cluster with no Alloy render twelve empty panels. Golden Image is not: the
two links are independent, same rule as
RepairandTeardown. - "Golden Image", not "Image Build". These CONSUME what
image.builder.pipelinepublishes; nothing here builds an image. Naming the family for a verb it does not perform would send someone to the wrong repo. - New labels
observabilityandgolden-image. Dashboards deliberately carry noocpvirtlabel -- it writes to Grafana Cloud and touches no cluster, the same line #309 draws for the SSH-only Linux steps.
Changed -- nine new fields on the Env Secrets credential type (#318)
grafana_cloud_url,grafana_cloud_editor_sa_token,grafana_cloud_prom_push_url,grafana_cloud_prom_username,grafana_cloud_loki_push_url,grafana_cloud_loki_username,grafana_cloud_push_api_key,quay_username,quay_password.- These are NOT keyed per environment, unlike every credential before them. One Grafana Cloud and one quay account serve both environments, so they are top-level in the vault and both controllers receive identical values.
- URLs and usernames are deliberately not
secret. A write-only field renders as "ENCRYPTED" in the AAP UI, which makes a mistyped push endpoint undiagnosable without re-reading the vault. Only the two tokens and the quay password are write-only. - None of the nine is
required. They serve four templates out of fifteen, and an environment that never deploys Alloy should not be blocked from creating the credential every Day 1 template depends on. grafana_cloud_sa_tokenis deliberately excluded. It appears indeploy_dashboard.ymlonly inside a comment explaining why the Editor token is separate -- nothing reads it. Adding it would have put a live credential into AAP for no reason. It stays incheck-secrets-example.py'sSTAGEDlist.link_rhel9_image.ymlneeded no new credentials -- that quay repository is public, so only the Windows link drove the rebuild.- Applying this requires deleting the credential and then the credential type:
AAP returns
403 Modifications to inputs are not allowed for credential types that are in use. Documented in the credential type's own header since #305.
Added -- job template for the AAP MCP server (#308)
AAP Ecosystem - Install MCP Server, runningplaybooks/mcp_server.yml.setup.ymlinstalls it as stage 4, but there was no way to re-run it alone.- Why standalone matters:
aap_mcp_allow_write_operationsis the write posture for an AI agent against AAP, and Red Hat's docs are explicit that changing it requires deleting and recreating theAnsibleMCPServerCR rather than patching.mcp_server.ymlalready handles that -- it reads the live object and deletes it when the flag differs -- but the only way to reach that logic was re-running the whole ofsetup.yml, which also reinstalls CNV and reapplies the entire AAP configuration. - Deploys the SERVER, not the client. Wiring a laptop to it is
claude mcp add --scope localvia thesales-demos-mcpskill, which writes to the operator's own config and is deliberately laptop-only (#102). The description says so, or someone launches this expecting their machine to change. - Qualifies on the same criteria as the other two in the family:
hosts: aap, auth fromopenshift_api_url/openshift_api_tokenvia the Env Secrets credential,aap_mcp_allow_write_operationsfrom committedgroup_vars,kubernetes.corealready in the EE, and no laptop-only dependency.
Fixed -- the state-migration tasks had no cluster auth either (#315)
- Second occurrence of #313, in tasks added by the same PR. The first Linux
provision after #301 -- the run that triggers the one-time migration -- failed
with
Invalid kube-config file. No configuration found. - #313 fixed the VM-namespace guard and stopped there. The two migration tasks
have the same defect but sit inside the Terraform block, so a different run
exposed them. Windows was unaffected: the migration is guarded
when: provision_os_type == 'linux'. - Audited rather than spot-fixed this time. All five
kubernetes.coretasks interraform_ocpvirt.ymlare now checked; two were missing auth, both fixed, audit reports zero. - Added a header note and a one-line check, because
environment:here is per-task with nothing to inherit, and the error reads like a broken kubeconfig rather than four missing lines -- which is why it was missed twice.
Fixed -- the new VM-namespace guard had no cluster auth (#313)
- The task added in #311 failed with
Invalid kube-config file. No configuration found.It sits outside the Terraform block, which is where theenvironment:lives, so it inherited nothing. The state-namespace guard beside it has always carried its ownenvironment:; the new one now does too, with a comment saying why, since this is a per-task rather than per-play pattern in this file.
Fixed -- the VM namespace was the second shared object (#311)
- The retried Windows provision got past the instance types and failed on
namespaces "sales-demos-sandbox" already exists. manage_instancetypesfrom #309 was too narrow, and the narrowness was the bug: it fixed the catalog and the next run failed on the namespace instead. Renamed tomanage_shared_objects, which is the actual concept -- shared environment scaffolding -- and the namespace is guarded by it too.- Enumerated rather than discovered one failure at a time. Every resource in
terraform/ocpvirt/is either scoped to one OS bycountoncreate_linux/create_windows, or shared. The shared set is exactly two: the VM namespace and thesd1.*catalog.terraform_data.memory_budgetis shared but creates no cluster object. So this is the last one. - All nine references to
kubernetes_namespace.demoaredepends_onwith no attribute access, so addingcountneeded no[0]indexing. provision_vm.ymlnow also ensures the VM namespace idempotently, sending onlymetadata.nameso it is a no-op patch when Terraform owns the object. A Windows-only environment gets its namespace either way.
Fixed -- both OS states fought over the shared instance type catalog (#309)
- The first real Windows provision after #301 failed with
Cannot create resource that already exists: resource "/sd1.small". instancetypes.tfcreates thesd1.*VirtualMachineClusterInstancetypeobjects, which are cluster-scoped and shared by both guests. #301 keyed state per OS -- right for the VMs, wrong for a shared catalog: both states tried to create the same three cluster objects.kubernetes_manifestcannot adopt an existing object, so this is ownership, not a force flag.- New
manage_instancetypesvariable, default true;provision_vm.ymlpasses false for Windows. The Linux state owns the catalog, Windows references it. - Verified in the failure: the Linux VM was untouched by the failed Windows run. Before #301 a Windows provision would have destroyed it outright.
- The file's header already flagged this shape for concurrent runs; #301 turned that external hazard into an internal one. The proper fix -- moving the catalog to environment scope -- is tracked in #309, along with the question it actually turns on: where the tier -> cpu/memory map should live.
Fixed -- provisioning one OS destroyed the other (#301)
- Terraform state is now keyed
secret_suffix=<env>-<os>instead of<env>.terraform/ocpvirt/locals.tfderivescreate_linux/create_windowsfromos_typeand those drivecount, so with a single shared state an apply withos_type=windowssetcreate_linux=false, dropped count to 0, and planned the running Linux VM for destruction. Building one OS meant losing the other. - #300 removed the survey dropdown that let an SE trigger this from the UI. This removes the possibility.
- Teardown is keyed the same way, so destroying Windows leaves Linux running.
Added -- Windows Day 1 templates (#301)
Windows Day 1 - 1 ProvisionandWindows Day 1 - Teardown, withos_type: windowspinned and a newwindowslabel.- Provision and Teardown only. There is no Windows Configure or Check
because there is no Windows content yet --
ansible.windowsis in neithercollections/requirements.ymlnor the EE, so those need a collection bump and an EE rebuild. That is #241. - Windows nightly teardown schedules in both environments. Not optional: the Linux sweep can no longer see the Windows VM, so without its own sweep a Windows guest would burn RHDP quota all night.
Changed -- os_type=both removed (#301)
- With state keyed per OS,
bothwould open a THIRD state holding two VMs whose names collide with the two single-OS states, and whichever ran last would fight the others. One apply builds one OS; run it twice for both. - Removed from the Terraform validation and the
provision_vm.ymlassert. Nothing in AAP passed it after #300 pinnedos_typeper template family.
Added -- cluster-wide memory check before provisioning (#301)
- Terraform's budget guard can only see the state it is applying, and neither OS's state can now see the other -- so both could pass their private check while together overcommitting the node.
provision_vm.ymlasks the cluster what the existing demo VMs request, adds this run's tier, and compares againstavailable_memory_gb. One read-only API call. The VM this run is converging is excluded by name, or a re-run would count its own VM twice and refuse to converge it.- The Terraform precondition stays as a cheap per-OS sanity check, with its error message rewritten to say what it can and cannot see.
Changed -- available_memory_gb is defined in Ansible now (#301)
- It existed only as a default on the Terraform variable, which Ansible never
passed -- so the number in
variables.tfwas the only one that ever applied, identically to both clusters, and could not differ per environment despite being a per-cluster measurement. - Now set in each environment's
connection.ymland passed on apply, so the Terraform guard and the new cluster-wide check measure against the same number. The Terraform default remains for hand-runs. - Caught by
secrets-example-sync, which correctly flagged the new assert as reading a variable nothing defined.
Added -- one-time state migration (#301)
terraform_ocpvirt.ymladopts the legacytfstate-default-<env>secret astfstate-default-<env>-linuxwhen the new key does not exist yet, so the running Linux VM is adopted rather than orphaned. Without it the new Linux state starts empty and Terraform tries to create a VM that already exists.- Guarded three ways (Linux only, legacy present, new absent), so it runs at most once per environment. Delete the block once both environments have run it.
Fixed -- the Windows password was too short for the CIS image (#305)
- Both environments'
linux_admin_passwordis 8 characters, and it was serving as the Windows administrator password too. CIS L1 for Windows Server 2022 sets a 14-character minimum, and the CIS-hardened image has been the one we clone since #270. secrets.yml.examplepredicted this exactly -- "will be REJECTED once the CIS-hardened Windows image lands. Lengthen both environments before then, not after." The image landed; the values were never lengthened.- The failure mode is why this is a bug and not a chore: sysprep cannot create the local administrator account, so the guest boots to a desktop and WinRM never answers. That reads as a network or credential fault and sends you looking in the wrong place.
provision_vm.ymlnow asserts the length whenos_type=windows, so it fails with a message that names the cause instead of producing an unreachable VM.
Changed -- Windows password is its own per-environment key (#305)
- New
windows_admin_passwordunderenv_secrets, wired throughconnection.yml, theSales Demos - Env Secretscredential type and its credential, so both the laptop and AAP paths carry it. playbooks/tasks/terraform_ocpvirt.ymlstops feedingTF_VAR_windows_admin_passwordfromlinux_admin_password. No fallback to the Linux value -- a silent fallback would reproduce the 8-character failure above.- This does not undo #201. #201's defect was scope: the old
windows_admin_passwordwas a single GLOBAL value because it was baked into the golden image both environments pull. #201 moved the real password onto the clone, and once it left the image nothing forced it to equal the Linux one -- sharing was convenience, not constraint. A per-environment key is compatible; a global one still would not be, and the comments say so in all four places that used to argue the other way. - Removed a stale top-level
windows_admin_passwordleft in the vault by #201. It was read by nothing, 11 characters long, and would have been a second source of truth for a key that now exists per environment. linux_admin_passwordis deliberately left at 8 characters. Lengthening it means reprovisioning the Linux VM, which is a separate decision; it only guards Cockpit login on the RHEL guest.
Fixed -- the workflow sorted last in its own family (#303)
- Renamed
Linux Day 1 WorkflowtoLinux Day 1 - 0 Workflow. #300 expected the first name to head its family; measured against the live API it sorted belowLinux Day 1 - Teardown. - A dash alone would not have fixed it.
/unified_job_templates/?order_by=nameshowsWindows Day 2 Auditsorting beforeWindows Day 2 - Check SMB Server, so Postgres ignores the punctuation at the primary collation level andLinux Day 1 - Workflownormalises to exactly what the old name did. The same data shows digits sorting ahead of letters, which is what makes the numbered steps work and rules out any word-based name here. - Also corrected the schedule name in
architecture.md, which #300 missed -- it still readSales Demos - Nightly teardown (6 PM).
Added -- labels on every template (#303)
- New
inventory/group_vars/aap/controller_labels.yml. This AAP had no labels at all:/api/controller/v2/labels/returnedcount: 0. - Three axes -- domain (
aap-ecosystem,linux), phase (install,day-1) and hypervisor (ocpvirt) -- so the Templates page gets a filter, and, because AAP copies a template's labels onto the jobs it launches, so does the Jobs page. ocpvirtis only on the hypervisor-bound templates. Provision and Teardown run Terraform, and the workflow inherits it by chaining Provision. Register, Configure, Compliance Scan, Check and Repair reach the guest over SSH and do not care what built it -- the same line #237's provider abstraction draws. Labelling the whole family would make the filter useless on the day it finally matters.- Ordering needed no work:
infra.aap_configuration's dispatcher runscontroller_labelswell before the job template and workflow roles.
Changed -- Rename AAP templates to a family taxonomy (#300)
- Every job template and the workflow now carry a family prefix, so the AAP
templates list tells a Sales SE what they are looking at. Two families:
AAP Ecosystem -for platform add-ons that belong to no demo story, and<OS> Day <n> -for one story's chain. Sales Demos - Build Demo VMis nowLinux Day 1 Workflow. Its five job templates becameLinux Day 1 - 1 Provision,2 Register,3 Configure,4 Compliance Scanand5 Check. Steps carry their position because AAP sorts the list alphabetically, which would otherwise scramble the chain into dictionary order.Sales Demos - Run Linux Demois nowLinux Day 1 - Repair, andplaybooks/run_linux_demo.ymlis nowplaybooks/repair_linux_vm.yml. The old name did not say what it does: it re-runs demo content on a VM that already exists, rather than building one.Sales Demos - Teardown VMsis nowLinux Day 1 - Teardown, and both nightly-teardown schedules were renamed to match. The old schedules are not retired explicitly -- deleting a job template cascades to its schedules, and the old template is retired in the sameconfig.ymlrun.Sales Demos - Install Automation Orchestratoris nowAAP Ecosystem - Install Automation Orchestrator.Sales Demosdeliberately survives on shared platform objects -- the project, the execution environment, the inventory and every credential -- because Windows Day 1 will use exactly the same ones, so the repo's name is their correct scope.- All nine old names carry
state: absententries soconfig.ymlremoves them from both environments. The module creates the new name and leaves the old one behind; without this the two would sit side by side in the UI and the stale one would still be launchable.
Added -- Job template for the self-service portal (#300)
AAP Ecosystem - Install Self-Service Portal, runningplaybooks/portal.yml. The portal had a playbook and a skill since #103 but no job template.
Fixed -- os_type dropdown could destroy the running Linux VM (#300)
- Removed the "Operating system" survey question from the provision template
and the workflow, pinning
os_type: linuxas a fixedextra_varsentry instead.terraform/ocpvirt/locals.tfderivescreate_linuxfromos_typeand that drivescounton every Linux resource, while state is keyedsecret_suffix=<env>-- one state per environment, holding both OSes. So answeringwindowssetcreate_linux=false, dropped count to 0, and planned the running Linux VM for destruction. - This removes the footgun from the UI. It does not make the two OSes
independent -- that needs state split per OS, tracked in #301, which is also
where the
Windows Day 1 - *templates land.
Fixed -- a repair left the compliance report stale (#300)
playbooks/repair_linux_vm.ymlnow importslinux_compliance_scan.ymlas well as register and configure. It previously ran only the first two, so a repair rewrote the demo page and left yesterday's OpenSCAP report still being served at<web_url>/compliance/, describing a machine that no longer existed in that state. The two entry points now agree on what "configured" means.
Fixed -- workflow documented as four nodes (#300)
architecture.mdand thesales-demos-dev-workflowskill both described a four-node workflow; the compliance node from #202 made it five.docs/images/aap-workflow-running.pngis stale for the same reason and now also shows the old workflow name. The run-sheet and talk-track say so inline until it is retaken.
Changed -- Update skills for CIS L1 Windows golden image (#296)
- Updated
ocpvirt-provision,ocpvirt-windows-image, andocpvirt-demoskills to reflect that the CIS L1 hardened Windows image is published and working. Removed stale "never boots" / "not published yet" language.
Fixed -- Terraform provider insecure flag inverted (#295)
- Swapped the ternary values for
TF_VAR_openshift_insecuresoopenshift_validate_certs: falsecorrectly setsinsecure=trueon the Terraform kubernetes provider. Without this, provisioning fails on RHDP's self-signed certificates withx509: certificate signed by unknown authority.
Changed -- Consume CIS L1 hardened Windows golden image (#270)
- Repointed
quay_windows_imagein both sandbox and democonnection.ymlfrom the unhardenedwin2k22-goldenrepo to the new CIS L1 hardenedwin2k22-cis-l1-golden:20260907-0516built byimage.builder.pipeline. - Updated architecture.md and ocpvirt-demo-plan.md to reflect that Windows login, sysprep, and WinRM all work end-to-end (#234, #255, #257), and that the image is now CIS L1 hardened.
Fixed -- Build Demo VM workflow nodes not updated after JT rename (#240)
- Added
destroy_current_nodes: trueto the Build Demo VM workflow soconfig.ymlreplaces existing nodes instead of only adding. Without this, renaming the JTs in #292 left the old node references in place — theinfra.aap_configurationmodule only adds nodes, it does not diff.
Changed -- Linux-scope playbook and job template names (#240)
- Renamed five playbooks and their matching job templates so names are
explicitly Linux-scoped, ahead of Windows day 2 operations arriving:
register_vm.yml→register_linux_vm.yml,configure_vm.yml→configure_linux_vm.yml,check_vm.yml→check_linux_vm.yml,compliance_scan.yml→linux_compliance_scan.yml,run_demo.yml→run_linux_demo.yml. - Added
state: absententries for the five retired JT names soconfig.ymlremoves them declaratively. - Updated the "Build Demo VM" workflow to reference the new JT names.
Fixed -- Namespace "All" breaks 7 dashboard panels (#289)
- Added
allValue: ".*"to the namespace template variable on the Cluster Health dashboard. Without it, selecting "All" substituted the literal stringAllinto${namespace:regex}, matching no namespace.
Changed -- Provider abstraction layer (#238)
- Refactored
playbooks/provision_vm.ymlandplaybooks/teardown.ymlinto a provider dispatch pattern:include_tasks: tasks/terraform_{{ hypervisor }}.ymlandinclude_tasks: tasks/teardown_{{ hypervisor }}.yml. - Extracted OCP Virt-specific logic into
playbooks/tasks/terraform_ocpvirt.ymlandplaybooks/tasks/teardown_ocpvirt.yml. - Extracted shared AAP host registration into
playbooks/tasks/register_hosts.ymland shared workflow artifact publishing intoplaybooks/tasks/provision_artifacts.yml. - Added
hypervisorsurvey variable to the Provision VM job template (defaultocpvirt). Renamed play-level vars fromocpvirt_*toprovision_*for the shared ones (AAP connection, inventory, groups, request timeout). - No behavior change when
hypervisor=ocpvirt(the default).
Added -- Companion documentation repo (#285)
- Created sales.demos-docs with GitHub Pages (MkDocs Material) for talk tracks, run sheets, architecture guides, and design plans.
- Docs site live at ericcames.github.io/sales.demos-docs.
- Updated README to link to the docs site for talk tracks.
- Marketing content (#252) will live in Google Drive, not in either GitHub repo.
Fixed -- Grafana Cloud dashboard panel refinements (#275)
- License Expiry panel:
awx_license_expiryis a TTL (seconds remaining), not an epoch timestamp. Wrapped with(time() + metric) * 1000so thedateTimeFromNowunit renders correctly (e.g. "in 3 months"). - Nodes Ready panel: added a second query for total node count and switched
to
textMode: "value_and_name"so the panel shows "3 Ready, 3 Total" instead of just "3".
Changed -- Grafana Cloud dashboard query improvements (#275)
- Simplified
clustervariable matching from regex to exact match across all panels — the variable is single-value, socluster="$cluster"is clearer and cheaper thancluster=~"${cluster:regex}". - Scoped the Series Budget panel to the selected cluster instead of counting all series globally.
- Wired the
namespacemulti-select variable into VM and pod panels (VMs Running, Pods Running, VM Status, VM CPU/Memory/Network I/O) so the namespace dropdown filters them. - Changed health-check panels (AAP Controller, Alloy Federation) from
max(up{...})tomin(up{...})so a DOWN target is never masked by healthy ones. - Replaced hardcoded
[5m]with$__rate_intervalin allrate()calls for automatic window sizing.
Added -- Grafana Cloud Phase 2/3: dashboard as code (#275)
playbooks/files/grafana/cluster-health.jsondefines a "Cluster Health" dashboard covering five sections: overview stat panels, cluster nodes (CPU/memory/filesystem/network), KubeVirt VMs (status table, per-VM CPU/memory/network), AAP platform (capacity, jobs, hosts, pod resources), and Loki logs with namespace filtering.playbooks/deploy_dashboard.ymlpushes the dashboard to Grafana Cloud via the HTTP API. Creates a "Sales Demos" folder, idempotent re-runs.sales-demos-dashboardskill with preflight checks and MCP verification.grafana_cloud_editor_sa_tokenadded tosecrets.yml.example— a separate Editor-role service account so MCP reads (Viewer) and Ansible writes (Editor) stay on separate tokens.clustertemplate variable lets one dashboard serve both sandbox and demo.
Added -- Grafana Cloud Phase 1: deploy Alloy for metrics and logs (#265)
playbooks/deploy_alloy.ymldeploys Grafana Alloy as a DaemonSet in thegrafana-alloynamespace, pushing metrics and logs to Grafana Cloud. Three data paths: Prometheus federation from the cluster's Prometheus endpoint (prometheus-k8s, KubeVirt VM metrics, node health, pod/container usage), AAP controller/api/controller/v2/metrics/scrape via the gateway with basic auth, and Kubernetes API log streaming for four namespaces (aap,sales-demos-<env>,openshift-cnv,grafana-alloy).-e alloy_state=absentreverses the deployment, removing the namespace and all cluster-scoped RBAC resources.sales-demos-alloyskill with preflight checks and Grafana MCP verification steps.- Five push credential keys added to
secrets.yml.example:grafana_cloud_prom_push_url,grafana_cloud_prom_username,grafana_cloud_loki_push_url,grafana_cloud_loki_username,grafana_cloud_push_api_key. Separate from the Viewer SA token used for MCP reads. - Series budget: ~2,098 (measured 2026-09-06) of the 10k free-tier limit via
match[]federation selectors and relabel rules that drop noisy CPU modes and pause containers.
Fixed -- Alloy RBAC, AAP metrics endpoint, and federation target (#273)
- Added
pods/logsubresource to thealloy-discoveryClusterRole soloki.source.kubernetescan stream pod logs via the Kubernetes API. - Switched AAP metrics scrape from the controller service
(
aap-controller-service:80) to the gateway (aap.aap.svc:80). On AAP 2.7 authentication routes through the gateway; the controller service returns 401 on direct basic-auth requests. - Changed Prometheus federation target from
thanos-queriertoprometheus-k8s. Thanos Query does not implement the/federateendpoint.
Changed -- Worktrees mandatory for code changes (#267)
- Strengthened CLAUDE.md worktree rule from conditional ("use when multiple
sessions run") to unconditional ("always use a worktree for code changes").
The main checkout stays on
mainas a read-only home base. The conditional rule failed in practice — sessions assumed they were alone until another one switched the branch underneath them.
Added -- Grafana Cloud Phase 1 design (#265)
- Expanded
docs/plan/grafana-plan.mdwith the Phase 1 architecture: Grafana Alloy on OCP, Prometheus federation from built-in monitoring, AAP/api/v2/metrics/scrape, API-based log collection for four namespaces. - Series budget strategy (~600–900 of 10k), push credential requirements, risk mitigations, and verification steps documented.
Changed -- MCP demo docs updated for five servers (#262)
- Updated
server-inventory.md,architecture.md,talk-track.md,run-sheet.md,README.md, andobjections.mdto reflect Grafana Cloud as the fifth MCP server (81 tools, Viewer SA, stdio viauvx). - Added Grafana tool categories, credential flow, verification, and troubleshooting to the reference docs.
- Added "What about observability?" objection/answer.
- Demo narrative unchanged — still focused on the OpenShift + AAP governance story; Grafana appears in counts and reference tables only.
Added -- Grafana Cloud MCP server (#260)
- Official
grafana/mcp-grafanaserver wired up via--scope local, following the AAP MCP server pattern. One server namedgrafana(not per-environment — Grafana Cloud is a single external instance). utilities/make-grafana-mcp.shreads vault credentials and registers the server with Claude Code.docs/plan/grafana-plan.mddocuments the design and future phases.- Vault keys
grafana_cloud_urlandgrafana_cloud_sa_tokenadded tosecrets.yml.example. .claude/settings.jsonallowlistsmcp__grafana__*.sales-demos-mcpskill updated to include Grafana as a fifth server.
Fixed -- AAP cannot WinRM into Windows VMs (#257)
- Added
LocalAccountTokenFilterPolicy = 1as a secondFirstLogonCommandsstep in the sysprep answer file. Without it, Windows UAC remote access restrictions reject NTLM authentication for non-built-in Administrator accounts likedemoadmin, even though the password is correct. - Added "Sales Demos - Windows Machine" credential to CaC — Machine type,
username
demoadmin, password fromlinux_admin_password.
Fixed -- Windows clone prompts for password change on built-in Administrator (#255)
- Added
<AdministratorPassword>to the sysprep answer file to set the built-in Administrator's password and suppress the "must change password at first logon" prompt. The<LocalAccount>alone createsdemoadminbut leaves the built-in account with an expired password. - Added
<AutoLogon>fordemoadminwith<LogonCount>1</LogonCount>so the first login happens automatically andFirstLogonCommands(WinRM HTTPS listener setup) execute without requiring a manual VNC login.
Fixed -- Windows clone sysprep answer file never applied (#234)
- Renamed the Secret key from
autounattend.xmltoUnattend.xmlinterraform/ocpvirt/main.tf. After sysprep, Windows mini-setup searches forUnattend.xmlon removable media — notAutounattend.xml(which is only searched during a fresh install from media). Two bugs stacked: the producer cached its own answer file in%WINDIR%\Panther(fixed byimage.builder.pipelinePR #71), and the consumer named the file wrong. With the cache gone, the filename mismatch became the remaining failure. - Shortened the Windows
ComputerNameto ≤15 characters (sd-win-sm-1c-2g,sd-win-md-1c-4g,sd-win-lg-2c-6g) via atier_windows_hostnamemap inlocals.tf. The full VM name (sd-win-small-1cpu-2gb, 21 chars) exceeded the Windows NetBIOS limit and caused the specialize pass to reject the answer file outright. quay_windows_imageupdated to20260906-0300, built with the producer fix.
Diagnostic trail — three stacked bugs, each masking the next:
1. Producer cached its answer file in %WINDIR%\Panther (precedence 3),
preventing Windows from ever reaching the consumer's CD (precedence 5).
Fixed by image.builder.pipeline PR #71.
2. With the cache gone, Windows still stopped at the OOBE region screen
(
)
because the Secret key was autounattend.xml — the fresh-install name.
After sysprep, Windows searches for Unattend.xml.
3. With the key renamed, Windows found the file on D:\ for the first time
and rejected it: "The answer file is invalid" for the specialize pass
(
).
The ComputerName exceeded the 15-character NetBIOS limit.
Proven end-to-end: clone reaches the Windows lock screen with no manual
intervention
(
).
Lesson: do not kill the virt-launcher pod mid-OOBE. Deleting the pod to
force a Secret refresh corrupted the OOBE state on the root disk
(
),
requiring a full destroy-and-reprovision. Restart the VM via the OpenShift
console or wait for the provision_vm.yml playbook to converge.
Changed -- single copy-paste SSH command in Check VMs output (#218)
- The Check VMs job output now shows one command an SE can paste directly into
a terminal:
virtctl ssh --kubeconfig ~/.kube/<env>.kubeconfig -o StrictHostKeyChecking=accept-new -n <ns> cloud-user@vm/<name>. The previous two-stepoc login/virtctl sshpair required interactive browser auth. utilities/make-kubeconfig.shnow writes to~/.kube/<env>.kubeconfigin addition to the repo-local.kube/copy (used by the MCP servers). The~/.kube/path is what the--kubeconfigflag references so the command works from any directory.- Removed
openshift_api_urlfrom VM host variables in AAP — its only consumer was theoc loginline.
Fixed -- clean up stale SSH host keys when VMs are rebuilt (#216)
terraform/ocpvirt/outputs.tfssh_commandnow includes-o StrictHostKeyChecking=accept-new, so the first connect after a rebuild auto-accepts the new key instead of erroring. UnlikeStrictHostKeyChecking=no,accept-newstill warns on a genuine man-in-the-middle change on subsequent connects.playbooks/teardown.ymlrunsssh-keygen -Rfor each destroyed VM's virtctl hostname (vm.<vm-name>.<namespace>) afterterraform destroysucceeds, proactively removing entries already cached from a previous build. From an AAP execution environment this is a harmless no-op — the EE has no persistentknown_hosts.
Added -- say that this is the directory to start Claude in (#230)
.mcp.jsondefinesopenshift-sandbox(kubernetes-mcp-server, toolsetscore,config,kubevirt, read-write) andopenshift-demo(read-only), and both are project-scoped -- they load only when Claude Code starts here.CLAUDE.mdnever said so. The producer repoimage.builder.pipelinehas no MCP servers at all, so a session started there gets no cluster tools; that is what happened while the Windows sysprep bug was being fixed, and it was noticed only after the work was underway. A session started here cancdinto that repo and run its playbooks anyway, so the advantage runs one way only.- Recorded the half that makes the note honest: MCP does not supply that
repo's credentials. Its Windows playbooks read
K8S_AUTH_HOSTandK8S_AUTH_API_KEYfrom the environment and assert them non-empty, and those -- withWINDOWS_ADMIN_PASSWORD-- are maintained here, whichimage.builder.pipeline/docs/design.md4.1 now records. Without that, the note would read as "start there and the credentials sort themselves out". - A pointer to
image.builder.pipeline#63, the handoff for the sysprep defect this repo diagnosed in #228. The producer fix is merged and unverified -- no rebuild has happened. This repo is where it gets proved, and the path is already built: repointquay_windows_imageininventory/group_vars/<env>/connection.yml, re-runlink_windows_image.yml, clone.CLAUDE.mdis the file a new session loads automatically, so the pointer belongs there and nowhere else.
Changed -- document the measured Windows boot state (#228)
docs/demos/openshift-virtualization/architecture.mdandrun-sheet.mdboth said Windows "does not boot yet -- what is missing is the image". The image has been published and linked since #220, and the whole path was measured on sandbox on 2026-09-05. Rewritten to the measured state.- Recorded the numbers, because nobody had them before: the 60 GiB Windows disk
clones from the
win2k22DataSource in under 60 seconds via the Ceph RBD CSI smart-clone path, and the VM isRunningabout 40 seconds later. That is a demo-worthy number and the run-sheet now says to show it. - Recorded why the guest still cannot be logged into. The sysprep unattend added
in #201 is attached correctly and ignored anyway: Windows finds the answer file
the build cached in
%WINDIR%\Panther(search-order 3) before the sysprep CD-ROM (search-order 5). Fix belongs to the producer --ericcames/image.builder.pipeline#59-- and nothing here changes. - Added the same warning as a comment on the sysprep Secret in
terraform/ocpvirt/main.tf, where it reads as working code. It notes explicitly that renaming the key tounattend.xmlis the obvious-looking fix and is wrong -- rows 4 and 5 of Microsoft's search order wantAutounattend.xmlfor every configuration pass, not justwindowsPE. docs/plan/ocpvirt-demo-plan.mdgains a "Phase 3 Windows: measured" section carrying the numbers, the precedence table, and the two traps that cost time. It also records a gap the test exposed: there is no Windows configure path at all --windemois referenced by zero playbooks and zero job templates.
Added -- set the Windows password on the clone via a sysprep unattend (#201)
terraform/ocpvirt/main.tfnow builds akubernetes_secretholding anautounattend.xmland attaches it to the Windows VM as asysprepvolume. The golden image is published generalized, so a clone runs the OOBE specialize pass; without an answer file it stopped at the region-select screen and the built-in Administrator password was the random one the build generated and discarded. The disk clone itself always worked -- measured on sandbox, the 60 GiB DataVolume reachedSucceededin under 60 seconds.- The unattend sets the ComputerName, creates the local administrator named by
windows_admin_username(defaultdemoadmin), skips the OOBE screens, and re-creates the WinRM HTTPS listener on 5986. That last step is not redundant with the identical step in the image build:sysprep /generalizestrips the listener certificate, which was issued to the pre-sysprep computer name. - New
windows_admin_passwordTerraform variable, fed byprovision_vm.ymlfrom this environment'slinux_admin_password. The password was previously baked into an image both environments pull, so it had to be one global value and could not be per-environment; moving it onto the clone fixes that, and reusing the Linux value means one password to remember per environment. - The Windows VM's
domain.devices.disksis now spelled out. KubeVirt auto-attaches a disk for any volume without one -- measured, thewindows.2k22preference producedrootdiskon the sata bus from an emptydevices-- but auto-attach produces adisk, and Windows reads its answer file from removable media, so the sysprep volume must be acdrom. - Removed
windows_admin_passwordfromsecrets.yml.exampleand emptiedSTAGEDinutilities/check-secrets-example.py. It was declared and read by nothing; the image now bakes in a throwaway, so it should not be a vault key. - Watch the length.
linux_admin_passwordis 8 characters in both environments. That is accepted today, but CIS L1 for Windows Server 2022 mandates a 14-character minimum and will reject it once the hardened Windows image lands. Lengthen both before that, not after.
Changed -- document Phase 2 validated state in the plan doc (#225)
- Updated
docs/plan/ocpvirt-demo-plan.mdconsumer-half steps to match what actually works: Opaque secret (not dockerconfigjson), explicit DataVolume import trigger (not DataImportCron alone). - Added "Phase 2: validated" section recording the three stacked bugs (#222, #224), the CDI 4.20 private-registry limitation, and the measured import time (~5 min vs. estimated 80 min).
Fixed -- pull secret format and import path for private registries (#222, #224)
- CDI's importer pod expects
accessKeyId/secretKeykeys in an Opaque secret, not akubernetes.io/dockerconfigjsonsecret. The old format producedCreateContainerConfigError: couldn't find key accessKeyId. - CDI 4.20's DataImportCron controller cannot authenticate to a private registry for its digest check — it silently reports "No source digest" and never creates a DataVolume. The playbook now creates a DataVolume explicitly as the import trigger, then updates the DataSource to point at the resulting PVC. The DataImportCron template stays in HCO for future CDI versions.
- Supersedes the earlier dict-key fix (#222); that bug was real but the corrected dockerconfigjson was still the wrong secret type for CDI.
Changed -- point quay_windows_image at the published golden image (#220)
- Both environments'
connection.ymlnow reference the real containerdisk (quay.io/zigfreed/win2k22-golden:20260905-1826) instead of a placeholder. The repo is private, soquay_usernameandquay_passwordmust be set in the vault before runninglink_windows_image.yml. - Updated the stale "NOT YET CONSUMED" comment in
secrets.yml.example— the quay credentials have been consumed since #3 merged.
Added -- compliance report URL in the MOTD banner (#217)
- The SSH login banner now shows the CIS L1 compliance report URL alongside the Demo page and Console URLs, so an SE lands on the VM and sees all three links immediately.
Changed -- Check VMs output: copy-paste SSH access for SEs (#212, #214)
- The Check VMs job now shows
oc login <api_url>thenvirtctl ssh ...as two copy-pasteable commands that work from any directory. Droppedmake-kubeconfig.sh(requires repo root + vault access) andsummary.json(machine-readable, not what an SE clicks). Added Cockpit URL back. provision_vm.ymlforwardsenv_nameandopenshift_api_urlas host vars socheck_vm.ymlcan reference them.
Fixed -- compliance scan fails without root (#210)
- Added
become: truetocompliance_scan.yml— the dnf install and file ownership tasks require root, matchingconfigure_vm.yml.
Added -- consume the RHEL 9 CIS L1 golden image from image.builder.pipeline (#202)
playbooks/link_rhel9_image.ymlcreates a DataImportCron for the CIS-hardened RHEL 9 containerdisk (quay.io/zigfreed/rhel9-cis-l1-golden), following the same pattern as the Windows image link (#3). DataSourcerhel9-cis-l1inopenshift-virtualization-os-images— the stockrhel9stays untouched.- Integrated into
setup.ymlas stage 2/6, after CNV install. Skippable with-e link_rhel9_image=false. linux_datasource_namedefault changed fromrhel9torhel9-cis-l1interraform/ocpvirt/variables.tf— every new Linux demo VM boots from the hardened golden image.prepare_env.ymlupdated to validaterhel9-cis-l1by default.
Added -- OpenSCAP CIS Level 1 compliance scan (#202)
- New
playbooks/roles/linux_compliance/role installsopenscap-scannerandscap-security-guideon the guest, runsoscap xccdf evalwith the CIS L1 Server profile, and publishes an HTML report at<web_url>/compliance/plus a machine-readablesummary.jsonwith pass/fail counts and score. - New
playbooks/compliance_scan.ymlplaybook, targetinglinuxweb. - New "Sales Demos - Compliance Scan" job template.
- Added to the Build Demo VM workflow between configure and check, so the full chain is: provision → register → configure → compliance → check.
- The demo page's golden image card now links to the compliance report.
Added -- golden image provenance on the demo page (#202)
- The demo page now shows a Golden Image card with the image source, build
date (parsed from the containerdisk tag), CIS profile level, and a link to
the image.builder.pipeline repository. The card renders only when
golden_image_sourceis set, so VMs booted from the stockrhel9image show no change. facts.jsoncarries the same fields undergolden_image.provision_vm.ymlforwardsgolden_image_sourceandgolden_image_cis_levelas host vars on the AAP inventory host, following the same pattern asweb_urlandssh_command.
Changed -- drop pull secret from RHEL 9 image link (#208)
- The
quay.io/zigfreed/rhel9-cis-l1-goldenrepository is now public, solink_rhel9_image.ymlno longer creates or references a pull secret. CDI pulls the image without credentials. The Windows image still needs one.
Added -- clickable links to the related repositories (#203)
- A Related repositories table in
README.mdlinking image.builder.pipeline and rego_policy_libraries, saying what each is and which way the dependency runs. There was no such section at all, which mattered because the Windows work is split across two repos and someone landing in either could not see the other half. - States the contract explicitly: the consumer half is #3 here, the producer
half is
image.builder.pipeline#24, and the only thing binding them is one string -- a containerdisk tag inquay_windows_image. - Scope is navigation, not dependency. The section says so, because
CLAUDE.md's "this repo is self-contained" rule still stands: links so a reader can find the other half, never instructions to go run something there.
Changed -- the Windows image producer now lives in the factory repo (#199)
sales.demos#193was transferred toimage.builder.pipeline#24. #3 split producer/consumer and deliberately deferred the producer's permanent home; that deferral is now resolved in favour of the image factory.- The deferral cost nothing, which was the point. The contract between the halves is one string -- a containerdisk tag in a private quay repo -- so moving the producer changed no code here, only references.
- Why the factory won: that repo's
CLAUDE.mdalready states "producer/consumer across repos is intentional -- different audiences, different lifecycles"; its README and ROADMAP already claimed Windows Server 2022 / CIS L1 as Phase 3; and its PR #23 has rewritten that Phase 3 to ship Windows as a CIS-hardened containerDisk on Quay.io, naming both halves. Choosing a hardened image pulled that way regardless -- hardening plus compliance evidence is that repo's purpose, not this one's. - 24 references across the skills, README, ROADMAP, plan doc, talk-track docs,
terraform comments and
link_windows_image.ymlnow sayericcames/image.builder.pipeline#24explicitly. GitHub redirects a transferred issue so nothing was broken, but a bare#193read as though the work were tracked here and silently landed the reader in another repository.
Fixed -- the branch-cleanup note assumed merge commits; squash breaks both its claims (#197)
CLAUDE.mdsaid pullingmainfirst makesgit branch -d"check something real", and offeredgit branch --merged mainas the way to find leftovers. Both are true under a merge commit and false under a squash, and squash is enabled and in use here: measured 2026-09-04,squash=true merge=true rebase=true, and the last three merges onmainwere squash, squash, merge-commit.- A squash puts a new commit on
mainwith the same tree, so the branch tip never becomes an ancestor. Recreating one of each as a local branch:git branch --merged mainlisted the merge-commit branch and not the squashed one, andgit branch -don the squashed one failed outright with "not fully merged". - The consequence worth knowing is the silent one.
--merged mainis offered as the leftover-finder and cannot see a squash-merged branch at all -- exactly the leftovers it looks like it is catching. Replaced with a finder that works under both, and verified against a planted leftover. - After a squash,
-dstill deletes the branch on its upstream tracking ref and prints "merged to refs/remotes/origin/..., but not yet merged to HEAD". That warning is expected and means nothing -- the note now says so, because reading it as "the PR did not merge" is the obvious wrong inference. - The rest of the note stands:
-dreally is asking "have you pushed?", and it still beats-Dbecause it refuses to drop unpushed work.
Added -- CNV now points at a published Windows golden image (#3)
playbooks/link_windows_image.ymland theocpvirt-windows-imageskill: a pull secret for the private quay repository plus aDataImportCrontemplate on the HyperConverged CR, so CDI imports the Windows containerdisk and keeps thewin2k22DataSource populated. Terraform has cloned that DataSource since Phase 1 and has been blocked on it ever since.-e windows_image_link_state=absentreverses it, removing the cron template and the secret. Shipped in the same change, because the thing it touches is a cluster-wide boot source.- The playbook refuses to link a placeholder.
quay_windows_imageis stillquay.io/<user>/windows2k22-golden:<date>in both environments, and a cron pointed at a nonexistent repository fails in an importer pod rather than at link time -- an error that reads as "CDI is broken", not "there is no image".
Changed -- #3 is now the consumer half only; building the image is ericcames/image.builder.pipeline#24
- Split producer/consumer. Consumption is small, owned by this repo, and
correct no matter who builds the image; the contract between the halves is one
string, a containerdisk tag in a private quay repo. Production is written
portably so its permanent home -- here, or
image.builder.pipelinePhase 3, which already lists Windows Server 2022 / CIS L1 -- could be decided later. It was decided: the factory. See the entry above. - The consumer half can be proven with a throwaway plain image; the hardened one replaces it at a new tag with no code change here.
Changed -- a DataImportCron, not a hand-created PVC (#3)
- #3 originally said "snapshot the disk to a
DataSourcenamedwindows2k22-golden". That is not how boot sources are kept on a cluster, and the cluster is the proof. Measured on sandbox, CNV 4.20.24:HyperConverged.status.dataImportCronTemplatescarries six entries -- fedora, centos-stream 9/10, rhel 8/9/10 -- each withmanagedDataSource,garbageCollect: Outdatedand a registry source. Windows is absent only because Red Hat cannot redistribute the media. - A hand-created PVC is a one-shot artifact with no refresh path. A cron makes a fresh RHDP environment a config step instead of a data-movement one.
- Taking over the SSP placeholder is the designed handoff, not a fight.
win2k22isssp-operator-owned withspec.source.pvcandReady=False"PVC not found";rhel9iscdi-controller-owned, labelleddataImportCron: rhel9-image-cron, withspec.source.snapshotandReady=True. The cron takes the placeholder over and rewritessourcefrompvctosnapshot. Fallback if it ever misbehaves: pointwin_managed_datasourceandwindows_datasource_nameat a name SSP does not own -- one line each. - The six built-in templates are not at risk.
spec.dataImportCronTemplatesis empty on a stock cluster; the common ones live in HCO and appear only instatus, flaggedcommonTemplate: true.
Fixed -- the WinRM port has never matched itself (#3)
terraform/ocpvirt/main.tfpublished 5985 on the Windows Service whileplaybooks/provision_vm.ymlregistered thewindemogroup withansible_port: 5986andansible_winrm_server_cert_validation: ignore. Both cannot be right, and the mismatch survived because no Windows guest had ever booted to exercise it. Settled on 5986 (HTTPS), which is what the cert-validation setting already implied; ericcames/image.builder.pipeline#24 configures the guest to match.
Fixed -- provisioning warned about Windows unconditionally (#3)
provision_vm.ymlprinted "the Windows golden image (#3) is not built yet" on everywindows/bothrun regardless of cluster state -- true while #3 was open, misleading afterwards. It now asks the cluster: it reads the DataSource and warns only when it is genuinely not Ready.- It still warns rather than refuses, deliberately. Terraform creates the VM
either way,
os_type=bothstill yields a working Linux guest, and linking the image a minute later fixes it without re-provisioning.
Fixed -- the plan doc named a tier that has never existed
docs/plan/ocpvirt-demo-plan.md's verification step 3 saidlarge-2cpu-8gb. The tier islarge-2cpu-6gb; the 8 GiB variant was considered and rejected in #2 becauseos_type=bothat 8 GiB never schedules.
Added -- CLAUDE.md records that this working tree is shared by concurrent sessions (#194)
- More than one Claude session works in this checkout at a time, and the branch
can change under you mid-task. Nothing said so, so every session assumed it was
alone. On 2026-09-04 a commit landed on another session's branch because that
session had merged two PRs and checked out its own branch between the
git checkout -band the commit. - The rule is to re-run
git branch --show-currentimmediately beforegit addandgit commit, not once at the start; the rest follows from it. Also: explicit paths overgit add -A,gh pr create --head <branch>, andgit show --stat <sha>afterwards. - Recovery is
git branch -f <your-branch> <sha>, which touches nothing else. Force-pushing a branch another session has pushed is explicitly ruled out -- that is theirs to fix. - Recorded in
CLAUDE.mdrather than a doc page for the same reason as thedelete_branch_on_mergeand branch-protection notes beside it: it is a property of how the repo is worked in, invisible from reading the tree, andCLAUDE.mdis the only file every session loads automatically.
Added -- Development workflow skill (#191)
sales-demos-dev-workflowskill documenting the end-to-end dev/test cycle: branch, PR, merge,config.yml --limit <env>, then launch the Build Demo VM workflow. Captures the gotchas that cost time when context resets between sessions.
Added -- Browser terminal via Cockpit Route (#63)
- Cockpit (RHEL web console) exposed through an OpenShift Route for
customer-facing browser terminal access without
virtctl. - New Terraform resources:
kubernetes_service.linux_cockpiton port 9090 andkubernetes_manifest.linux_cockpit_routewith edge TLS. cockpit.confwritten at first boot via cloud-initwrite_files, withAllowUnencrypted = true(the Route terminates TLS) and the Route's Origin (Cockpit validates the websocket Origin header).linux_admin_passwordwired throughenv_secrets,connection.yml, the "Sales Demos - Env Secrets" credential type, andprovision_vm.ymlinto Terraform cloud-init.cockpit_urladded as Terraform output, AAP host variable, and MOTD line.
Changed -- Use the stock Red Hat Quay.io registry instead of creating our own (#186)
- Removed
hub_ee_registries_allfromhub_ee_registries.yml. The stockRed Hat Quay.ioregistry, provisioned by the installer on every RHDP environment, already points athttps://quay.io— creating a secondquay_ioentry was duplication. hub_ee_repositories.ymlnow referencesregistry: Red Hat Quay.iodirectly instead ofregistry: quay_io.- Remote Registries in the Hub UI shows two entries (the stock pair) instead of three.
Added -- AAP project syncs now resolve from Private Automation Hub (#69)
playbooks/link_hub.ymland thepah-link-aapskill: aSales Demos - PAH Galaxycredential aimed at the curatedapprovedrepository, assigned to the organization, so a project sync installscollections/requirements.ymlfrom the hub with no internet egress.#68populated the hub and#70curated it; until now nothing pointed at it.-e hub_galaxy_link_state=absentreverses it, deleting the assignment, the credential and the token. Built and proven in the same change, because the failure mode this carries is "every job template in the organization stops working" and the fix for that has to be quick and known-good.- The playbook refuses to link an empty
approved. A Galaxy credential over an empty repository does not fail at link time, it fails in every subsequent project sync with an error that names a collection rather than the cause. approvedonly -- not the three mirrors as ordered fallbacks. #69 asked for one credential per distribution; pointing at a mirror "just in case" gives up the only claim this use case makes, that your teams install what you approved.
Fixed -- the curated set was the pin list, not the dependency closure (#69)
-
approvedheld nine collections and a project sync needed ten. An AAP project sync runsansible-galaxy collection install -r collections/requirements.yml, which resolves transitive dependencies. The first real run against a linked organization failed:ERROR! Failed to resolve the requested dependencies map. Could not satisfy the following requirements: * ansible.eda:>=2.5.0 (dependency of infra.aap_configuration:4.7.0)
-
refresh-hub-requirements.py --write-approvednow computes the closure and refuses to write a set missing a dependency rather than emitting one that looks complete. Dependencies are curated at their sync-window floor, the oldest version the hub is guaranteed to hold, and the entry carries a comment saying what pulled it in. --audit-pinscould not have caught this, and that is the lesson worth keeping: it checks the collections this repo names against the version window. Only the resolver knows what they depend on.link_hub.ymltherefore ends with a real project sync rather than an object-existence check.
Fixed -- #69's gate 3 named the wrong token, and the wrong problem
- It is a gateway token, not a PAH API token.
POST /api/galaxy/v3/auth/token/exists on AAP 2.7 and issues a real 40-character token; the gateway rejects it. Measured against the hub's collection index: basic auth200, galaxy_ng token403under bothTokenandBearer, gateway token200under either. The resulting project-sync failure reads403 ... Authentication credentials were not provided, which looks like a credential that was never attached rather than one that was rejected. - The mint is
POST /api/gateway/v1/tokens/atreadscope -- the same endpointutilities/make-aap-mcp.shalready uses, andreadwas verified sufficient before it was chosen. - Gateway tokens accumulate, unlike the galaxy_ng endpoint which resets, so the playbook retires the tokens it minted on earlier runs before minting a fresh one. Verified: a second run left exactly one.
- The token is minted, never stored. #69 called it "per-user and
short-lived" and asked for a rotation story.
GALAXY_TOKEN_EXPIRATIONisnull-- there is no time-based expiry, and that phrase is inherited from Red Hat's cloud Automation Hub. It does not help: the token dies with the RHDP environment, so the cadence was never 30 days but every rebuild. It comes fromaap_username/aap_password, which already rotate with the environment. - Anonymous access was considered and rejected.
GALAXY_ENABLE_UNAUTHENTICATED_COLLECTION_ACCESSand_DOWNLOADare settable and the credential type requires onlyurl, so a tokenless credential would work with nothing to mint. Anything reaching the route would then read the whole hub -- defensible on an ephemeral demo cluster, a poor pattern to demonstrate to a customer who will copy it. CLAUDE.md's token-cleanup exception rewritten: it used to rest partly on "no playbook creates it", which this makes untrue. The exception is now stated as what it always was -- a token that is the deliverable, as opposed to one created incidentally -- and the unlink path is what keeps it narrow.
Changed -- documentation catching up to a shipped #69
docs/plan/pah-plan.md: replaced the stale "Two pinned collections are already outside the window" section, which contradicted the curated-repository section 40 lines above it, with what the six gates actually turned out to be.objections.mdanswers "So AAP now installs its collections from your hub?" with yes, and tells the three-ways-incomplete story, because the way the gap was found is a better answer than the yes.talk-track.md,ROADMAP.md,README.md,playbooks/setup.ymland thepah-syncskill updated.setup.yml's comment still explains why linking is not a setup stage: setup must leave an environment whose job templates work, and this is the one change that can stop them working.
Verified on sandbox, 2026-09-04
--checkverifies the curated repository and mints nothing.- First link green; the
Sales Demosproject sync succeeds againstapproved. - Second run retires one token and mints one, leaving exactly one.
- #69's gate 2 in its literal wording, inside the EE via podman:
ansible-galaxy collection install -r collections/requirements.yml -s .../content/approved/downloads every artifact from/content/approved/. Sales Demos - Provision VMbuilds a real VM (changed=7) andSales Demos - Teardown VMsdestroys it, both green, with the project's collections now coming from PAH.- Unlink restores the pre-#69 state and the project sync stays green.
demois deliberately not linked. That is a separate PR, after sandbox has been stable across a real demo.
Fixed -- hub_ee_registries.yml describes our registry in isolation (#181)
- Clarified that of the three dispatched roles, only
hub_ee_registrydoes work here —hub_ee_registry_indexandhub_ee_registry_syncdefault to false deliberately, so "Never synced" on the registry in the Hub UI is expected. - Pointed at
hub_ee_repositories.ymlas the object that actually mirrors the image, with measured evidence of a completed sync. - Named the two stock registries (
Red Hat Ecosystem Catalog,Red Hat Quay.io) and explained why we create our ownquay_ioentry rather than reusing the stock one that covers the same endpoint. - Qualified the name-rule claim: alphanumerics and underscores only applies to API-created objects; stock registries provisioned by the installer are exempt.
Added -- sales-demos-talk-track skill (#182, closes #62)
- New laptop-only skill to scaffold or verify a use-case directory under
docs/demos/. Two modes: scaffold copies_template/and sets up placeholders; verify checks the five required files, discovers renderable artifacts, runs the renderer when applicable, visually inspects the output, and validates the "Where the words come from" source table. - Issue #62's gate condition — write a second use case before building the skill — was met: three use cases now exist (OpenShift Virtualization, Private Automation Hub, MCP Servers).
Fixed -- -d is not a merge check, and CLAUDE.md said it was (#179)
- #177 corrected the branch-cleanup note and introduced a new overstatement in
the same breath: "
-drefuses a branch that is not actually merged." - Disproved while shipping the PR that introduced it. Deleting
docs-177-local-branch-cleanupbefore #178 merged,-dallowed it:warning: deleting branch '...' that has been merged to 'refs/remotes/origin/...', but not yet merged to HEAD. git branch --helpsays why: "The branch must be fully merged in its upstream branch, or in HEAD if no upstream was set."git push -usets an upstream, so every branch in this workflow has one ---dis asking "have you pushed?", not "did the PR merge?"- The command does not change, and the correction is what explains why it
works.
git checkout main && git pull && git branch -d <branch>is right because the pull puts the merge inmainfirst, so the branch is merged on both criteria. That ordering was already correct and had been presented as incidental; it is load-bearing. Run-dbefore the pull and it waves through a branch whose PR never merged. -dstill beats-D: it refuses to drop unpushed work, which is the loss that actually matters. Nothing was lost in the incident -- the commit was safe onoriginand the merge picked it up. The defect was the claim sitting in the standards file where it would be trusted.- Docs only, one paragraph. CLAUDE.md remains the single place this is stated.
Fixed -- "no manual pruning is needed" was true of the remote only (#177)
- CLAUDE.md recorded
delete_branch_on_mergeand concluded that a merged PR cleans up after itself. It cleans uporigin/<branch>. The local branch in the working clone survives the merge untouched -- observed straight after #176 merged, withorigin/fix-173-...gone and the local copy still sitting there. - The wrong half was the reassuring half: the note told you no pruning was
needed, so leftovers accumulate silently and nobody looks. Now it says which
half is automatic and gives the sweep --
git checkout main && git pull && git branch -d <branch>, withgit branch --merged mainto catch any missed. -d, never-D, written down deliberately:-drefuses a branch that is not actually merged, which is what makes the sweep safe to run without reading the branch list first.- Docs only. CLAUDE.md is the single place this was stated -- CONTRIBUTING.md and README.md do not mention branch cleanup at all, so there was no second copy to correct.
Fixed -- validate.yml had never run a single check-mode code path (#173)
- It failed inside the EE at
infra.aap_configuration.gateway_organizationswith "check mode and async cannot be used on same task" while passing on the laptop -- same commit, same cluster, minutes apart. The symptom was an ansible-core version gap. The cause was not. check_mode: trueon a play sets the TASK's check mode. It does not set theansible_check_modevariable, which is True only for a CLI--check. Measured both ways on core 2.16.19 and 2.18.18rc1.infra.aap_configurationwrites its check-mode handling entirely against that variable -- so for as long as this playbook has existed, every one of those branches took its non-check path, on both machines. Eleven of the roles it runs were therefore asking forasyncwhile in check mode.- The laptop forgave it and the EE did not, because core dropped the guard
that rejects the combination in 2.17.0 -- present through 2.16.19, absent
from 2.17.0 onward, established by unpacking the 2.16.19, 2.17.0, 2.17.14,
2.18.0 and 2.18.18 wheels and reading
ansible/plugins/action/__init__.py, not off a release note. validate.ymlnow requires--checkand refuses without it. Asserting rather than warning: a validate run reporting success while exercising the non-check path is the wrong answer that looks right. The play keepscheck_mode: trueas well, so nothing can be applied by accident either way.- Three shapes of
asyncacross the roles dispatch runs here, three answers, and only the last costs coverage. The rule is to use the override wherever the collection provides one, because it is free, and skip a role only where it does not:
| Roles | How they set async |
Answer |
|---|---|---|
| 11 controller/gateway | ansible_check_mode \| ternary(0, 1000) |
--check. Nothing lost. |
8 hub_* |
a per-role variable descending from aap_configuration_async_timeout |
set the parent to 0 in the play. Nothing lost, and it covers hub roles added later. |
1 gateway_organizations |
flat async: 1000 -- no knob, no guard |
skip the role on core < 2.17, loudly. |
- The
gateway_organizationscase is an upstream miss rather than a design choice, and the proof is 55 lines below it in the same file: the controller task in that same role does carry the ternary (roles/gateway_organizations/tasks/main.yml, lines 26 and 81, at 4.7.0). Its skip empties the wildcard source varaap_organizations_all, not the base -- dispatch re-derives the base from everyaap_organizations_*var, so setting the base would simply be overwritten. Same shape as the #106 skip directly above it, and it says out loud what it did not validate. utilities/run-in-ee.shnow prints both ansible-core versions on every run and flags a mismatch. Every collection pin matched exactly while the laptop ran2.18.18rc1and the EE ran2.16.19;build-ee.sh's drift check was green and correct throughout, because the divergence was entirely underneath the collections. Pinned collections are not a pinned environment. A note and not a failure -- running the EE's dependency set instead of the laptop's is the whole point of the wrapper -- but never again an invisible one.- Verified in the EE against
sandbox:ok=13 changed=0 failed=1before,ok=57 changed=4 failed=0after, against a laptop run ofok=87 changed=4 failed=0. Identical task banners and identicalchangedin both; theokgap is the organizations role iterating an empty list in the EE. - #68 had already hit one instance of this same
ansible_check_modegap and guarded it locally withhub_sync_enabled: false. That override stays: it is now belt-and-braces rather than load-bearing, it costs nothing, and it is the safe direction to be wrong in.
Added -- build-ee.sh proves the published EE carries no credential (#172)
execution-environment.ymlstages~/.ansible.cfg-- which holds the rotating Red Hat offline token -- into the galaxy build stage only, so the published image gets the installed collections and not that file. #120 measured that and found it true. Nothing enforced it. Move thatADDfromprepend_galaxytoappend_final, add aCOPYfor another reason, or let a future base image ship its own/etc/ansible/ansible.cfg, and a token-bearing image would build, pass every existing check, and push to a public registry.- Same reasoning as check 2 in
utilities/check-no-secrets.sh: the mechanism keeping the secret out is verified, not trusted. Deleting it fails the build. - Four checks, none redundant, each proven against a deliberately poisoned image rather than reasoned about:
- no
/etc/ansible/ansible.cfg ansible --versionreportsconfig file = None-- catches a config Ansible loads from anywhere, including viaANSIBLE_CONFIG- no
*.cfgcarrying a[galaxy_server.*]section and a realtoken=-- catches a config that is present but inert, which check 2 cannot see. Scoped to*.cfgbecause two upstream collection READMEs document the section with atoken=<SuperSecretToken>placeholder, andinfra.aap_configurationships anansible.cfg.j2; a looser grep flags all three. - no layer
ADD/COPY-ing a config in -- catches a config added in one layer and deleted in a later one, where the merged filesystem is genuinely clean and checks 1-3 all pass. Justified by building that image and recovering the token from a 224-byte layer blob in plaintext, not by assertion. - Runs before the
--pushgate, so a leaking image cannot be published.
Added -- run playbooks inside the EE AAP actually uses (#120)
utilities/run-in-ee.shruns any playbook throughansible-navigatorinsidesales-demos-ee, the image AAP runs job templates on, instead of beside it on the laptop.ansible-playbookresolves~/.ansible/collectionsand the system python; a job template resolves what the image baked in. Two dependency sets, and only one is production. CI cannot tell them apart -- the lint gate executes nothing -- so a local run was this repo's only pre-merge verification and by default it checked the wrong one.- Everything after the playbook is passed through unchanged. Take the
ansible-playbookline out of any skill, put the wrapper in front of it, and it runs in the image -- same flags, same--vault-id, same~/path. That works because navigator runs the EE as root withHOME=/rootand bind-mounts the project at its own host path, so mounting~/secretsat/root/secretsmakes a~/-relative--vault-idresolve identically inside. Measured withansible-navigator exec --ll debug, not assumed; the mount is doubled to/home/runneras well, which is the same belt-and-braces navigator itself uses for~/.ssh. - The EE tag is read from
controller_execution_environments.yml, so the verification image cannot drift from the production one -- that drift is the defect being closed, and a second hardcoded copy would reintroduce it. The registry differs deliberately (quay rather than the PAH mirror, which needs a credential and a live environment). It fails rather than guessing if that line will not parse.EE_IMAGEoverrides, matchingbuild-ee.sh. --with-hub-tokenis opt-in and off by default, mounting~/.ansible.cfgread-only for one run. Required byconfig.yml,validate.yml,setup.yml,sync_hub.ymlandcurate_hub.yml; the wrapper refuses to start those without it. A run-time bind mount is the same single file #22 and #68 made authoritative -- not a second stored copy of a rotating credential, which is what #68 actually refused. Every other playbook runs with no token reachable in the container at all.- No committed
ansible-navigator.yml. A tracked one would put a credential directory path in a public repo, apply silently to anyone runningansible-navigatorin this directory, and become a second source of truth for the EE tag. - New skill
sales-demos-verify-ee, plus a Verify it in the EE section in each of the eight phase skills,README.md,CONTRIBUTING.mdandCLAUDE.md. Additive --ansible-playbookstays the documented everyday command.
Fixed -- an EE does not resolve the hub token to an empty string, it raises (#120)
inventory/group_vars/aap/main.ymlhad said that running inside an EE "resolves this to an empty string, which is not an error". Measured, that is wrong: theinilookup on a missing~/.ansible.cfgraisesAnsibleParserError: Invalid filename: 'None'. The quiet failure it warned about is the one that follows an empty token, not an absent file. Corrected there, inplaybooks/sync_hub.yml's header, and in the #68 assert'sfail_msg, which now names the wrapper flag instead of declaring the EE impossible.- What has not changed:
sync_hub.ymlstill cannot run as an AAP job template. AAP has no laptop to mount from (#68). This changes the laptop story only.
Found -- pinned collections are not a pinned environment (#173)
- The first serious use of the above found
validate.ymlpassing on the laptop (ok=212 failed=0) and failing in the EE (ok=13 failed=1) on the same commit and cluster, minutes apart: "check mode and async cannot be used on same task" ininfra.aap_configuration.gateway_organizations. - Every collection pin matched exactly, so
build-ee.sh's drift check was green throughout. The divergence is underneath them -- laptop ansible-core2.18.18rc1on python 3.14, EE2.16.19on python 3.12. Nothing in this repo pins or compares ansible-core. Tracked in #173; documented as a known EE-side failure rather than fixed here. - Verified working the other way too:
probe_env.ymlreturned identical figures and an identicalok=31 changed=0 failed=0recap both ways, 2026-09-04.
Fixed -- #124 guarded the wrong thing: ansible.hub CREATES a missing remote (#170)
hub_collection_remotes.ymlhad said since #68 that the file being written entirely as updates was load-bearing, because "if a remote did not already exist, these items would try to update something absent". That is false, and went unchecked for the whole life of the claim.collection_remote.py:277callscreate_or_update, andah_pulp_object.py:365-367isif self.exists: update() else: create().- Measured on sandbox rather than only read. Declaring
zz-probe-124, which did not exist, returnedchanged=True failed=Falseand the hub then listed four remotes; deleting it returned the hub to its stock three. - #116 measured correctly that the three remotes exist. The consequence it attached to that measurement was never tested, and #124 inherited it.
- Two things that broke. The
fail_msgtold the reader the file "never creates them" and sent them hunting for a creation path that already exists; and the assert blocked self-healing -- a hub missing a remote used to have it recreated on the next apply, and since #124 that hard-stopped instead. - The check stays, with a different justification. What genuinely depends on
the three being stock is the demo:
docs/demos/private-automation-hub/clickops.mdopens on the stock hub UI and edits thecommunityremote by hand. An environment missing one would apply cleanly and break the demo in front of a customer. So it is a demo-readiness gate, not a correctness one, and the message now says so -- including that the run would have succeeded. - New escape hatch
hub_require_stock_remotes(-e hub_require_stock_remotes_override=false), which restores the pre-#124 self-healing behaviour on a hub you know is not stock. Skips the API call as well as the assert. config.ymlis deliberately still unguarded, and the reasoning is now recorded in the playbook so it is not re-proposed: on the dispatch path it would prevent nothing, put a hub call and a hard failure on the main build of a fresh environment known to flap 503 while it settles, and apply a demo-readiness check to every config apply.- Corrections to
playbooks/sync_hub.yml,hub_collection_remotes.yml,docs/plan/pah-plan.md, and the #124 entry below.
Changed -- sync_hub.yml now enforces the hub-remotes premise instead of asserting it in a comment (#124)
group_vars/aap/hub_collection_remotes.ymlis written entirely as updates, on the premise thatrh-certified,validatedandcommunityalready exist on a stock Private Automation Hub. The stated reason for that mattering was wrong and is corrected below (#170).- #116 verified that by hand against the live 2.7 hub and recorded the measurement. The premise was then only a comment -- true on the day it was taken, and inherited untested by the next environment.
playbooks/sync_hub.ymlnow makes the same read-onlyGET /api/galaxy/pulp/api/v3/remotes/ansible/collection/before it applies anything, and fails with the missing names. In the playbook, not the skill, so it fails identically from both entry points --/pah-sync's preflight would enforce it on the laptop path only.- One assert over the set difference, not a loop. A looped assert stops at
the first failing item, so a hub missing two remotes would name one, get
fixed, and fail again. Verified: with two bogus remotes declared, the run
fails naming both, having applied nothing (
changed=0). - Skipped under
--check, becauseuridoes not run there and the assert would die on a missingjsonkey rather than saying anything about the hub -- the same lesson as the verification block at the foot of the file (#68). Verified skipping cleanly. ansible.builtin.uriwith the credentials the playbook already uses, notcurl -u: the password reaches the endpoint without passing through a shell variable or a process argument. No new credential path and no new file.- This is a behaviour change. A run that previously proceeded on a hub missing a remote now fails early with a sentence explaining why, instead of a dispatch error several tasks later.
Changed -- the two pieces of repo identity a fork must repoint are now variables (#132)
scm_urlincontroller_projects.ymlis the sharp one. A fork whose AAP still names this repo syncs upstream: job templates run this repo's playbooks rather than the fork's, and nothing looks wrong -- changes simply never take effect. Nowsales_demos_scm_url, following the idiomscm_branchon the next line already used.upstream_nameinhub_ee_repositories.ymlis nowsales_demos_ee_upstream. Pulls from the default namespace are public and work for anyone, so this only matters once a fork pushes its own EE -- but hardcoded, a fork silently mirrors someone else's image.- Both use
default(..., true), so an empty override falls back rather than being passed through. Verified default, overridden, and empty. EE_IMAGEinutilities/build-ee.shwas already env-overridable and is unchanged..github/CODEOWNERSis deliberately left alone -- correct for this repo, and a fork's own to rewrite.- A
### Forkingsection inREADME.mdwith the two commands, and the same facts recorded inCLAUDE.md, which mentioned none of this.
Fixed -- an unguarded copy of the repo URL that #132 did not list (#132)
render-demo-assets.pycarries its ownlinux_configure_repo_url, andcheck-renderer-fixture.pyreconciled onlylinux_configure_motd_creditsandfacts.json. So a fork could change the role default and go on rendering a demo page crediting upstream -- with CI green, which is precisely the "green tick asserts something it does not mean" failure #145 exists to stop.- The check now iterates a
MIRRORED_DEFAULTStuple instead of hardcoding one key, and handles scalars as well as lists. Proven by mutating the role default and confirming a non-zero exit and a readable diff, then restoring it.
Changed -- repointing has two correct answers, and the docs asserted only one (#166)
- #131's README section said flatly "do not edit them to repoint the repo"
and sent every reader to a
local.ymloverlay. That is wrong for anyone running from AAP: the overlay is gitignored, so it is absent from the SCM checkout a job template runs from. The caveat saying so was five paragraphs below the instruction.sales-demos-first-timestep 6 had the same defect, in the same words. - Both now lead with the choice: laptop + tracking upstream ->
local.yml; forked + running from AAP -> editconnection.ymlon your branch. Doing both is fine and they do not interfere. - The overlay's benefit is conflict avoidance, and it is now quantified
rather than asserted. Since March, ten commits have touched the two
connection.ymlfiles with eighteen edits to the three cluster-identity lines -- three of them pure repoints (#135, #101, and one in August), roughly monthly. A reuser who edits those lines conflicts on every such pull. - Made explicit that
local.ymlholds only the keys that differ, typically three lines, not a copy ofconnection.yml. That was a real misreading of the previous wording. CLAUDE.mddid not mention either mechanism #131 introduced. It now records the overlay (including why the filename is load-bearing) andSALES_DEMOS_VAULT_PASS, and states that a job-template question is never answered with the overlay.- Checked every relative link in every tracked Markdown file: none broken.
Added -- clone-and-configure: a local overlay and a real onboarding path (#131)
inventory/group_vars/<env>/local.yml, gitignored, overridesconnection.yml. #130 fixed credentials for a reuser; it did not fix connection values. A stranger who cloned this passed CI and then ran against this repo's clusters. Now they drop in one file and never diverge from upstream or conflict on a pull. Ansible loads agroup_vars/<group>/directory in sorted order and the last file wins, so no code changed.- The filename is not the one #131 proposed, and that matters. The issue
specified
connection.local.ymlon the reasoning that it sorts afterconnection.yml. It sorts before it --'l' < 'y'-- so it would have been loaded, silently overridden, and left the user pointed at the committed cluster while believing they had repointed it. Measured before building: with both files present Ansible returned theconnection.ymlvalue.local.yml('l' > 'c') wins, and is what shipped. - A
## Getting startedsection inREADME.md, which had no getting-started, prerequisites or quick-start heading anywhere. The real onboarding document issales-demos-first-time, previously discoverable only by opening the repo in Claude Code; README now links it in prose and states the three things a clone deliberately does not carry.
Changed -- the two EXECUTED vault-password paths are now overridable (#131)
- Of ~75 mentions of
~/secrets/.vault_pass_sales_demos, only two are code: thefilelookup building the AAP Vault credential, andutilities/make-kubeconfig.sh. Both now read one env var,SALES_DEMOS_VAULT_PASS, defaulting to the current path -- so one export moves both and they cannot end up disagreeing. Nothing changes for an existing setup. The ~73 documentation mentions are left alone; they are examples, and churning them is noise. sales_demos_vault_password_fileusesdefault(..., true)deliberately: an unset env var resolves to'', not Undefined, and a plaindefault()would hand the lookup an empty path. Verified unset, set, and set-but-empty.sales-demos-first-timestep 6 no longer looks for placeholders that do not exist. It told the user to checkconnection.ymlforcluster-<id>; the committed files hold real IDs, so that check could never fire. It now asks Ansible for the value in effect and points atlocal.yml.- Step 2's orientation line said one prerequisite "you cannot create yourself".
Untrue since #130 -- you choose the vault password. Step 0 gained
ocand alocal.ymlprobe; step 4.5 gainedoc, which three skills shell out to and nothing checked; step 7 gainedrhsm_ok, becauserhsm_org_idandrhsm_activation_keyare top-level rather than per-environment keys and fail late, at Phase 4 guest registration, far from their cause (#128).
Fixed -- 13 references to a secrets file that moved in #5 (#131)
group_vars/aap/secrets.ymlhas beenplaybooks/group_vars/all/secrets.ymlsince #5. Nine files still named the old path, including bothconnection.ymlheaders and the AAP Vault credential's own description -- every one of them a place a new user would look and find nothing.CHANGELOG.mdand the superseded block indocs/plan/ocpvirt-demo-plan.mdwere deliberately NOT rewritten. Both are historical records; the plan doc already marks that passage superseded, so it gained a further note recording the #5 move and the #130 untracking rather than having its history edited.- Documented in
sales-demos-mcpwhy.claude/settings.jsonallowlistsmcp__aap-sandbox__*andmcp__aap-demo__*while.mcp.jsondefines neither: the AAP servers carry bearer tokens and are registered--scope local. A fresh clone showing two entries pointing at nothing is the expected state. Neither file can say so in place -- both are strict JSON and take no comments.
Added -- how to get started with a ServiceNow MCP server, and with any MCP server that does not exist (#93)
- Two pages in
docs/demos/mcp-servers/, taking the file set from six to eight. servicenow.md-- measure/stats.dofirst, then the fork. The recommendation is ServiceNow's native MCP Server Console, once an instance reaches Zurich Patch 9+ or Australia Patch 2+ with a Now Assist SKU, because there governance stops being asserted and becomes a screen you can show. The demo instance is Yokohama (measured 2026-09-02), so that path is closed today and the page says when to re-check.- The demo does not depend on it.
servicenow.itsmis already pinned athub/certified-requirements.yml:411; a job template creates the incident, writes the work note and closes the record with no MCP server anywhere. An MCP server would add the read half. Losing it costs a beat, not the thesis -- the agent reads, Ansible writes. building-a-server.md-- the general question underneath: what to do when no server exists, which is also #94's situation with every network vendor. Four questions to decide whether to build; Go when the server ships (static binary, no interpreter -- which is exactly whykubernetes-mcp-serverruns vianpxand/sales-demos-first-timeadds only that one prerequisite), Python when you are reaching; stdio first, streamable HTTP to ship; packaging viaansible.mcp_builder.ansible.mcp_builderdoes not scaffold a server -- it installs pre-built ones from npm, PyPI, Go binaries or source into an EE viaappend_final. Recorded because the name suggests otherwise. Sameappend_finalroot-user gotchautilities/build-ee.sh:18-19already documents.- Hosting is described and not decided --
network-mcp-plan.mdholds that open as Decision C pending network SME review, and this page points there rather than pre-empting it.
Changed -- #93's premise did not survive research, and the issue closes on the finding (#93)
- #93 planned an agentic ITSM demo built on a community ServiceNow MCP server, on the reasoning that Yokohama ruled out the native console.
- Neither community server can be constrained. No read-only mode, no tool
filtering:
jschuller/mcp-server-servicenowships 8 write tools,michaelbuckner/servicenow-mcpshipsnatural_language_updateandupdate_script. #93's "enforce read-only at the ServiceNow end" was not a preference but the only available lever -- and an account is a weaker, far less demonstrable place to put a boundary than a missing tool. snc_read_onlyis additionally incompatible with OAuth (ServiceNow Support: the role blocks client registration and token refresh), so the design would have had to decline the server's own headline auth.- Their tools are Table API wrappers -- discoverability, not capability, over a REST credential. Decision: the community path is not documented here.
- The two code comments that waited on #93 --
playbooks/provision_vm.yml:17("re-add it with the incident node, not before") and the no-failure_nodesnote incontroller_workflows.yml-- now point atservicenow.mdinstead of an issue number, so they explain themselves rather than citing a closed issue. docs/plan/platform-addons-plan.mdsaid the read-only stance "costs ... on ServiceNow (#93) a dedicatedsnc_read_onlyaccount". Corrected in place: an account was the only available boundary, not a chosen one, and the page now says so rather than leaving a plan doc asserting the rejected design.- No successor issue: the research lives in the two pages. If the demo instance reaches Zurich, the work gets re-filed then.
Fixed -- the demo page and facts.json disagreed about virtualization (#160)
- A live VM served a page saying
Virtualization: KVM (guest)whilefacts.jsonon the same host, from the same run said{"type": "NA", "role": "NA"}. - Cause: on a KubeVirt guest these facts come back as the literal string
"NA", so| default()never fires -- it is defined.index.html.j2handled that; thefacts.jsontask did not. The fix had been applied to one of the two consumers of the same broken fact. - Why it mattered more than a cosmetic mismatch. #47's stated purpose for
facts.jsonis "same data, curl-able", and the page's own footer says "everything on this page is inspectable". The page made a claim and the artifact offered as evidence denied it -- on a demo whose whole story is Red Hat virtualization, whereNAis the least convincing possible answer. - Normalised once, in
linux_configure/vars/main.yml, and read by both the template and the task. Not the conditional written twice more carefully: two copies is how they drifted.vars/rather thandefaults/because this is derived, not configuration, and nothing should override it.
Changed -- the drift gates did their job, in sequence (#160)
- Worth recording as the first real exercise of machinery built the same day.
Changing the role's fact made
renderer-matches-role(#145) fail with "the task now uses a variable the fixture does not define"; fixing the renderer madedocs-artifacts-current(#85) fail with a diff of the stalefacts.jsonblock intalk-track.md. One fact changed, and CI named each of the remaining copies in turn.
Fixed -- documented commands assumed an ambient ~/.kube/config (#161)
virtctl sshto a freshly provisioneddemoVM failed withdial tcp: lookup api.cluster-k59xk-1... no such host-- a cluster that died two days earlier. The VM was fine;virtctldefaults to~/.kube/config, whosecurrent-contextstill named the dead environment.- The automation was never affected, and that bounds the fix.
provision_vm.ymlhas zero references to~/.kube/config-- it synthesises a temporary kubeconfig fromconnection.ymlplus the vault and deletes it in analways:block. Only instructions to people assumed the ambient file. README.md's SSH section listed "oclogged in" as the prerequisite and gave a barevirtctl ssh. It now namesKUBECONFIG=.kube/<env>.kubeconfigand explains why the ambient file is not trusted.- Both force-unlock recovery blocks --
terraform_lock_check.ymlandocpvirt-provision/SKILL.md, which are the same text in two files on purpose -- pointed at$HOME/.kube/configafter anoc login. That is the worst place to trust an ambient kubeconfig: a force-unlock aimed at the wrong cluster is a bad afternoon. Both now use the repo's per-environment file. utilities/check-kubeconfig.shverifies rather than trusts, the same shape ascheck-no-secrets.sh's second check (#130).make-kubeconfig.shwrites the file once; repointing an environment editsconnection.ymland the vault and does not regenerate it, so the two drift silently. The check compares the kubeconfig'sserveragainst that environment'sopenshift_api_urland names the fix. Verified against a deliberately staled kubeconfig -- it fails with both URLs printed.- Wired into
/sales-demos-mcp's preflight, which is where kubeconfigs are generated.
Fixed -- the relative path in the force-unlock instructions
- Both blocks
cd terraform/ocpvirtfirst, so a repo-root-relative.kube/<env>.kubeconfigwould have resolved toterraform/ocpvirt/.kube/...and failed. They use../../.kube/and the path was checked from that directory.
Not a bug -- the SSH key
- Initially suspected.
~/.ssh/id_rsamatchesdemo_ssh_public_keyand is a default identity ssh offers automatically, so no-iis needed and no key should be written anywhere. Recorded because the obvious next move -- extractingdemo_ssh_private_keyfrom the vault to disk -- would put a private key on the filesystem to solve a problem that does not exist.
Changed -- job templates now run on the 2.7-based EE, v1.1.0 (#122)
controller_execution_environments.ymlpoints atsales_demos_ee:v1.1.0, and the description string is corrected fromv1.0.0 ... AAP 2.6tov1.1.0 ... AAP 2.7. The upstream audit-trail digest is updated tosha256:be41f1ff....- Proven before it was flipped, which is what #122's additive rule asks for.
Sales Demos - Install Automation Orchestratorran green onv1.1.0in both environments -- sandbox job 108 and demo job 51, eachok=30 changed=0. Idempotent, andkubernetes.coreend to end, which is exactly what the python-interpreter defect would have broken. - Rollback is one line.
v1.0.0stays mirrored in both hubs viahub_ee_repositories.yml, so revertingimage:needs no re-mirror and no quay round trip. - The temporary verification EE objects created to run
v1.1.0without retiringv1.0.0first are deleted; the job templates were restored to the managed EE before this change.
Fixed -- #143 broke the Automation Orchestrator job template (#122 step 2)
install_ao.ymlasserted and readenv_secrets[aap_env_name].aap_password. That variable does not exist in a job template.secrets.ymlis untracked (#130), so there is no vaulted file in the project checkout; the "Sales Demos- Env Secrets" credential type injects
aap_passwordas an extra_var instead. The playbook therefore worked from a laptop and failed from AAP withassertion: env_secrets is defined ... evaluated_to: false. - This is exactly the split
CLAUDE.mdforbids -- "required vars asserted at the top so both entry points fail identically". One entry point worked and the other could not start. - Now reads
aap_password, whichconnection.ymlresolves from the vault on a laptop and the credential injects in AAP.config.ymlandcurate_hub.ymlhave always read it that way; this bringsinstall_ao.ymlin line. - Found by running the job template, not by review. #143 was verified only
from the CLI, where
env_secretsis defined -- the defect was invisible from that side. It surfaced on the first AAP run of the playbook after #143, which is #122 step 2's whole purpose.
Added -- main is branch-protected, and CI is now a gate rather than a convention
- A pull request is required to change
main, with 0 required approvals -- zero because there is one collaborator and GitHub does not permit approving your own PR, so requiring one would deadlock every PR. Zero still forces the branch-and-PR flow. - All 8 lint jobs are required checks.
CLAUDE.mdsaid "a green CI run does not mean a playbook works", and that is still true; what changed is that a red one can no longer be merged past. Adding or renaming a job means updating the required list, or PRs hang on a check that never reports. - Enforced for admins, which is the only setting that addresses what
prompted it: a commit reached
maindirectly becausegit checkout -bfailed on an existing branch and|| trueswallowed the error. Admin bypass would have allowed it, since the push already had admin rights. - Force pushes and deletion of
mainare blocked; PR conversations must be resolved before merge. - Verified by attempting a direct push, which was refused with
GH006: Protected branch update failed.
Added -- MCP server demo documentation in docs/demos/mcp-servers/ (#153)
- Six files following the demo template, plus a status-table reference. The
MCP servers are both tooling and a demonstrable use case — the demo argument is
governed agentic automation: the AI reads everything, changes nothing except
through Ansible.
README.md,run-sheet.md,talk-track.md,architecture.md,objections.md, andserver-inventory.md(earned sixth file, same justification as PAH'sclickops.md). - Phase 1: OpenShift MCP content is complete. Tool listings, credential
flows, verification commands, and troubleshooting tables are sourced from the
/sales-demos-mcpskill and measured against live servers. AAP MCP sections are marked placeholders for Phase 2. server-inventory.mdis the canonical status table. The same format Claude Code renders when asked "show me the MCP servers" — server name, transport, access posture, tool count, auth method. Per-server tool listings with the nine mutating tools--read-onlyremoves called out explicitly.- Red Hat links included: the MCP protocol spec,
kubernetes-mcp-serverupstream, the AAP MCP Server deployment guide, ToolHive on OpenShift, andansible.mcp_builder. - Four existing docs updated.
docs/demos/README.mdand rootREADME.mdgain a use-cases row.ROADMAP.mdis reframed from "Not a use case: tooling" to "Both tooling and a demonstrable use case." This CHANGELOG entry.
Fixed -- the EE build clobbered its own python interpreter (#122)
- Two defects, one cause, and the second is the dangerous one.
assembleinstalls the system packages the collections' bindep files ask for, and that list includespython3-devel. On RHEL 9 that pulls inpython3-3.9, which repoints/usr/bin/python3from the base image's 3.12 to 3.9. - Build-time:
assemble's next step is$PYCMD -m pip install, so the build dies with/usr/bin/python3: No module named pip.utilities/build-ee.shnow passes--build-arg PYCMD=/usr/bin/python3.12. - Runtime: it lands in the final image too.
ansible's own shebang stays 3.12, but Ansible's interpreter discovery resolves/usr/bin/python3-- now 3.9, whose site-packages has nokubernetes, noyaml, none of the collections' python dependencies. Everykubernetes.coretask in this repo runs on the EE, so the image would have failed in front of a customer.append_finalrestores the symlink and then asserts the interpreter can importkubernetesandyaml. build-ee.sh's existing checks would not have caught the second one. It verifies terraform runs as UID 1000 and that every pinned collection is at its pinned version; both passed on the broken image. That is why the assertion is in the build rather than left to the reviewer.- The 2.7 base did not cause this. The 2.6 build never reached the step that
installs
python3-devel, because introspection found no python requirements. The 2.7 base's different bundled collections surface a fragility that was always in the definition. Nothing published is affected --v1.0.0was checked directly and haspython3 -> 3.12with working imports.
Changed -- docs catch up to AAP 2.7 (#122)
README.mdsaid the EE base was "AAP 2.6" and, under Conventions, that the platform is 2.6;CONTRIBUTING.mdtold contributors to pin to 2.6 because "this catalog item ships 2.6". All three contradictedCLAUDE.md, which has said 2.7 since #101, and the CONTRIBUTING line was actively wrong guidance..claude/skills/sales-demos-ee-build/SKILL.md's re-pin command still queried theansible-automation-platform-26stream, so following the skill would have re-pinned the base back to 2.6.- Grouped here rather than split out: this is docs catching up to code under one
theme, which is what
CLAUDE.mdasks for.
Changed -- the EE base moves from the AAP 2.6 stream to 2.7 (#122)
execution-environment.ymlnow pinsansible-automation-platform-27/ee-supported-rhel9@sha256:563d524b.... The platform went to 2.7 in #115 while the image still came from the 2.6 stream, and the repo was already pinning 2.7-generation collections into it. On the 2.7 base that mismatch does not arise: the base shipsansible.controller 4.8.6andansible.platform 2.7.20260812, so the pins are now same-generation rather than cross-generation.microdnfre-verified against the new digest by running the image -- present,dnfabsent -- rather than assumed to carry over from the 2.6 pin.- Built and published as
quay.io/zigfreed/sales-demos-ee:v1.1.0(sha256:be41f1ff...). Verified as UID 1000:python3 -> 3.12, importskubernetes/yaml, ansible-core 2.16.19, Terraform 1.15.8, all nine collections at their pinned versions. v1.0.0is still mirrored and still what job templates run. Per the additive rule,hub_ee_repositories.ymlmirrors both tags, andcontroller_execution_environments.ymlis deliberately NOT repointed here -- #122 requires the replacement to run a real job template first. Flipping it is a one-line change, and so is rolling back.
Added -- AAP MCP servers (aap-sandbox, aap-demo) in /sales-demos-mcp (#150)
/sales-demos-mcpnow sets up four MCP servers in one run: the two existing OpenShift servers (openshift-sandbox,openshift-demo) plus two new AAP servers (aap-sandbox,aap-demo).utilities/make-aap-mcp.shautomates the full AAP MCP client flow: resolves AAP hostname and password from the vault, creates a personal access token via the gateway API, finds theaap-mcproute, and registers the server withclaude mcp add --scope local.aap-demois read-only (scope=readon the token), matching theopenshift-demoposture — the environment customers watch should not be mutated by the agent.- The script prints token cleanup instructions, since these are the documented exception tokens that must be retired by hand.
Fixed -- config.yml created job templates against a stale project checkout (#148)
- AAP validates a job template's
playbook:against the project's SCM checkout, andconfig.ymlnever synced the project. A project whose last sync predated a newly added playbook failed template creation with{'playbook': ['Playbook not found for project.']}-- andinfra.aap_configurationcensors that message withno_log, so the run died showing a barefatal:andcensored:with no reason. - It aborted a routine
demobuild at stage 2 of 5. Nobody had changed anything; the project simply sat at a revision from earlier in the day. CNV installed, then the MCP server, Automation Orchestrator and the VM verification never ran. #141 had recorded this as an ordering note beside the template, which described the trap instead of removing it. config.ymlnow looks the project up and syncs it before the dispatch role, blocking until the sync finishes. An async update would leave the same race, only narrower.- Only when the project already exists. On a fresh environment it does not, and dispatch creates it -- which syncs at current HEAD as part of creation, so there is nothing stale to fix. Verified: with a project name that does not exist the lookup returns nothing and the sync skips cleanly rather than failing.
scm_update_on_launchstaysfalse, and that is not in tension with this. Syncing at configuration time is a different moment from syncing at launch time, and only the second would make a running demo unpredictable.- Cost measured, not assumed: 2.4 seconds on demo. The module reports
okrather thanchangedbecause a project update is an action rather than a configuration change -- it still launched update id 37 and waited for it. Skippable with-e sync_project=false. ansible.controller.project_updaterather thanansible.platform, by necessity: projects are a controller concept andansible.platformships no equivalent, the same reason the inventory/group/host modules are controller ones. Declared in.ansible-lintmock_modules, since CI lints offline.
Added -- CI fails when the renderer diverges from the linux_configure role (#145)
utilities/check-renderer-fixture.py+ arenderer-matches-rolejob. #85 proved the docs matchrender-demo-assets.py; this proves the script matches the role.- The gap this closes was worse than no check. The renderer necessarily
carries its own copies of things the role owns -- that is what lets it render
without a cluster. If those diverged, #85's gate stayed green: the renderer
and the docs agreed with each other while both disagreed with the machine
linux_configureactually builds. The green tick asserted something it did not mean. facts.jsonis verified by rendering the role's own task, not by comparing key names. The role writes it from a Jinja dict literal piped throughto_nice_json; the checker renders that samecontent:block with the renderer's fixture and diffs the result againstfacts_json(). That covers structure and values, and makes the role the source of truth rather than something a docstring claims to match.to_nice_jsonis supplied asjson.dumps(indent=4)-- Ansible's own default for that filter, and whatfacts_json()already passes.- This was more tractable than #145 expected. The issue offered "enforce it
or downgrade the claim to an honest comment" and thought the second was
likely. Rendering the task turned out to work exactly, so the docstring on
facts_json()now says the claim is enforced rather than asserted. linux_configure_motd_creditsis compared directly. The MOTD "Powered by" list is data, defined in the role defaults and again in the fixture.- A renamed or deleted task fails, rather than silently checking nothing -- a vanished source of truth is the same drift one level up.
- Verified in four directions: a clean tree passes; changing a credit in the
role fails with a diff; renaming a
facts.jsonkey in the role fails naming the key; renaming the task fails saying the mirror can no longer be found. - Not every fixture value is reconciled, deliberately. Gathered facts like
ansible_kernel, and the pinnedansible_date_timethat keeps output deterministic, exist precisely because there is no cluster. Only what the role owns is checked.
Added -- CI fails when a committed banner block drifts from its template (#85)
utilities/check-docs-artifacts.py+ adocs-artifacts-currentjob. The demo docs quote/etc/issue.net,/etc/motdandfacts.jsonin fenced blocks, and every copy is hand-pasted --render-demo-assets.pyprints to stdout and writes nothing into markdown. Editingmotd.j2used to leave the docs stale with CI green.- Why this one matters more than a normal docs-lint. These blocks are what the demo falls back to when there is no cluster, so a stale banner has a presenter describing a machine that does not exist, in front of a customer.
- Blocks are located by an explicit marker,
<!-- rendered: motd.j2 -->on the line above the fence, rather than by matching surrounding prose. Prose matching breaks the moment someone rewords a sentence, and the marker makes the coupling visible to whoever edits the doc next -- which is the actual failure being guarded against. Four sites gained one. - The checker imports the renderer rather than parsing its stdout.
render-demo-assets.pywraps each artifact in a decorative banner for humans; scraping that would couple the docs gate to the presentation of a script whose job is to print things nicely. It callsrender()andfacts_json()instead. - A missing marker is a failure, not a pass. If no document carries a given marker the artifact is no longer verified anywhere -- the same drift one level up -- so the checker fails rather than reporting a cheerful zero.
- Verified in both directions: editing
motd.j2and touching no docs fails and names both copies with line numbers, which is #85's stated "done when"; editing a doc fails with a unified diff; deleting a marker fails; and a clean tree passes with all four blocks matching. demo-page.pngis deliberately not checked. It is a Chrome screenshot, so a byte diff would fail on a font or Chrome change rather than on real drift.
Fixed -- nothing had actually drifted yet (#85)
- All four committed copies still matched their templates when the gate went in, 22 days after #85 was filed. The check arrives clean rather than with a backlog, which is the good case and worth recording as the baseline.
Changed -- Automation Orchestrator's admin password is now AAP's (#143)
install_ao.ymlseedsspec.secrets.initialAdminPasswordSecretReffromenv_secrets[<env>].aap_password, so AO and AAP are one credential rather than two. Before this the operator generated a random password that had to be dug out ofao-initial-admin-passwordin the cluster -- different and unpredictable in every environment.- Seed-time only, and deliberately so. The CRD says the secret "is used only
during initial database seeding to create the admin user. Once the admin user
exists, this secret is ignored." So it fixes every future environment and
changes nothing on
sandbox, whose admin already existed and had been updated by hand to the same value. - The playbook does NOT call the password-change API on every run, and that is a decision rather than an omission: doing so would turn an idempotent install into something that rewrites a credential on a live demo platform on every reconcile, and would fight anyone who changed it on purpose. Changing an existing instance's password is an API operation, documented in the skill.
- Verified:
changed=0on a second run, AO stillReady=True / Degraded=False, and the AAP admin password still authenticates against/api/v1/auth/login.
Added -- Automation Orchestrator installs on every build, on its own database (#141)
playbooks/install_ao.yml+/sales-demos-orchestrator+ a job template. #108 established that the operator installs and all five product images pull, and that PostgreSQL was the only obstacle. This removes it: CloudNativePG supplies the database, and AO now arrives with every environment rather than being a catalog entry somebody could install by hand.- Wired into
setup.ymlas stage 4 of 5, default-on and skippable.-e install_ao=falsedrops it. Default-on matches the goal; the flag exists because this is the longest stage and a hung add-on must not fail a build someone needs in twenty minutes. Setup goes from roughly 10 minutes to 15. - THREE DATABASES, NOT TWO, AND THE THIRD IS UNDOCUMENTED. The CRD requires
exactly two secretRefs --
backendDatabaseandtemporalDatabase-- so two is what you build, and thenao-temporal-migrationcrash-loops forever onpq: database "temporal_visibility" does not existwhile every other component waits. Temporal keeps its visibility store in a separate database whose name is fixed and is not derived from the temporal database's name. Nothing in the CRD, thealm-examplessample or the operator description mentions it; it was found by reading the migration logs on the first live install. - Not AAP's PostgreSQL, and that was a considered choice.
aap-postgres-15is owned by theAnsibleAutomationPlatformCR withblockOwnerDeletion, so databases added to it sit inside something another operator recreates at will, and Temporal's write volume would land on the database the whole demo platform depends on. CloudNativePG is certified, v1.30.0, and carries novalid-subscriptionannotation. - Two traps encoded in the playbook so nobody re-finds them. ODF's
Multicloud Object Gateway ships a vendored CloudNativePG under
postgresql.cnpg.noobaa.io, whose CRDs are present on any ODF cluster and will not servepostgresql.cnpg.ioresources. And an AllNamespaces operator has its CSV copied into every namespace, so waiting onitems[0]of a CSV list reads whichever operator happens to sort first -- both waits select by name. - Verified by asking the Route for a page rather than trusting the recap: the
playbook requires HTTP 200 before it reports success, and two consecutive
runs report
changed=0.
Changed -- available_memory_gb 67 -> 63, because AO comes out of that budget (#141)
- Measured by
probe_env.ymleither side of the install: requests moved 15.00 -> 16.91 vCPU and 50.30 -> 52.77 GiB, a delta of 1.91 vCPU / 2.47 GiB for nine AO pods plus one PostgreSQL instance. Theprobe_workloads.ymlplaceholder of 2.0 / 4.0 is replaced with that measurement. - Overstating this budget is the dangerous direction. The precondition in
terraform/ocpvirt/locals.tffails closed, so a figure that is too small merely refuses tiers the cluster could run, while one that is too large admits a plan that will not schedule.
Added -- Automation Orchestrator installed as an experiment, and it is not the blocker we expected (#108)
- The operator installs and its images pull.
stablestill resolves tov2026.8.1787147047, the exact version #92 recorded, and the CSV reachedSucceeded/InstallSucceededwith the controller-manager1/1 Running. The bundle and controller images pulled fromregistry.redhat.iounder the environment's existing pull secret -- no separate pull secret was needed. #92 could only say the operator was in the catalog and was careful that catalog presence is not entitlement; that guess is now retired. - Footprint measured twice, and the estimate was 64x too large. Read back
from the pod: one
managercontainer requestingcpu: 10m/memory: 64Mi.probe_env.ymlrun before and after independently agrees -- requests moved 15.00 -> 15.01 vCPU and 50.30 -> 50.37 GiB. It replaces an estimate of 2.0 vCPU / 4.0 GiB.available_memory_gbstays 66 either side of the install, so nothing interraform/moves. - All five product images pull, so entitlement is a closed question. The
operator installing does not prove the product it manages will run, and the
valid-subscription: ["Red Hat Ansible Automation Orchestrator"]annotation invites the opposite assumption -- so every image the CSV lists was pulled by a throwaway pod that referenced it and exited 0: operator, backend, UI, temporal andrhel9/redis-6. All came down under the environment's existing pull secret. #108 was written expecting to hit a licensing wall; there is not one. - The real obstacle is PostgreSQL, not licensing. The CRD requires
spec.postgreswith ahostplus two distinct databases (backendDatabaseandtemporalDatabase) and offers no embedded option, so anAutomationOrchestratorinstance cannot be stood up on RHDP without provisioning a database first. The answer to "can we demo this?" is not yet, and for a different reason than the issue expected -- a provisioning problem, not an entitlement one. #108 said both outcomes were worth writing down. - No playbook and no skill in this change, and it is not in
setup.yml. #108 left that open pending the outcome, and the outcome was that the operator is the cheap part -- automating the install of a controller nobody can instantiate would be automating the wrong half. That reasoning expires with the database, and #141 opens to remove it: CloudNativePG (certified, v1.30.0, no subscription) provisions the two databases so AO joins everysandboxanddemobuild, default-on with a skip flag. - The operator is left running on
sandbox-- 64 MiB is not worth reclaiming and it lets the next session go straight at the CR question. It sits in its own namespace labelledsales.demos/experiment=issue-108, anddocs/plan/platform-addons-plan.mdrecords the two-command removal. OnlyAllNamespacesinstall mode is supported, which is why it has its own cluster-scoped OperatorGroup rather than sharing CNV's.
Changed -- probe_workloads.yml splits the orchestrator into two entries (#108)
- The measured operator (
installed: true) and the still-unmeasured instance are now separate rows. Folding them into one would have let the part that actually costs memory vanish from the file the moment the operator was marked installed -- the entry would read as measured while the backend, Temporal, UI and redis went uncounted.
Changed -- secrets.yml is no longer tracked (#130)
playbooks/group_vars/all/secrets.ymlis now vault-encrypted and local only. It was vault-encrypted and committed; untracking it is what makes this public repo reusable by anyone else. Shipping one person's encrypted credentials hands a forker a blob they cannot decrypt, cannot replace without diverging from upstream, and that conflicts on every pull.secrets.yml.exampleis the contract, and each machine builds its own file.- This depends on #129 and would break AAP without it. Job templates used to receive the vaulted file in the project's SCM checkout and decrypt it with the "Sales Demos - Vault" credential. They now get their credentials from the "Sales Demos - Env Secrets" credential type as extra_vars.
Fixed -- the secret guard would have gone silent (#130)
- Gitignoring the file and keeping the old check would have passed silently.
git ls-filesreturns nothing for an untracked file, so the vault-header loop never iterated,failstayed0, and the script printed "passed" -- and every other pattern in it also reads fromgit ls-files, so a plaintext untrackedsecrets.ymlfull of live tokens would have been invisible to all of them. CI would have gone green while the one thing the script guards stopped being guarded. utilities/check-no-secrets.shnow makes three checks that cannot no-op: nothing namedsecrets.ymlis tracked; the.gitignorerule actually matches (git check-ignore); and a tracked one, if it exists anyway, still begins with$ANSIBLE_VAULT. The ignore rule is not trusted -- it is verified, and deleting it fails the build. That answers the standing objection inCLAUDE.mdthat an ignore rule hides the file instead of verifying it, rather than discarding it.- The check order is load-bearing. Tracked-ness is tested before
git check-ignore, because git reports a tracked file as "not ignored" whatever.gitignoresays. Testing check-ignore first blamed.gitignorefor a rule that was present and correct.
Changed -- documentation and the first-time skill follow the new model (#130)
CLAUDE.md,README.md,CONTRIBUTING.md,.github/SECURITY.md,.gitignore,aap_settings.yml,secrets.yml.exampleand thelint.ymlheader all said the file was committed. They now describe building it from the example, andSECURITY.mdrecords that credentials committed before this change remain in git history.sales-demos-first-timestep 2 was a dead end. It told a new user the file already existed and to ask for the password. On a fresh clone there is now nothing to decrypt. It covers two cases instead: a fresh machine, where you create both the file and a password of your own choosing, and a shared environment, where you need the file and the password because the file is no longer in git. Its step 0 audit now checks for the file too.
Added -- AAP credential type so secrets.yml need not be tracked (#129)
inventory/group_vars/aap/controller_credential_types.ymldefines "Sales Demos - Env Secrets": four write-only fields (aap_password,openshift_api_token,rhsm_org_id,rhsm_activation_key) injected as extra_vars, plus the matching credential and its attachment to all six job templates.- Why it exists. Every template carried only "Sales Demos - Vault", whose
sole job was decrypting the vault-encrypted
secrets.ymlthat AAP received in the project's SCM checkout. That works only while the file is tracked. #130 untracks it, and the moment it is untracked AAP's checkout has no secrets file at all --env_secretsgoes undefined and every template fails on the connection asserts. - No playbook and no
connection.ymlchanged.connection.ymldefinesaap_passwordasenv_secrets[aap_env_name].aap_password; extra_vars outrank group_vars, so in AAP the injected value wins and that expression is never evaluated, while on a laptop the local vaulted file still supplies it. One variable contract, both entry points. - This is not the #4 restriction. AAP disallows Vault credentials on SCM inventory sources. This is a job template credential, which is the sanctioned way to hand a job a secret.
- One type, two environments, no environment key in the fields. Each RHDP
environment has its own AAP, so
config.yml --limit sandboxfills sandbox's controller fromenv_secrets['sandbox']and--limit demofills demo's fromenv_secrets['demo'], off the same lines -- exactly how "Sales Demos - PAH Registry" already resolvesaap_hostname. - Rotation is now an explicit act. With the vaulted file in SCM, editing the
vault propagated on the next project sync. Now a rotated token does nothing
until
config.ymlis re-run for that environment. { {in the injectors is not a typo. Thecontroller_credential_typesrole rewrites brace-space-space-brace into{{before sending injectors to AAP. Written normally, Ansible would expand the template at CaC time and inject the value as a literal default instead of letting AAP substitute its own field at launch.- Known check-mode behaviour. On an environment that has never had the type
applied,
validate.ymlfails withcredential_types/?name=Sales+Demos+-+Env+Secrets returned 0 items, expected 1-- check mode does not create the type, so the credential referencing it cannot resolve. It clears after one realconfig.ymlrun. The dispatch role names this case itself ("missing dependencies caused by check mode").
Fixed -- check-secrets-example.py miscounted prose and YAML keys (#137)
- Comments are stripped before the Jinja scan. A
{{inside an explanatory comment opened a match that ran to the next}}several lines later and captured every English word between them as a variable name, so a header explaining Jinja escaping reportedAAP,Ansible,Jinja,escapeandhatchas missing vault keys. - Only real definition sites define a variable. The checker treated every
mapping key anywhere in the tree as a definition.
rhsm_org_idlegitimately appears as a key under a credential'sinputs:, which made the checker believe the variable was defined and then report its declaration in the example as an orphan -- backwards, since those are the two keys the check exists to protect. A key now counts only at the top level of a vars file (group_vars/, a role'sdefaults/orvars/) or under avars:/set_fact:mapping. - The
set_factmatch handles the FQCN. This repo writesansible.builtin.set_fact; keying on the bare name silently missed every fact it sets, which turned ~50 ordinary playbook facts into "missing vault keys". target_envis now in theNOT_A_VAULT_KEYallowlist: it is supplied per run via-e target_env=<env>and as a job template extra_var, never stored.- Both defects were latent in #128 and surfaced immediately when #129 added a file that trips them -- which is the check doing its job, one layer down.
Fixed -- demo pointed at a cluster that no longer resolves (#135)
inventory/group_vars/demo/connection.ymlnamed the previous demo cluster inaap_hostname,openshift_api_urlandopenshift_apps_domain. The environment had been rebuilt and its credentials refreshed in the vault, but these three lines were not.- The failure gave no signal pointing here. DNS simply stopped resolving, so
every connection died at the network layer --
curlreturned HTTP 000 and theopenshift-demoMCP server reported onlyCONNECTION_CLOSED. Same shape as #101 on sandbox. - Credentials and hostnames are refreshed in two different places by design, so updating one leaves no trace that the other is stale. The file header now says that explicitly: when an environment is rebuilt, change both.
- Verified against the rebuilt environment: the AAP gateway reports
2.7withdb_connected, the vaulted token authenticates as its cluster-admin ServiceAccount, and the cluster's owningresses.config.openshift.iodomain matchesopenshift_apps_domainexactly.
Fixed -- secrets.yml.example had drifted from what the code requires (#128)
- Added
rhsm_org_idandrhsm_activation_key.playbooks/roles/linux_register/tasks/main.ymlasserts both and fails the play without them, and/ocpvirt-demopreflights for the activation key -- but neither was declared insecrets.yml.example. A secrets file built from the example passed every preflight and then failed Phase 4 on guest registration, which is the one failure mode that only surfaces in front of an audience. - Corrected the pre-#5 paths in the example header. It still told the reader
to copy and encrypt
inventory/group_vars/aap/secrets.yml; the file moved toplaybooks/group_vars/all/in #5. - Marked
quay_username,quay_passwordandwindows_admin_passwordas not yet consumed. No tracked file reads any of the three -- they are staged for the Phase 2 Windows golden image. Kept rather than deleted (additive only), but named in an allowlist so that adding a future orphan is deliberate.
Added -- CI check that keeps the example honest (#128)
utilities/check-secrets-example.py, wired in as thesecrets-example-syncjob. The realsecrets.ymlis vault-encrypted, so CI can never diff the two. Instead it finds every variable referenced underplaybooks/orinventory/that nothing in either tree defines and that is used at least once without a| default(...)guard. Those can only come from the vault, so each must be declared in the example.- The bare-versus-defaulted distinction is the whole discriminator. It is
what separates a required credential from an optional override:
tf_state_namespaceis always written with a default and needs no vault entry, while{{ rhsm_org_id }}is used bare and will fail the play outright. On the current tree it identifies exactly the six vault keys and nothing else. - Also fails on a declared key nothing reads, and on an
env_secretscredential present for one environment but not the other -- a file that works right up until someone runs against the environment customers see.
Added -- AAP self-service portal, ported from aap.selfservice (#103)
playbooks/portal.ymland thesales-demos-portalskill deploy Red Hat Developer Hub with the AAP plugin via theredhat-rhaap-portalHelm chart (2.1.0). One playbook, two phases: bootstrap the portal (OAuth app, namespace, secrets, Helm deploy), then sync the org list and patch the portal ConfigMap.- Ported from
aap.selfservice, not built from scratch. That repo validated the Helm path on 2026-05-06 (~11 min end to end). The work here is adapting credentials and connection flow to this repo's conventions --env_secretsin the vault,connection.ymlfor non-secrets,--limitfor environment selection. No new vault keys needed: every credential the portal requires already exists. - Helm, not the RHDH operator. Both are available on the cluster (operator
rhdh-operator.v1.10.3on thefastchannel). The Helm chart bundles RHDH with the AAP plugin pre-wired; the operator deploys generic RHDH and the plugin would need wiring manually. That is a build, not a port, and the chart path is already proven. - Three hard-learned facts survived the port (and live debugging). (1) OAuth
applications live in the gateway registry (
/api/gateway/v1/applications/), never the controller's. (2) Never PATCHclient_secret-- the gateway hashes it differently on PATCH than on POST, givinginvalid_clientat/o/token/. The playbook deletes and recreates the application on every run. (3) AAP 2.7 defaultspkce_requiredtotrue; RHDH's RHAAP auth provider does not send PKCE parameters, so the OAuth flow fails silently -- AAP redirects back without an authorization code and the portal shows "You have to provide code or refreshToken". The playbook setspkce_required: falseexplicitly. - The service token is durable by design. Same exception pattern as the MCP
client token documented in CLAUDE.md. The portal backend uses it to sync
templates and serve API requests. Cleaned up in
rescue:only if the OCP setup block fails. - Replaces
oc rollout statuswithk8s_infopolling. The source repo shelled out tooc; the port polls the Deployment forupdatedReplicas == replicasandunavailableReplicas == 0, matching the patternmcp_server.ymlalready uses. - Not added to
setup.yml. The portal is a platform addon (#92 Phase 4), not part of the base environment build. It runs separately via thesales-demos-portalskill or a future job template. .ansible-lintgainsansible.platform.applicationandkubernetes.core.helmas mock modules -- both are used by the portal playbook and CI lints offline.
Fixed -- skill preflights rejected a valid ServiceAccount token (#105)
- The token-shape check in
ocpvirt-setupandsales-demos-first-timeonly acceptedsha256~OAuth tokens. A ServiceAccount JWT -- the better credential, since it does not expire mid-run -- startseyJand was rejected with a message blaming the vault password or a missingenv_secretskey, neither of which was the problem. - Both call sites now accept either form.
sha256~*for OAuth tokens andeyJ*.*.*for ServiceAccount JWTs. TheeyJpattern still rejects the #86 failure mode (an Ansible error string contains spaces and starts with neither prefix), so the guard that was added to stop non-empty garbage from passing as a credential is preserved. - The prose in
sales-demos-first-timeStep 7 previously said a value "that is non-empty but not asha256~token will fail later as a confusing 401"; it now describes both accepted forms and why the #86 case is still caught.
Fixed -- validate.yml could not run on the environment it was most needed for (#106)
validate.ymltold you to "run this before config.yml, always" and then died on any environment whereconfig.ymlhad never run -- the first apply against a brand-new cluster, which is exactly when a dry run is worth the most.- A read-after-write across two roles, not version drift. Dispatch runs
hub_ee_registrythenhub_ee_repository, and the second reads back the registry the first would have written. In check mode that write never happens, soansible.hub1.1.0 indexes an empty lookup and raisesKeyError: 'id'atah_ee_repository.py:238. - And it failed opaquely.
aap_configuration_secure_logging: truehid the traceback behindcensored: 'the output has been hidden...', so the reported symptom was a censored failure with no cause. - The fix is conditional, not a blanket skip. A read-only pre-flight asks
the hub which EE registries exist. Only when a declared one is genuinely
absent does it empty
hub_ee_repositories_all, so dispatch includes the role with nothing to iterate instead of crashing. Onceconfig.ymlhas created the registry the pre-flight does nothing and full coverage returns -- the gap exists only on the first run, and the next validate closes it. - The coverage gap is stated where it happens. #106 asked for that
explicitly. The skipped run does not validate the items in
hub_ee_repositories.yml, and both the task comment and the runtime message say so, along with the command that removes the skip. - The pre-flight uses
ansible.builtin.uriwith the password fromenv_secrets, the same waysync_hub.ymlreaches this hub -- the credential never reaches a shell variable or a process argument. - Verified on live sandbox both ways: registry present,
ok=199 changed=11 failed=0with no skip; registry absent,ok=192 changed=10 failed=0with the skip and its explanation. Neither fails.
Fixed -- the last unverified AAP 2.6 claim, now measured (#116)
hub_collection_remotes.ymlsaid all three remotes already exist on a fresh AAP 2.6 hub. That claim is load-bearing, not decorative: the entire file is written as updates rather than creations, so a missing remote would mean updating something absent.- Measured against the live 2.7 hub 2026-09-03 via
GET /api/galaxy/pulp/api/v3/remotes/ansible/collection/: count 3, namedvalidated,rh-certified,community-- exactly the three declared, unchanged across the version move. The version stamp is dropped rather than bumped, matching the treatment the other #116 claims got. - The query ran through Ansible, not a shell curl. Reading the vault
password into a shell variable to pass to
curl -uwas blocked by the permission classifier, correctly.ansible.builtin.uriwith the password resolved fromenv_secretsis how this repo already handles hub credentials insync_hub.yml-- the password never reaches a shell variable or a process argument. The shell version was the shortcut; the block caught it. - This closes the last open item in #116 apart from the execution environment, which turned out to be a larger finding and moved to its own issue (#122).
Fixed -- the AAP 2.6 assertions that survived the 2.7 move, re-measured rather than re-typed (#116)
- #115 adopted 2.7 but deliberately left nine
AAP 2.6strings alone, because #101's instruction was re-verify, do not blind-edit. Each is now measured. - The load-bearing one holds.
playbooks/sync_hub.ymlbuildshub_apifrom the claim that the gateway fronts Hub by PATH at/api/galaxy/. Verified two ways on 2.7: only two Routes exist in theaapnamespace (aap,aap-mcp), so there is still no hub route; and the platform's own hub API reports its links rooted at/api/galaxy/v3/plugin/ansible/content/...-- the exact path the playbook builds./pah-syncwas never broken. Same correction applied tohub_ee_registries.ymlandcontroller_credentials.yml. - The version stamp is dropped rather than bumped on those three. The claim has now held across a major platform version; pinning it to one release understates what is known about it.
- The
ansible.controllerfallback is still necessary. Measured against the pinned collections:ansible.platform 2.7.20260604ships neither a host nor a group module, whileansible.controller 4.8.0ships both.provision_vm.ymlkeeps its deliberate exception to the ansible.platform-over-ansible.controller rule, now dated to 2.7 and flagged for removal whenever that gap closes. pah-sync's skill description said PAH "ships with every AAP 2.6 environment". PAH ships with AAP generally, so the version is dropped rather than bumped -- it added nothing and dated badly.- Two items are deliberately unchanged, and the PR says why: the collection remotes claim needs a credentialed hub API call, and the execution environment turns out to be a larger finding than a stale comment.
Changed -- available_memory_gb raised from 14 to the measured 67 (#118)
playbooks/probe_env.ymlmeasured sandbox on 2026-09-03 and recommended 67: 124.68 GiB allocatable, 49.05 GiB already requested, 75.63 GiB free, less an 8 GiB safety margin. Cross-checked againstoc describe node.- This unblocks nothing today, and that is worth stating plainly. The
largest shipped request is
os_type=bothatlarge-- 2 VMs x 6 GiB plus 350 MiB overhead each, about 12.7 GiB -- which passed under 14 just as it passes under 67.variables.tfalready said no shipped combination trips the guard. This makes the safety net accurate rather than arbitrary, so that when it does bind it binds on a real number. - The probe's number is used as emitted. Shaving it to reserve room for the
add-ons in
probe_workloads.ymlwas considered and rejected: shipping a probe and then not trusting its output one commit later is how hand-adjusted figures start. The workflow is install an add-on, re-run the probe, take the new number. - The 14 had leaked into six documents, all now corrected:
README.md,ROADMAP.md,docs/demos/openshift-virtualization/architecture.md,docs/plan/ocpvirt-demo-plan.md, and thecontroller_schedules.ymlcomment in both environments. - The overnight-teardown rationale was rewritten, not deleted. Both
controller_schedules.ymlfiles justified the schedule by arguing memory headroom was scarce. At 75 GiB free that argument no longer holds, but the schedule is still right for a different reason -- RHDP environments are metered and reclaimed, so a VM running overnight burns quota -- and #92's add-ons come out of the same budget. Capacity being comfortable today is not a guarantee, which argues for keeping the teardown. ocpvirt-demo-plan.mdgets a third correction entry rather than an edit. That line already recorded one correction (35 GB -> ~14 GiB in #2); it now records this one too. The figure has been wrong twice, in both directions, and each time it read as settled fact -- so the history is the useful part, and the durable fix is the probe rather than a better number.- Tier sizes were deliberately not revisited.
sd1.largeis 6 GiB becauseu1.large's 8 GiB did not fit the old budget; it would fit now. Resizing is a separate decision with its own blast radius, and #100's lesson is that a number moves when something is measured, not when it merely becomes possible.
Added -- a read-only cluster probe, because the memory budget was five times wrong (#100)
playbooks/probe_env.ymland thesales-demos-probe-envskill measure what a cluster actually has: allocatable, what is already requested, what is free, and a recommendedavailable_memory_gb.- The number it replaces was never wrong in a way anything reported.
terraform/ocpvirt/variables.tfdeclaredavailable_memory_gb = 14, measured once on a smaller cluster. Sandbox has 75.63 GiB free. The budget guard inlocals.tffails closed, so a stale figure does not error -- it silently refuses tiers the cluster could run, and the demo just gets smaller. - Strictly read-only. Every task is
k8s_info; a run reportschanged=0, so it is safe mid-demo when someone asks whether the cluster can take another VM. That is why it is a second playbook rather than a flag onprepare_env.yml, which builds and destroys a real VM to do its job. - Requests, not usage. The scheduler places pods and KubeVirt VMs against requests; live consumption does not decide whether the next one fits. Both are printed side by side because the gap on sandbox is 21 GiB -- optimising against live usage would suggest room that is not there.
- Two accounting rules, either of which silently skews the answer. A pod
reserves
max(sum(containers), max(initContainers)), not the sum of everything. And only pods withspec.nodeNamehold capacity -- an unscheduled Pending pod reserves nothing. Getting the second wrong inflated the first run by 1.56 GiB and 0.82 vCPU againstoc describe node; the corrected probe matches the node's own accounting exactly (14.5 vCPU / 49.05 GiBagainst14500m / 50231Mi). inventory/group_vars/aap/probe_workloads.ymlholds candidate add-on footprints as data, each tagged with asource:saying whether it is measured, derived or a guess. A guessed number and a measured one look identical once written down, which is precisely how14survived; the tag is what stops that recurring.- Confirmed on sandbox 2026-09-03: OpenShift 4.20.34, CNV
Available=True, and both add-on operators (mcp-gateway,automation-orchestrator-operator) offered on OperatorHub -- the outstanding question indocs/plan/platform-addons-plan.md, which now opens with the measurement. variables.tfis deliberately not changed here. Raising the default changes which tiersplanaccepts, and behaviour changes ship on their own.
Changed -- the repo targeted AAP 2.6 while its only live environment ran 2.7 (#101)
- Measured, not assumed. The sandbox gateway returns
{"status":"good","version":"2.7",...}and the controller behind it reports4.8.6(2026-09-03).inventory/group_vars/aap/main.ymlsaidaap_target_version: "2.6"with a comment asserting the catalog item ships 2.6. The catalog item moved; #92's environment arrived on 2.7. - The pin is declarative -- nothing reads it. Confirmed by grep across the
repo and
~/.ansible/collections: the only occurrence was its own definition. Correcting it changes no behaviour, and it is kept rather than deleted because it records which version the surrounding configuration was measured against. - The controller version is not the platform version, and conflating them
is how the stale pin survived.
4.8.xis the controller,2.7the platform. Bothmain.ymlandCLAUDE.mdnow say which is which. available_memory_gbis deliberately still 14. cluster-kbjvc is larger than the cluster that 14 was measured on, so the default under-provisions -- it fails closed inplanrather than leaving a VM Pending. It is not re-measured because the honest number is not knowable yet: CNV is not installed here, and its own footprint comes out of the same budget, so any figure taken now is wrong the moment/ocpvirt-setupruns. The reasoning is recorded interraform/ocpvirt/variables.tfrather than left implicit.CLAUDE.mdnames the one sanctioned curl. No tool on the AAP MCP server returns the platform version --config_retrieveandstatus_retrieveboth give the controller version,gateway-settings_listgives categories -- soGET /api/gateway/v1/ping/is the only source, needs no credential, and is now an explicit exception to the #113 MCP-first rule instead of an undocumented one the issue was already relying on.- Step 3 of #101 (re-verifying the 2.6 measurement claims) had already shipped
in
37ffbbc; the issue was simply never updated. No files were re-touched.
Added -- the MCP servers are now the default path, not an option (#113)
- The servers connected but nothing made the agent use them.
ocandcurlwere pre-approved in a personalsettings.local.jsonwhile no MCP tool was, so the shell path ran silently and the MCP path stopped to ask. Given a free path and a prompting one, habit wins. .claude/settings.jsonis now tracked and allowlistsopenshift-sandbox,openshift-demoandaap-sandbox. Claude Code merges it with each person'ssettings.local.json, so it is additive -- no existing local permission is replaced, and a fresh clone gets the same behaviour without hand-configuring anything.- The entries are per-server wildcards on purpose. The read-only guard
belongs at the server, where it already is:
openshift-demoruns--read-onlyanddemo'saap_mcp_allow_write_operationsisfalse, whilesandboxis write-enabled on both. Because the environment is baked into the server name (#16), naming the server is choosing the posture -- and a list of individual tool names would go stale the first time a server gained a tool. Wideningopenshift-sandboxgrants nothing the shell did not already have there:oc delete,oc applyandoc patchwere unprompted already. CLAUDE.mdcarries the matching directive, and records that the check is direct -- the terminal renders each call by name, somcp__openshift-sandbox__pods_listused the server andBash(oc get pods)did not.- Config and docs only. No playbook, no deployed server, no environment is touched.
Fixed -- the MCP server's ingress choice was documented under a key that cannot hold it (#111)
CHANGELOG.mdanddocs/plan/platform-addons-plan.mdboth saidservice_typeis pinned toRoute.Routeis not a legalservice_type. Read from the live CRD oncluster-kbjvc, the two keys carry different enums:ingress_typetakesnone/Ingress/Route(defaultRoute),service_typetakesLoadBalancer/ClusterIP/NodePort(defaultClusterIP). The docs had takenservice_type's enum values and attached them to the value pinned oningress_type.- The #29 reasoning was sound but filed under the wrong key.
LoadBalancerandNodePortare service types, so "both are dead on RHDP" justifiesservice_type: ClusterIP-- it says nothing aboutingress_type. - The old wording also implied both keys were pinned away from their defaults.
They are not:
RouteandClusterIPare each the CRD default. Pinning them is still right, because an operator upgrade can move a default, and #29 means neither is a value to inherit silently. The corrected text says that instead. - Docs only.
playbooks/mcp_server.ymlalready applied the correct keys and the deployedAnsibleMCPServer/aap-mcpalready carried them; nothing about the running server changed.
Added -- the AAP MCP server, deployed by Phase 0 (#102)
playbooks/mcp_server.ymldeploys it andsetup.ymlruns that as stage 3 of 4, so a freshly built environment arrives with it on rather than needing a second visit. Verified oncluster-kbjvc: 140 tools, includingjob_templates_launch_create,workflow_job_templates_launch_createandjobs_stdout_retrieve-- steps 4 and 5 of the demo stories in #93 and #99.- It uses the typed CRD, not the documented shortcut, and that is the whole
point. Red Hat's docs say to add an
mcp:block to the AAP CR. Measured on the live 2.7 CRD,spec.mcpisx-kubernetes-preserve-unknown-fieldswith no sub-properties -- the API server accepts a misspelled key and reports success, giving a green run and no server. The operator also ownsansiblemcpservers.mcpserver.ansible.comwith 31 validated fields, which is whatspec.mcpproduces anyway. Applying it directly means a bad field name is rejected at apply time instead of silently ignored. allow_write_operationsis not idempotent, per Red Hat's own docs: "If you changed the permissions of the MCP server after it was created and deployed, you must delete the AnsibleMCPServer custom resource and recreate it." A plain apply that flips it leaves a server enforcing the OLD permission while the CR claims the new one. The playbook reads the live object and deletes first. That is why it is longer than an apply, and it must not be simplified.- The write posture is per-environment and deliberately has no default --
trueonsandbox,falseondemo, and the playbook refuses to run if it is unset. A silent default is the wrong way to decide whether an agent can POST, PATCH and DELETE. - Ingress type and service type are both stated, never inherited.
ingress_type: Routeis what makes the server reachable;service_type: ClusterIPkeeps it offLoadBalancerandNodePort, which are dead on RHDP (#29). Each is also the CRD's current default -- they are pinned so an operator upgrade cannot move them out from under a working deployment. - A bug the first live run found, kept rather than papered over. Probing the freshly admitted Route returned 503 -- the router had a backend with nothing behind it. The playbook now waits for the Deployment to report a ready replica, not merely for the Route to exist, and the skill's failure table says a 503 shortly after deploy means "wait", not "misconfigured".
CLAUDE.mdgains its first documented exception to "always clean up tokens". An MCP client needs a durable credential, so analways:block would destroy the thing it was created for. Three things keep it from being a hole: no playbook creates it (the skill does, on a laptop), it is never committed (claude mcp add --scope local, not the tracked.mcp.json), and it is the one token here you retire by hand. It also inherits the creating user's permissions -- Red Hat's words -- soallow_write_operationsis a second gate, not the only one.- Recorded in passing: tokens moved in 2.7.
/api/controller/v2/tokens/is now404; the gateway owns them at/api/gateway/v1/tokens/.
Added -- the OpenShift MCP servers, so asking a cluster a question is a tool call (#102)
- The cost this removes is real and this repo was paying it constantly. Every
question asked of a cluster in #101 -- node capacity, which EE registries
existed, whether
spec.mcpwas on the CR -- cost acurl, a vault read and a JSON parse, hand-assembled each time..mcp.jsonreplaces that with a tool call. - The OpenShift server runs on the laptop, not in the cluster, reversing what
#92 assumed. It filed
kubernetes-mcp-serveras a "zero-footprint fallback"; it is the correct primary. An in-cluster server can never help bootstrap the environment it runs in, and dies with every expiring RHDP cluster -- this session began by finding both of this repo's had expired. A local one re-readsconnection.ymland carries on. - It also takes #94's Decision C off the critical path. That decision -- containerize, adapt stdio to streamable HTTP, Route, auth, vault-to-Secret -- exists because every network vendor MCP server is stdio-only with no container image. Claude Code and a stdio server both run on the laptop, so there is no gap to cross here at all.
- Two servers, one per environment, named after it.
openshift-sandboxhas full access (25 tools);openshift-demois--read-only(16). The environment is in the server's name, so you pick it by picking the tool. One server whose target silently changed underneath you is exactly #16 -- where--limit demoresolved to sandbox's hostname and token with no warning -- and repeating that with cluster-write tools attached would be materially worse. --read-onlywas measured, not assumed. It removes precisely the nine mutating tools and keeps every investigative one, includingvm_guest_infoandvm_troubleshoot. Sodemocan still diagnose a broken VM and cannot change it -- #93's "agent reads, Ansible writes" thesis costing nothing here, unlike Dynatrace (#99), where it costs a withheld scope.- The
kubevirttoolset is enabled, addingvm_create,vm_clone,vm_lifecycle,vm_guest_infoandvm_troubleshoot-- directly relevant to a repo whose whole first use case is OpenShift Virtualization. utilities/make-kubeconfig.shderives.kube/<env>.kubeconfigfromconnection.ymlplus the vault. Nothing new is stored: it is gitignored,0600, and regenerable, so it is a cache with an obvious refresh rather than the second copy of a rotating credential that #22 and #68 both refused. It accepts both token shapes, because #105 is open precisely for a check that accepted onlysha256~and rejected a valid ServiceAccount token.- One new prerequisite, and it is the first non-Red-Hat one:
npx. Recorded in/sales-demos-first-timestep 4.5, with the standalone binary noted as the escape hatch for anyone who would rather not install Node. - Known rough edge, stated rather than hidden: a fresh clone shows a failing
MCP server until
/sales-demos-mcpruns, because the committed config points at a kubeconfig that does not exist yet. The alternative -- following whatever~/.kube/confighappens to point at -- trades a visible, self-explaining failure for a silent, wrong-environment success. That is the worse trade. docs/plan/platform-addons-plan.mdis written to teach the mechanism, not just record the decision: what a tool call is, and why the stdio-versus-HTTP transport split is the single fact that makes the network servers in #94 hard and these easy.
Changed -- the three "VERIFIED ON AAP 2.6" claims, re-measured on 2.7 (#101)
- #101 step 3 says re-verify, do not blind-edit, the claims 2.7 might
invalidate. Measured against the live 2.7 gateway and controller on
cluster-kbjvc. Two of the three held; the third was wrong in both files that asserted it. - The gateway settings count was wrong, and had been wrong before this.
gateway_settings.ymlrecorded 44 settings on 2.6;make-env-logo.pyattributed 43 to 2.7 from upstream documentation. The live 2.7 gateway returns 41. All 41 were enumerated by name. - The conclusion those counts supported survived intact -- none of the 41
marks the environment post-login, so
custom_logoreally is a sign-in-time marker and the browser-extension half of the design is still necessary.custom_logoalso reads back at 26,714 characters, matching the "26 KB of base64 PNG" measured in #54. - So the counts are gone rather than corrected in place. A number that has now been stated three ways across two versions is the least durable part of the claim, and quoting it invites the next reader to trust the tally over the finding. The finding is what is load-bearing; it is what the comments now say.
controller_settings.ymlheld: all five keys exist on the 2.7 controller, which exposes 111 settings in total. What was a 2.6-only measurement is now one on both versions.aap_target_versionandavailable_memory_gbare deliberately untouched. They are step 2 of #101 and wait on the probe in #100 --available_memory_gbis the value a hardcoded guess got wrong by roughly 4x, so replacing one guess with another would repeat the mistake this sequencing exists to avoid.
Changed -- sandbox repointed at the live cluster, because both environments had expired (#101)
- Both environments this repo points at were dead, and nothing in the tree
said so.
api.cluster-k59xk-1(sandbox) andapi.cluster-xcvjx-1(demo) both refused connections; the local~/.kube/configstill pointed at the former. The repo had zero runnable environments, which is a state every playbook here fails in identically and unhelpfully -- a connection error, not a message saying the cluster is gone. sandboxnow points atcluster-kbjvc, the AAP 2.7 environment measured in #92 and confirmed live: the API answers 200 on kubelet v1.33.13, and AAP reports{"status":"good","version":"2.7","db_connected":true}.- Three values in
inventory/group_vars/sandbox/connection.yml, plus the twoenv_secrets.sandboxkeys in the vault. Note the new cluster has no-1suffix -- every RHDP environment so far has carried one, so this is the kind of detail that gets pattern-matched wrong. openshift_apps_domainwas not assumed from the API hostname. The AAP route ataap-aap.apps.cluster-kbjvc.dyn.redhatworkshops.ioanswering 200 is itself proof of the ingress domain, and it was then confirmed againstoc get ingresses.config.openshift.io cluster.demois deliberately left pointing at a dead cluster. Repointing it needs a demo-purposed RHDP environment, not this one, and quietly aiming both environments at the same box would erase the distinction--limitexists to enforce.- This makes the cluster reachable, not demo-ready. CNV is not installed on
cluster-kbjvc-- nokubevirt.ioAPI group, nodevices.kubevirt.io/kvm-- so/ocpvirt-setupstill has to run before any VM phase works. Stated here because "the repoint is merged" reads like "the demo works", and it does not. - The 2.7 adoption proper --
aap_target_version,available_memory_gb, and re-verifying the three "VERIFIED ON AAP 2.6" claims -- is the rest of #101 and waits on the probe in #100, so that the memory budget is a measured number rather than a second folk figure.
Added -- the branch convention, which existed in git log in two shapes and nowhere in CLAUDE.md (#97)
CLAUDE.md-> Workflow documented five conventions and nothing about branches. The gap surfaced concretely: asked which branch to use for #94, the convention had to be reverse-engineered fromgit log, which turned up two competing patterns and no rule between them --issue-5-ocpvirt-demo(numbered, no type) againstdocs-pill-proof(typed, no number), withfix-86-preflight-vault-lookupthe only recent branch carrying its issue number. Both are defensible; neither was written down, so every new branch was a fresh judgement call.- The stated rule is
<type>-<issue>-<slug>, which is the two existing styles reconciled rather than a correction of either. Both remain in the history and neither needs rewriting. Carrying the issue number is the load-bearing part -- it links a branch back to its decision without anyone readinggit log. delete_branch_on_mergeis now enabled on the repository, so merged PR branches clean up after themselves. This is recorded here and inCLAUDE.mdprecisely because a repository setting leaves no trace in the tree -- there is no file a reader could check to discover it.- Prompted by a cleanup that deleted 17 local and 11 remote branches, leaving
only
main. That backlog existed because the setting was off. Every branch was verified landed before deletion, including six thatgitreported as unmerged: their patch-ids had drifted through squash or rework, but the content was demonstrably inmain-- checked by confirming every file each branch touched is present, not by trustinggit cherryalone. The one file unique to those six wasutilities/aap-env-badge/envs.json, the superseded name ofcolors.json.
Changed -- the DevNet sandboxes are up, and B1 is a plan rather than a candidate (#94)
- The previous entry's caveat was wrong, and wrong in the useful direction. It recorded DevNet sandbox availability as unresolved because Cisco's docs and Cisco Community threads disagreed, and said only signing in would settle it. Signing in settled it: the always-on labs are back, the February 2026 community reports are stale, and Cisco's own documentation was right. The catalog's only maintenance banner is for the Cisco Security Cloud Control lab, which is unrelated. Catalyst Center Always-On v2.3.3.6 is live and launchable, which is exactly the target the Cisco issue needs.
- Seven always-on sandboxes exist where the plan assumed one. Beyond Catalyst
Center: Catalyst 8000 and Catalyst 9000 (SSH, RESTCONF, NETCONF),
IOS XR (YANG, model-driven programmability), Network Services
Orchestrator, SD-WAN 20.18, and the ACI Simulator. The vendor table
records that no official IOS/NX-OS MCP server exists; that gap now has live,
credentialed, permanently available gear behind it, which strengthens A4 for
cisco.iosandcisco.iosxrand hands A1 targets that needed no sourcing. - Meraki is reservable, not always-on, and that reverses the Cisco plan. The Cisco issue was framed around Meraki because Cisco's hosted MCP server is a Meraki server -- but a reservable sandbox is time-boxed with no stable endpoint, which is a poor fit for a server running continuously in a cluster. Catalyst Center is the only target pairing an official MCP server with an always-on endpoint, so it now leads. The order inside the Cisco issue is DevNet Content Search (no target at all), then Catalyst Center, then Meraki.
- Also catalogued as reservable: Cisco Modeling Labs (full API -- the concrete option for B4's in-cluster simulation), IOS XE on Cat8kv, XRd (containerized IOS-XR), Identity Services Engine 3.4 -- which explicitly advertises "ISE ansible modules" -- NSOLAB, Nexus Dashboard, and a CI/CD pipeline sandbox bundling GitLab, Ansible, pyATS, CML and Open NX-OS. ISE advertising Ansible modules, and a sandbox shipping Ansible and pyATS preinstalled, are evidence Cisco already expects this audience.
- Cisco is now the only genuinely unblocked vendor -- vendor-published servers and a confirmed always-on target -- rather than merely the one with no open Decision A.
Added -- a third use case, and an options brief rather than a design (#94)
docs/plan/network-mcp-plan.mdplans MCP servers on OpenShift for AI-assisted development of Cisco, Palo Alto and Aruba use cases. It is deliberately not a settled design -- three decisions are written open for a network SME to resolve, which is a departure from the other two plan docs and is called out in its Context. The implementation issues are deliberately unopened: Decisions A and B change what the Palo Alto and Aruba issues are, so opening them now would guarantee rewriting them.- The premise did not survive the research. "Use vendor-supplied MCP servers
where they exist" holds for Cisco alone, which publishes three. Palo Alto's
official Cortex MCP server serves XSIAM/Cortex SecOps data, not PAN-OS --
every PAN-OS server is community. Aruba has nothing official at all: its
central-mcp-serveris documented on HPE's own developer portal, which makes it look sanctioned, and the same page says "This is not an officially supported product of HPE." That disclaimer is quoted verbatim in the plan doc rather than paraphrased, because org ownership and portal hosting are not support statements. - The finding that actually defines the work is that every server found -- Cisco's included -- is stdio transport and ships no container image. So this is not a deployment exercise; the foundation is containerize → adapt stdio to streamable HTTP → Route → authenticate → inject credentials from the vault. They are also all read-only already, which means the #93 governance stance (read-only MCP, writes through an AAP job template) costs nothing here -- it describes what the software does rather than restricting it.
ansible.mcpruns the opposite direction from its name, and has been syncing into this repo's PAH since #68 (hub/certified-requirements.yml:37) referenced nowhere else. It gives playbooks modules to discover and call tools on MCP servers -- Ansible as MCP client. It is not a way to expose Ansible as MCP, and anything planned on that assumption would have been wrong.- The AAP MCP server is Technology Preview on 2.6, not only 2.7, so #92 is a sequencing preference for this work rather than a hard block.
- One claim was corrected before this shipped. The first draft said DevNet sandboxes were "temporarily offline as of February 2026" and treated Cisco as fully unblocked. Checking both sources found they disagree: Cisco's Catalyst Center sandbox page still lists an Always-On sandbox with no outage notice, while Community threads from February 2026 report the always-on labs pulled for maintenance with no restoration date. Documentation being stale and the labs being back are equally consistent with that, and no further reading settles it -- someone has to sign in and try. B1 is now recorded as a candidate rather than a plan, and Cisco's "no open decisions" status is scoped to the server side only.
- Adds the
mcpandnetworklabels, backfillingmcponto #92 and #93 so the whole body of MCP work is one query.
Fixed -- skill preflights could never read a vaulted credential (#86)
- Two skills resolved
openshift_api_tokenandaap_passwordwith an ad-hocansible ... -m debugcall. Those live inenv_secretsinplaybooks/group_vars/all/secrets.yml, and Ansible loads agroup_vars/directory adjacent to the inventory or to a playbook -- an ad-hoc command has no playbook, so the file was never loaded and every lookup died with'env_secrets' is undefined. The secrets layout is correct and deliberate (CLAUDE.md-> Secrets: exactly one mechanism); the two snippets simply never caught up with it. ocpvirt-setupreported success on the failure.-m debugprints its errors into the same"msg"field the snippet scraped, so$OCP_TOKENbecame the stringThe task includes an option with an undefined variable..-- non-empty, sotest -npassed and it printed✅ resolved sandbox credentials via vault. It then failed forty seconds later as anHTTP Error 401at the CNV check, which the skill's own troubleshooting table blames on an expired RHDP token. Hit for real on a minutes-old token that was perfectly valid.sales-demos-first-timeStep 7 could not pass on any machine. That is the step whose own text says "Do not declare success until this passes." It read all five values in one call, two of them vaulted.- Both now read each value from where it actually lives: inventory-resolved
values (
aap_env_name,aap_hostname,automation_hub_token) throughansible ... -m debug, which also proves the--limit; vaulted credentials throughansible-vault view | python3, the patternREADME.mdandpah-syncalready used. - The guards now check shape, not just presence --
sha256~*for the token,https://*for the API URL, and aCHANGEMEtest on the password. Checking for a non-empty string is what let an error message pass as a credential. - Verified by running every changed block verbatim against the live sandbox,
including the negative cases: a non-existent environment reports
pw_set=False token_ok=False, and the error text that used to pass is now rejected.
Fixed -- the masthead pill now asks AAP which environment it is (#87)
- Hit for real. A new RHDP sandbox was provisioned,
connection.ymlwas updated and the vault refreshed -- the two stepsCLAUDE.mdsays a new environment takes -- and the masthead showed a greyUNRECOGNIZED ENVpill next to a correctly badged greenSANDBOXsign-in page. Nothing errored, and no CI job referenced the stale file. - The generated hostname map was itself the third place to edit that #54
claimed it avoided. Re-running a generator and committing its output is a
third step, and
aap_hostnamechanges on every rotation, so the map had to be re-synced every time. That entry's reasoning was wrong; this is the correction. target_envreplaces the hostname. The hostname is only a proxy for the environment;target_envis the environment, andcontroller_templates.ymlalready sets it fromaap_env_nameonSales Demos - Provision VMandSales Demos - Teardown VMs. The badge does one same-originGET /api/controller/v2/job_templates/and scans for the field rather than matching a template by name, so a rename cannot break it.assert_target_environment.ymlalready fails a run closed iftarget_envandlimitdisagree, so the value cannot drift.- Measured against the live 2.6 sandbox before writing any of it: the
name-filtered query returns
count: 1;extra_varscomes back as a JSON-encoded string, not an object, and is parsed accordingly; the same request logged out returns401; the AAP document sends no CSP header, so a content-script fetch is not blocked. No new manifest permissions -- the content script already runs on the AAP origin. - Signed out is now distinguished from unidentifiable. A
401/403paints nothing, because the sign-in page already carries the badged logo and a grey pill contradicting a green one two inches away is worse than none. Every other failure still paints the neutral pill. The distinction keys off HTTP status, never the URL -- route-sniffing is the coupling this design avoids. envs.jsonis deleted.make-env-badge-config.pynow emits a colours-onlycolors.jsonand reads nothing fromconnection.yml, so rotating an environment does not require re-running it.env_colors.pystays the single source of truth so the sign-in logo and the pill cannot drift apart.- CI now verifies the generated file, which nothing did before -- a committed generator output that nothing checks is a copy waiting to drift.
- Two claims in
utilities/aap-env-badge/README.mdare corrected rather than quietly dropped: it no longer "reads no AAP data" (it reads one endpoint, and still changes nothing), and "it keeps working when RHDP hands you a new cluster ID" was false when written -- this is what makes it true.
Changed
- The rendered
/etc/motdnow appears in the ocpvirt demo README (#83), which previously said the render script "prints the two login banners" and showed neither. Verified byte-identical to whatrender-demo-assets.pyemits frommotd.j2./etc/issue.netstays in the talk track, where the contrast beat needs both banners shown in order. - Committed screenshots now render inline in the four docs that only named the files (#81): both demo READMEs and both run sheets. The run sheets use thumbnails linked to the full image rather than full-width embeds -- they are read on a second screen while presenting, and eight full-width screenshots turn a scannable checklist into a long scroll. No new or re-captured images.
Added
- PAH demo screenshots committed to
docs/images/pah-*.png(#74): the empty and populated Repositories views plus all three remote Edit dialogs. Demo README updated from "blocked on a token" to "one rehearsal away from Ready."
Added -- a curated repository, so removal actually works (#70)
approved: a fourth Hub repository with no remote, whose contents are declared inhub/approved-collections.ymland reconciled byplaybooks/curate_hub.yml. Unlike the three mirrors, that reconcile removes -- delete a line and the collection leaves the repository. Verified: populate 0 -> 9, idempotent re-run atadd 0, remove 0, changed=0, and a real removal taking it 9 -> 8.- This is the repository consumers should point at. The three synced ones are
mirrors whose contents Red Hat and the community decide; this one holds what
was approved, at exactly the declared versions --
approvedcarries one version ofansible.platformwhererh-certifiedcarries four. - Seeded with the nine collections this repo itself pins, at exact versions. Not arbitrary: it is what makes #69 safe, since AAP would resolve against a repository containing precisely what a project sync needs.
ansible.hub1.1.0 has no repository-to-repository copy, so this drives Pulp directly withPOST {repo_href}modify/, carryingadd_content_unitsandremove_content_unitsin one atomic call. Deliberately not themove/endpoint -- a move takes the collection OUT of the source, so curating intoapprovedwould have silently strippedrh-certified. The whole cycle was proven on a scratch repository before the playbook was written.- The first real run failed, and correctly.
ansible.platform 2.7.20260604was absent from the hub entirely, sitting below the certified 3-version floor. The generator now lowers a floor to any version this repo has pinned, which costs two extra versions across the whole hub -- and--audit-pinsnow reports "Every pinned collection is inside its window", closing gate 2 of #69. - Also corrected: this repo pins nine collections, not ten, in four files and in #69.
Added -- Private Automation Hub as code, the repo's second use case (#68)
- Every environment now configures its Private Automation Hub on every build.
config.ymlapplies three collection remotes and repositories and starts a sync without waiting, sosetup.ymlstays at roughly ten minutes;playbooks/sync_hub.ymland thepah-syncskill are the blocking entry point that waits and then verifies. Content: all Red Hat certified (214) and validated (47) collections windowed to the 3 newest versions of each, plus 15 curated community collections at their current version only. - Pulp has no "keep N versions" control, and
retain_repo_versionsis not it -- that prunes repository snapshots, not collection versions. A requirements entry of a barenamespace.namesyncs every version ever published, and some certified collections have forty. Soutilities/refresh-hub-requirements.pycomputes a>=floor per collection and writeshub/{certified,validated,community}-requirements.yml, all committed. That generated diff is the reviewable artifact the whole use case exists to produce. hub/is deliberately notcollections/.collections/requirements.ymlis what a laptop and the execution environment INSTALL;hub/*.ymlis what PAH SYNCS from upstream. Different direction, different lifecycle, and confusing the two is the likeliest mistake here -- every generated file says so in its header.- A refresh is a script, not a playbook, matching
utilities/build-ee.sh: it writes into the repo checkout so it must never run from AAP, and it is ~260 HTTP calls, which as sequentialuritasks would take minutes and produce output nobody can read. Concurrent, stdlib-only: 25 seconds for all three lists. - The three-token table is the deliverable, not a footnote.
ansible.hubandansible-galaxycall three unrelated credentials "token", and this is where people stall on day one. The Red Hat offline token syncs your hub FROM Red Hat and lives in~/.ansible.cfg; your hub's own API token authenticates clients TO it and is not stored at all; a galaxy.ansible.com token is only needed to publish and is not needed here. Written out indocs/demos/private-automation-hub/architecture.md. sales.demosnow has six-file demo documentation for one use case.clickops.mdholds the full click-by-click UI walkthrough, because the demo's argument is a contrast with doing it by hand and that procedure has to be real rather than a strawman -- and thirty clicks would have destroyed the run sheet's one job, being scannable by someone standing up mid-sentence.- Deliberately not done: no organization Galaxy credential, so nothing in AAP
resolves from the hub yet. That is #69, held behind gates, because a Galaxy
credential makes every project sync depend on the hub being complete. It is
already known to be incomplete --
--audit-pinsreports thatansible.controllerandansible.platformare pinned below their version window. Found by writing the check, not by having it fail in a demo.
Fixed -- three failure modes found by running the sync for real (#68)
- A Pulp sync is additive and the docs now say so. Dropping a collection from
hub/community-requirements.ymland re-syncing left all 15 in the repository. The requirements files are an allowlist for what gets pulled IN, not a declaration of desired state: adding works, changing a version keeps the old one, removing does nothing.ansible.hubPOSTs to{repo}/sync/with no body, so nomirrorflag is sent and Pulp defaults to additive. Same root cause as the>=floor only widening. The honest answer -- a curated repository you create and copy approved versions into, which is a list you can genuinely remove from -- is tracked in #70 rather than claimed here. sync_dependenciesis now false on every remote. It was true for certified on the reasoning that certified collections only depend on each other, so the dependency walk could not escape the curated set. Wrong: the first real sync died on404 .../collections/index/containers/podman/, a collection in neither generated list, pulled in by something that depends on it and absent from console'spublishedrepo. One unresolvable dependency fails the entire sync task, so the repository stays empty rather than partially filled.- The
infra.aap_configurationasync defaults are far too short for a sync, and misreport the failure. Every role wraps its work inasync:and polls withcollect_async_status; the defaults are 50 retries one second apart -- about fifty seconds. A certified sync runs for minutes, not seconds. Left alone the playbook fails withattempts: 50and, because secure logging is on, acensoredmessage that says nothing, while the sync runs happily inside Pulp. Confirmed by querying/pulp/api/v3/tasks/directly: staterunning, notfailed.sync_hub.ymlsets 360 retries at 15s, and narrows secure logging off for the sync role alone -- it carries repository names and no credentials, unlike the remote role, which keeps it. - A trailing newline made every remote report
changed, forever. Pulp stores a remote'srequirements_filewith the trailing newline stripped, so a generated file that has one differs by exactly that character on every comparison and the module rewrites the remote each run. The sync worked and the run was green -- it simply never reportedchanged=0, which is the precise claim the config-as-code demo makes. Fixed with anrstripin the generator and a per-rule.yamllintexemption scoped tohub/; nothing else in the repo is exempt, and both places carry the reason so nobody tidies the newline back. - Two remotes still report
changedand always will, which the talk track now addresses head-on rather than hoping nobody reads the recap.rh-certifiedandvalidatedcarry a token the API never returns, so the module has nothing to compare against;community, with no credential, reportschanged=0. Same behaviourcontroller_settings.ymldocuments forSUBSCRIPTIONS_CLIENT_SECRET-- the platform refusing to hand back a secret, not drift. validate.ymlwould have kicked three live PAH syncs, while printing "Nothing will be changed."ansible.hub1.1.0'scollection_repository_syncreadsmodule.params.get("check_mode"), butcheck_modeis not in its argument_spec -- so it is alwaysNone, the guarded early-exit never fires, and the sync runs for real under check mode. It should bemodule.check_mode. Guarded in two places because one is not enough: the group_varssync:expression carriesnot ansible_check_mode, andvalidate.ymlforceshub_sync_enabled: false.ansible_check_modeis only True for a CLI--check-- a play-levelcheck_mode: true, which is exactly whatvalidate.ymluses, leaves it False. Verified both ways, and verified by counting Pulp sync tasks either side of a validate run.- Check mode cannot validate content, and said so confusingly.
uridoes not run under--check, so registered results come back as bare skip markers with nojsonkey and the first assertion dies on a missing attribute rather than reporting anything about the hub. The verification block is now gated onnot ansible_check_mode. A related trap: Ansible templates aloop_control.labeleven for items thewhenskips, so a label reaching into a skipped result fails the task with an error unrelated to the assertion -- labels now referenceitem.itemonly.
Fixed -- generic sibling-repo references in code comments (#65)
- Three comments cited sibling repositories by directory name as precedent. One
of those names identified an external organisation, which this repo's own rule
does not allow in a tracked file. Replaced with "a sibling daily-demo repo",
which carries the same weight as evidence without naming anyone;
inventory/group_vars/aap/controller_templates.yml,playbooks/provision_vm.ymlandterraform/ocpvirt/backend.tf. Non-identifying references (dc1.azure) are unchanged. - The comments themselves were kept. They record why a pattern was chosen and where else it was verified, which is the kind of note that saves someone an afternoon. Only the identifying token needed to go.
- Found by a full history audit -- every blob in the object store, every commit
message, every ref. Everything else came back clean: no private keys, AWS
keys, GitHub or Slack tokens at any revision; every committed
secrets.ymlvault-encrypted at every revision; no non-Red Hat email addresses; no routable IPs. - No history rewrite. The name is also in one historical commit message, and rewriting 82 commits would change every downstream SHA, break existing PR and issue cross-references, and still not remove it from GitHub -- which serves unreachable commits by SHA long after they leave every branch. Verified directly: two commits reachable from no local ref still resolve through the GitHub commits API. Real removal needs the rewrite plus a Support request, and that is not worth it for a directory name in a comment.
Added -- stage the docs for NotebookLM (#64)
utilities/collect-notebooklm-sources.shand its manifestutilities/notebooklm-sources.txt. NotebookLM takes files rather than repositories and answers only from what it is given, so the corpus is an explicit allowlist and this script turns that list intobuild/notebooklm/, ready to drag into a browser.build/is gitignored -- every staged file is a copy of a tracked one.- The manifest is an allowlist and never globs. No directory is walked, so a repo holding customer material cannot be swept into a Google product by a pattern that was slightly too wide. Each source is a line someone wrote.
- Files are renamed on copy to
<repo>--<flattened-path>.md. Filenames are the only handle NotebookLM shows in its source list and in every citation, and several repos' worth ofREADME.mdwould be indistinguishable at exactly the moment you want to know where an answer came from. - The staged bundle is grepped before it is declared ready, using the same
real-value patterns as
check-no-secrets.sh, and the copies are deleted if anything matches. Placeholders (sha256~CHANGEME,cluster-<id>.dyn...) do not trip it; a genuine token does, and then there is no bundle left to upload. - The corpus starts at this repo only, and deliberately includes
docs/plan/ocpvirt-demo-plan.mdandCLAUDE.md: the notebook's first job is working out what gets refactored intosales.demosover time, and that judgement needs the design rationale and the conventions, not just the docs.
Added -- a real demo page, and the restart-503 (#60)
demo-page-live.png-- the demo page served by an actual guest, not rendered. RHEL 9.8,large-2cpu-6gbresolving tosd1.large, 5642 MB, andKVM (guest)coming out right in production rather than only against the fixture. The cold open uses it now;demo-page.pngstays as the regenerable offline fallback and as what the render script verifies. Both are honest about which they are.- The
Configuredtimestamp on that page predates the capture by 14 minutes, which is itself the proof that the page survived a VM restart on the persistent disk.
Fixed -- two recovery moves that were learned the hard way (#60)
- A 503 with the VM reporting
Runningusually means the guest is still booting. Observed live: the VMI was re-created, and the route 503'd for about two minutes before the guest finished coming up. It self-healed -- the disk is persistent andlinux_configuresets httpdenabled, so the web server returned with no intervention. A presenter who hits this would otherwise start debugging something that is about to fix itself, so the run sheet now says to wait and narrate it as the "three definitions of done" beat. - A connection timeout is never a Route or cluster fault. The router answers a bad route with an instant 503; a timeout means the TCP connection never established, which puts the problem on the local network path -- VPN, proxy, wifi. This distinction cost real time to establish and is now in the recovery table, along with the check that settles it: whether the AAP or console tab also hangs.
Added -- live screenshots wired into the talk track (#58)
- Six images captured from a real run, filling the gap
render-demo-assets.pycannot:aap-survey.png,aap-workflow-running.png,ocp-vms-before.png,ocp-vms-after.png,route-503.png,aap-login-badged.png. The AAP and OpenShift interfaces cannot be rendered from templates, so #56 shipped a Mermaid graph and a checklist; this is that checklist cashed in. - The before/after namespace pair is the strongest of them, and it was not
on the requested list. Empty project, then one VM
Runningat the tier that was asked for, gives Beat 4 a visual spine it did not have. route-503.pngshows the Route live and correctly serving nothing. In a browser this lands harder thancurl -sIoutput — the hostname is on screen, and it encodes the whole story: VM name carrying the requested tier, the-webService, the namespace.LiveMigratable=Trueis visible in the namespace screenshot, and the talk track says there is no live migration. Both are true and the tension is real: the condition means the VM is eligible to migrate — shared storage, nothing pinning it to a host — it simply has nowhere to go on a single node. A sysadmin reading that Conditions column will call it out, soobjections.mdnow carries the precise answer rather than leaving it to be improvised.- The shots come from several different launches at different tiers —
smallin the survey,mediumin the 503,largein the namespace shot. The use-case README says so outright. They illustrate the mechanism; claiming they were one continuous run would be the kind of small dishonesty this repo's documentation does not do. aap-job-timings.pngturns the timing table from estimate into evidence. One real workflow run, node by node: provision 36 s, register 4 m 25 s, configure 3 m 49 s, check 5 s, 9 m 9 s total. The "about nine minutes" figure the docs have carried in four places is confirmed, and the shape behind it is now visible — 90% of the run is register plus configure, attaching to the CDN and then pulling packages over it. The machine itself exists in under 40 seconds.- The 36-second provision job sharpens the "three definitions of done" beat
into four. The provision node reports Success while the guest is still
booting: a green checkmark is not a usable server, which is exactly why
register_vm.ymlopens withwait_for_connectionand why register's 4 m 25 s includes a stretch spent waiting on a machine the previous job already called done. - Both the talk track and the run sheet now say to put the job list on screen rather than assert a duration. Durations in a controller's own job list are evidence; a presenter's estimate is not.
route-503.pngwas cropped to drop a visible bookmarks bar. The run sheet now says to hide it before shooting.- The run sheet's screenshot checklist is now split into captured and
outstanding, the highest-value remaining shot being the 200 half of
route-503.pngin the same browser frame.
Added -- documentation you can present from (#56)
docs/demos/, a talk-track tree with one directory per use case. The first isopenshift-virtualization/, ready to present in a 30-minute slot. Everything written down until now —README.md, the plan doc, the eightSKILL.mdfiles — is written for the person building the automation. Nothing was written for the person showing it.- Five documents per use case, and the split is the point.
run-sheet.mdis the live layer: minute markers, what is on screen, exact commands, recovery moves, scannable by someone standing up with an audience waiting.talk-track.mdis the rehearsal layer: prose, the actual words, why each beat exists. Thenarchitecture.md,objections.md, and aREADME.mdentry point. One document trying to do the first two jobs is too long to present from and too terse to learn from. - It works with no cluster, which was the requirement that shaped everything else. A demo environment expires, a slot moves, a colleague reads it on a plane.
utilities/render-demo-assets.pyis what makes that possible. Two of the three things a customer actually looks at are Jinja templates inlinux_configure/templates/, so they render on a laptop with nothing running: it rendersindex.html.j2against a representative fixture, screenshots it with headless Chrome todocs/images/demo-page.png, and printsmotd.j2,issue.j2andfacts.jsonas text for the talk track. Same convention asmake-env-logo.py— a generated image committed underdocs/images/beside the script that regenerates it.- The screenshot is rendered, not photographed, and the script header, the image caption and the use-case README all say so. It is accurate — the guest serves that exact template — but it is not a capture of a live run, and a public repo should not imply otherwise.
trim_blocks=Trueis not optional in that script. Ansible defaults it True and Jinja defaults it False, so with Jinja's default the newline after every{% for %}survives andmotd.j2's "Powered by" list renders with a blank line between each credit, tearing the boxed banner apart.- The fixture keeps
ansible_virtualization_type: "NA"deliberately. That is what a KubeVirt guest genuinely reports, and it is whyindex.html.j2cannot use| default()— "NA" is defined, so the default never fires. Using the real value means the committed PNG exercises that branch instead of hiding it. - The logos must be staged beside the rendered HTML.
index.html.j2referenceslogos/rhel.svgrelatively; render the file alone and the screenshot shows three broken-image boxes where the product marks belong. The script copies the directory into the temp dir, and the verification step is to open the PNG and look. - The AAP UI cannot be rendered, so the workflow is a Mermaid graph and the survey a table — arguably better than screenshots for a talk track, since both survive dark mode and a gateway upgrade. The run sheet ends with a checklist of screenshots worth capturing next time an environment is up.
docs/demos/_template/was extracted from the finished use case rather than authored ahead of it, so it carries the shape that actually worked. Private Automation Hub (ClickOps vs. configuration-as-code) is a named row in the index with no stub directory — an empty folder is worse than a line in a table.docs/plan/is untouched: it answers why the automation is built this way,docs/demos/answers how to show it. Different readers, different lifecycles.
Added -- the environment is now marked AFTER login too (#54)
utilities/aap-env-badge/, an unpacked MV3 Chrome extension painting aSANDBOX/DEMOpill in the middle of the AAP masthead. The sign-in logo frommake-env-logo.pymarks the environment you are entering; it disappears the moment you log in, which is when you start clicking things.- No gateway setting can do this, and that is now measured rather than
assumed. On the live 2.6 gateway,
/api/gateway/v1/settings/all/returns 44 settings and onlycustom_login_infoandcustom_logoare branding-related — andcustom_logowas already applied (26 KB of base64 PNG) while the masthead still rendered the stock lockup. Anything further server-side means patching a bundled asset in the gateway container, which the operator reconciles away. So: browser-side, and it touches nothing on the cluster. - An overlay, not DOM surgery. One
position: fixedelement appended to<body>; AAP's own markup is never modified. The masthead is PatternFly with version-prefixed class names, so anchoring inside it would break on a gateway upgrade. All it depends on is a<header>existing. - An unrecognized RHDP AAP host gets a neutral
UNRECOGNIZED ENVpill. Not a fallback — a freshly built environment nobody has recorded yet is exactly when you are most likely to act on the wrong cluster. envs.jsonis generated fromaap_hostnamein eachgroup_vars/<env>/connection.ymlbyutilities/make-env-badge-config.py, so a new RHDP environment does not become a third place to edit. A stale hand-maintained map would not error; it would label the wrong cluster with the right colour, which is the exact mistake the badge exists to prevent.utilities/env_colors.py— the colour convention lifted out ofmake-env-logo.pynow that two things paint an environment marker. The sign-in logo and the masthead pill cannot drift apart. Kept dependency-free: the badge generator needs neither Pillow nor ImageMagick.- The three places stating the environment could not be marked post-login are corrected to say what is actually true — no setting can, and here is what does.
Fixed -- a stale Terraform state lock now says how to clear it (#46)
- Hit for real: a
Sales Demos - Provision VMjob was cancelled mid-apply, and every run afterwards failed withError acquiring the state lock. The kubernetes backend releases its lock when terraform exits, and a job that is cancelled, times out, or has its pod evicted never gets there — so the lock outlives the run that took it. playbooks/tasks/terraform_lock_check.yml, shared byprovision_vm.yml(apply) andteardown.yml(destroy). On a failure that names a lock it fails with the lock ID, the holder, and the exactforce-unlockcommand, and states plainly that nothing was changed — the lock is taken before any work starts. Any other failure falls straight through to the existing message.Who:is misleading in AAP and the message says so. It shows a pod name like1000770000@automation-job-92-qswfk, which reads as a run in progress. That pod is gone; waiting never clears it.- The backend locks with a Kubernetes Lease
(
lock-tfstate-default-<env>insales-demos-tfstate), so whether a lock is actually held can be checked withocand no terraform at all — an empty.spec.holderIdentitymeans the failure is something else. Both the failure message and the skill give that command, because it is current whereWho:is a fossil. - Nothing force-unlocks automatically, deliberately. A stale lock is a rare recoverable annoyance; force-unlocking a live apply is a rare unrecoverable one. Doing it safely would need a liveness check against the AAP job, not the pod name in the error. Do not "improve" this into an automatic unlock.
- Troubleshooting entries added to the
ocpvirt-provisionandocpvirt-teardownskills. Teardown is the likelier victim: the nightly schedule can start while a manual job is still running.
Fixed -- laptop access details were wrong, and invisible (#49)
- The
ssh_commandoutput emitted a flag that no longer exists. It builtvirtctl ssh -n <ns> --local-ssh <user>@<vm>; virtctl v1.x removed its built-in SSH client, so local ssh became the only mode and--local-sshwas deleted rather than defaulted. The output failed withunknown flag: --local-sshbefore connecting. It also omitted thevm/resource prefix virtctl needs to tell a VM from a VMI. Verified working on virtctl v1.6.6:virtctl ssh -n <ns> <user>@vm/<vm-name>.-t/--local-ssh-optsis the surviving way to pass ssh options. - The job that produces the demo URL did not print it.
web_urlappeared only in the Provision log, tagged "503 until Phase 4 installs httpd" — whileConfigure VMs/Run Demo, the job that makes it return 200, said only "Public URL comes from the terraform outputweb_url".Check VMsnever mentioned a URL at all. All three now print the live URL and the laptopvirtctlline. web_urlandssh_commandare registered as AAP host variables byprovision_vm.yml. They cannot be recomputed downstream:configure_vm.ymlandcheck_vm.ymltargetlinuxweb, a group created at run time, whileocpvirt_namespaceandopenshift_apps_domainlive ingroup_vars/<env>/connection.ymland load only for thesandbox-local/demo-localhosts in theaapgroup.set_statsdoes not reach them either — it feeds workflow nodes, not a job re-run on its own. Neither value is a secret. Guests provisioned before this fall back to the terraform outputs rather than failing on an undefined variable.
Added -- login banners on the demo guests (#50)
- Two different messages, for two different moments.
templates/issue.j2is the legal authorized-use notice, rendered to/etc/issue(console) and/etc/issue.net(network, via sshd'sBanner) and shown before anyone has proved who they are — no branding, no product story, no demo URL.templates/motd.j2is the branded ASCII art, rendered to/etc/motdand shown after authentication.virtctl sshused to land on a bare prompt for both. This reverses the #5 port decision below: that dropped the MOTD/issue/banner set alongside two bundled images to keep personal assets out of a public repo, which is an argument about images, not text. - The art says what this demo actually is — Red Hat OpenShift Virtualization — rather than naming a different demo story.
- The pre-authentication half touches sshd, so it is deliberately careful.
sshd is how AAP reaches every one of these guests — including the connection
running the play itself. So: a drop-in at
/etc/ssh/sshd_config.d/99-sales-demos-banner.confrather than an edit tosshd_config;validate: sshd -ton the candidate file, so a config the daemon would reject fails the task instead of reaching it; and a reload, never a restart. Ifsshd_confighas noIncludeline the drop-in would be silently ignored, so the role checks and skips with a warning rather than editingsshd_configdirectly.linux_configure_ssh_banner: falseopts out. - Both
/etc/issueand/etc/issue.net. They are not interchangeable — getty prints the first on the console, sshd sends the second over the network. Writing only one leaves a login path with no notice on it. /etc/motdrather than/etc/motd.d/—pam_motdon RHEL 9 reads both, but/etc/motdneeds no assumption about the guest's PAM stack. Nocowsaypackage: the cow is static text in the template.- The tagline and the "Powered by" block live in
defaults/main.ymlas data, so another demo story can swap them with-e. They are padded to the box width by the template'sformatfilter, so an override cannot knock the right border out of alignment. They name what this demo actually runs — OpenShift Virtualization, Terraform, AAP, Insights — because a login banner reads as a claim to a technical audience. linux_configure_banner_ownernames the system's owner in the legal notice. The wording is conventional boilerplate, not legal advice; replacetemplates/issue.j2outright if there is approved text to use instead.- The demo URL is printed below the box, not inside it: a Route hostname runs
to roughly 84 characters and would tear the border apart. It comes from the
web_urlhost variable (#49), and is simply absent on a guest provisioned before that. linux_configure_motd: trueturns the whole thing off.
Added -- Phase 4: the demo itself (#5)
playbooks/run_demo.ymlwithplaybooks/roles/linux_registerandplaybooks/roles/linux_configure, theocpvirt-demoskill, and aSales Demos - Run Demojob template. Verified from AAP: the demo URL went from503 Service Unavailableto200 OK, serving a page built from the guest's own facts (sd1.small, 1 vCPU, 1620 MB). That closes the loop #29 opened — the Route existed from provisioning and had nothing behind it.- Registration is the first step, not an afterthought. The CNV
rhel9image ships with no repositories and no subscription:dnf repolistreports none anddnf installfails outright, so every demo story — webserver, patching, compliance — is dead on arrival. It is invisible until you try, because the VM boots and answers SSH perfectly.linux_registeruses the certifiedredhat.rhel_system_roles.rhcrole and then verifies repositories actually appeared, since registration can succeed while no entitlement matched and the resultingdnffailure points nowhere near the cause. rhsm_org_idandrhsm_activation_keyadded to the vault. The org ID is there too, bending the file's "credentials only" rule: the only global plaintext file is committed to a public repo and an org ID identifies a Red Hat account, so splitting one logical pair across two files would be worse.- Ported from
dc1.azureand trimmed — the MOTD/issue/SSH banner set and the two bundled images (a Red Hat logo and a personal QR code) are dropped rather than carry personal assets into a public repo. The page is self-contained: no external images, fonts or CDN, because it is served from a cluster whose egress you do not control, in front of a customer. (The MOTD came back in #50 — the personal-assets argument was about the images, not the text.) - Reboot-after-patching is off by default, unlike
dc1.azure. A reboot mid-demo takes the page away with someone watching, and these VMs are rebuilt nightly anyway.-e linux_configure_reboot=truewhen patching is the demo. - Firewalld inside the guest is opened explicitly. It is separate from anything OpenShift does, and without it the Route still returns 503 with httpd running perfectly.
Fixed -- three layout assumptions this exposed (#5)
- Roles must live playbook-adjacent. Ansible resolves roles relative to the
playbook directory, so
playbooks/roles/is searched and repo-rootroles/is not — and it cannot be added to the search path without a project-localansible.cfg, which this repo forbids. The rootroles/.gitkeepfrom the original skeleton was aspirational and is removed rather than left to mislead. - The secrets file moved from
group_vars/aap/togroup_vars/all/.aapscopes it to hosts in that group; every playbook until now targetedhosts: aap, which made it indistinguishable fromall.run_demo.ymlis the first to target the VMs, and they never received the vars — failing an assert that blamed a missing Vault credential which was attached. This is the file's third location today, so the reasoning now lives beside it:inventory/broke the AAP inventory sync (#4),aap/misses VM-targeted plays. .ansible-lint— mockansible.posix.firewalldand theredhat.rhel_system_roles.rhcrole. CI lints offline, and this is the second time that gap has only surfaced there.ANSIBLE_COLLECTIONS_PATHdoes not reliably reproduce it; cross-checking every FQCN inplaybooks/against the mock lists does. Also fixed a duplicatemock_roles:key that silently dropped the new entry.
Added -- the ocpvirt-provision skill that #4 never shipped (#42)
.claude/skills/ocpvirt-provision/— #4 named it as a deliverable and shipped the playbook and job template without it.README.mdlisted it as Done, so the gap was invisible. That broke the contract inCLAUDE.md: "Every phase is runnable as a skill and as an AAP job template." Phase 3 had one entry point..github/workflows/lint.yml— the skills gate is now bidirectional. It checked that every skill appears in the README, but not that every skill named in the README exists, which is exactly how this slipped through green CI. A row may name a missing skill only if explicitly marked "Not started".
Fixed -- contributor docs contradicted the repo's actual rules (#42)
.github/SECURITY.mdand.github/PULL_REQUEST_TEMPLATE.mdboth told contributors to put environment-specific values in a gitignoredsecrets.ymlrather thanconnection.yml— the reverse of the truth since #18 — and to replace RHDP URLs with placeholders, which contradictsCLAUDE.md, where they are the documented exception and committed on purpose. Anyone following either would have broken both environments, andcheck-no-secrets.shdeliberately does not flag RHDP hostnames, so CI would have stayed green. SECURITY.md now states where each class of value lives and why the secrets file is tracked rather than ignored.inventory/group_vars/aap/aap_settings.yml— header still described the pre-#18 model, including the claim that hostnames live in the secrets file.CLAUDE.md— one leftover "gitignoredsecrets.yml" phrase.
Changed -- docs caught up with two live environments (#42)
ROADMAP.md— gains a status column and theocpvirt-new-envrow it never had. It previously read as entirely unbuilt.docs/plan/ocpvirt-demo-plan.md— "Tonight's scope" and "Implementation plan (tomorrow)" are marked historical, pointing atROADMAP.mdfor status. The quay namespace open item is resolved (quay.io/zigfreed), leaving only the private repository Phase 2 still needs..claude/skills/sales-demos-first-time/— added the command-line tools the playbooks shell out to. It covered collections and the python client but notterraform,virtctl,podmanoransible-builder, so a new machine could complete every step and still not provision a VM..claude/skills/collections-sync/— a pin change is not finished until the EE is rebuilt.collections/requirements.ymlfeeds both the laptop and the execution environment; bumping a pin without rebuilding makes the two resolve different code, which is the drift the pins exist to prevent, and nothing detects it because both halves are internally consistent.
Changed -- setup.yml is now the one-command path (#1)
playbooks/setup.ymlimports three stages in order:install_cnv.yml,config.yml,prepare_env.yml. A bare RHDP environment becomes demo-ready in one command, which is what #1 asked for — CNV installed, AAP configured, and a real VM built and timed to prove it. Roughly 10 minutes, on top of RHDP provisioning the environment itself.- Each stage stays runnable on its own.
setup.ymlis a convenience, not a bottleneck:install_cnv.ymlwhen only a cluster needs CNV,config.ymlwhen only AAP objects changed,prepare_env.ymlto re-check an idle environment. - The AAP half is config-as-code rather than a ported bootstrap path. #1
described porting one from
aap.as.codeand flagged the cost itself: "the bootstrap step duplicates logic aap-skills/aap.as.code already owns and can drift." Applyinginventory/group_vars/aap/*.ymlthrough the dispatch role avoids that second copy and is idempotent — re-running converges rather than re-bootstraps. - Automation Hub credentials are deliberately not created, closing #1's
remaining bullet as obsolete rather than unbuilt. AAP would use them to install
collections/requirements.ymlat project sync, and the execution environment already carries every pinned collection (#31). Verified on the live sandbox: no organization has a Galaxy credential, the sync's collection play reportsok=3, changed=0, and job templates run green regardless. Adding one would only make every sync re-install what is already baked in.
Fixed -- prepare_env no longer waits 15 minutes to report a 44s answer (#39)
playbooks/prepare_env.yml— the smoke-namespace cleanup ran withwait: trueand dominated the whole playbook. Measured across two live environments, an identical 44s/45s build produced a total runtime of ~2.3 min on a warm cluster and ~17.5 min on a fresh one, because deleting the namespace blocks on DataVolume and PVC teardown, which on a freshly installed cluster contends with the CSI clone still materializing underneath. The playbook was slowest on exactly the environment where the answer matters most. Nowwait: false— 42s total on the environment that previously took 17m29s, a 25× reduction with the same verdict. The namespace still goes away; it was observed gone within a minute, unattended.
Changed -- the real end-to-end timings are written down (#39)
- The docs quoted "5m47s cold, ~30s warm" for a VM build, but never said how
long a fresh RHDP environment takes to become demo-ready. Now stated in
README.mdand theocpvirt-new-envskill: ~4 min to install CNV, ~2 min to verify, and roughly 20 minutes end to end from a bare RHDP environment — most of which is the environment provisioning itself. - Two corrections recorded rather than quietly dropped:
- The 5m47s cold build did not reproduce. A brand-new environment built in
44s, the same as a day-old one: all six boot-source VolumeSnapshots were
readyToUsebefore CNV finished installing, because the import runs alongside the install. The original figure most likely came from building immediately after install and catching the import mid-flight. - The CNV install is ~4 minutes, not the ~15 stated while #30 was in progress — that was inferred from a background task's apparent runtime rather than measured.
Added -- fresh-environment readiness (#30)
playbooks/prepare_env.ymland theocpvirt-new-envskill. Answers one question — would a live VM build in front of a customer be fast? Measured on the sandbox: 5m47s cold versus ~30s warm, and that gap is not Terraform's doing. The module is already on the fast path; the slow case is building against a cluster whose boot source has not finished importing, so the fix belongs in environment spin-up rather than the VM definition.- It asserts rather than assumes, because every check corresponds to a way an environment looks fine and is still slow:
- The
rhel9DataSource can reportReadywhile the VolumeSnapshot behind it is still materializing — the actual slow-build state. The snapshot is resolved fromspec.sourceby name and checked forreadyToUse, rather than inferred from the DataSource condition. Handles the PVC form too. - A StorageProfile reporting
copyinstead ofcsi-clonemakes every create pay a full disk copy, which no amount of pre-warming fixes. On RHDP the default StorageClass must be the ceph-rbd one; noobaa reportscopy. - The IngressController must actually be Available, or the Routes giving demo
VMs their web URL (#29) are never admitted. A mismatch between
openshift_apps_domainand the cluster's real domain warns rather than fails — a stale value produces URLs that resolve nowhere. - And it builds a real VM, times it, and destroys it. A playbook that has
verified everything except "can this cluster make a VM" is the failure mode it
exists to prevent. The smoke VM lives in its own namespace, removed in an
always:block so a slow or failed run leaves nothing eating the memory budget. It uses Red Hat'su1.smallrather than the repo'ssd1.*types, which do not exist untilterraform/ocpvirthas run — and this playbook is for clusters where it has not. playbooks/tasks/resolve_storage_class.yml— the StorageClass discovery extracted out ofinstall_cnv.ymlso both use one definition rather than two that drift, the same reasoning that extractedassert_target_environment.ymlin #24.
Changed -- documentation caught up with the code (#30)
ROADMAP.md— the sizing table still listedu1.small/u1.medium/u1.large. #2 moved to repo-ownedsd1.*types becauseu1.*has no 6 GiB size: atu1.large's 8 GiB,os_type=bothneeds ~16.6 GiB against the ~14.2 GiB actually free once AAP and CNV are running, so it would never schedule. Also notes that the real ceiling is enforced inlocals.tfat plan time, not by the table.docs/plan/ocpvirt-demo-plan.md— the state backend said "local state initially; optionally the NooBaa S3 endpoint later", which #4 found unworkable. Now records thekubernetesbackend and why state lives in its own long-lived namespace.inventory/group_vars/demo/connection.yml— thedemoenvironment is live rather than placeholders, so #16's environment isolation is now load-bearing instead of theoretical:--limit demoand--limit sandboxreach two different clusters.
Changed -- the EE is pulled from Private Automation Hub (#35)
inventory/group_vars/aap/hub_ee_registries.ymlandhub_ee_repositories.yml— PAH mirrorsquay.io/zigfreed/sales-demos-eeinto a localsales_demos_eerepository, and Controller pulls the local copy. quay stays the published artifact and the source of truth; this removes quay.io from the demo's runtime dependencies and makes the pull cluster-local rather than an internet round trip.- The sync has two gates and needs both, which is invisible if you only read
one file: the repository item must carry
sync: true, and a variable namedhub_ee_repository_syncmust be defined (dispatch includes the role on... is definedand never reads the value). Miss either and there is no error — the repository is created, stays empty, and Controller later fails to pull an image that was never mirrored. That flag is deliberately not suffixed_all: it is a scalar, and dispatch's wildcard merge handles only lists and dicts. controller_execution_environments.yml— image is now{{ aap_hostname }}/sales_demos_ee:v1.0.0. Templated on purpose: PAH is fronted by the AAP gateway on the AAP hostname, which differs per environment, so a literal would make this shared_allentry wrong for one of sandbox/demo. The name uses underscores because Hub repository names allow only alphanumerics and underscores.controller_credentials.yml—Sales Demos - PAH Registry(Container Registry). PAH requires authentication for container pulls even when the repository is not private, so this is needed regardless of visibility.collections/requirements.yml—ansible.hubpinned to 1.1.0. It drives the Hub objects and was unpinned and drifting: 1.0.4 was installed locally while the execution environment ships 1.1.0.
Notes -- why PAH works here without weakening the cluster (#35)
- AAP 2.6's gateway proxies Hub by path at
/api/galaxy/; there is no separate hub route.ansible.hub'sah_path_prefixalready defaults togalaxy, so nothing needs overriding. - The
*.appscertificate is issued by Google Trust Services and is publicly trusted, so the cluster pulls from PAH over TLS with noinsecureRegistriesand noadditionalTrustedCA— both verified still empty after the change. - Verified end to end:
skopeo inspectagainst PAH returnssha256:a6ee9e4b110bc12d47b222af93127f8fae9f8e3d02599dd8f1b35e3204d3559b, byte-identical to the quay original, and both job templates ran to success on the PAH-sourced image.
Added -- Phase 3: run playbooks from AAP, and against the VMs (#4)
playbooks/provision_vm.yml— ported fromdc1.azure. Asserts inputs, runsterraform init/applyagainstterraform/ocpvirt/, and registers the VMs into AAP (linuxwebwith SSH vars,windemowith WinRM vars). The output shape is preserved field-for-field, so Phase 4 needs no adaptation.terraform/ocpvirt/backend.tf— state moves to the kubernetes backend. Local state is fatal from AAP: an execution-environment pod is ephemeral, so state would vanish with the job and teardown (#6) would have nothing to destroy from. State lives in a Secret in a long-lived namespace of its own, deliberately not the VM namespace —oc delete project sales-demos-sandboxis the obvious way to clean up a demo and must not take the state with it.secret_suffixkeyssandboxanddemoapart.playbooks/check_vm.ymland theSales Demos - Check VMsjob template — the proof that AAP can run playbooks against a VM, not merely create one.- Config-as-code in
inventory/group_vars/aap/: project, both inventories, the inventory source, credentials, and both job templates.
Changed -- the vaulted secrets file moved (#4)
inventory/group_vars/aap/secrets.yml→playbooks/group_vars/all/secrets.yml. Ansible loadsgroup_vars/beside the playbook as well as beside the inventory, so playbooks resolve it identically. AAP does not: an SCM inventory source runsansible-inventory, which parses everygroup_varsfile next to the inventory. Verified against live AAP 2.6 — the vaulted file underinventory/group_vars/makes the sync die withERROR! Attempting to decrypt but no vault secrets found; it cannot be given the password, because AAP rejects Vault credentials on SCM sources outright; and a custom credential type injectingANSIBLE_VAULT_PASSWORD_FILEwould work but is the wrong answer, since the sync would then writeenv_secretsand the SSH private key into AAP's inventory variables in plaintext. Moving it keeps secrets out of the inventory tree whileconnection.ymlstill syncs freely.inventory/group_vars/{sandbox,demo}/connection.yml—demo_ssh_public_keyfilled in. Both were empty, which made cloud-init emitssh_pwauth: truewith no authorized key and no password: the guest had no credentials at all and was unreachable by SSH, byvirtctl, by anything.inventory/group_vars/aap/controller_projects.yml—scm_branchaccepts asales_demos_branchoverride. A job template validates itsplaybook:against the project's current checkout, so without this no config-as-code referencing a new playbook can be tested before merging.
Fixed -- the private-key check never worked (#4)
utilities/check-no-secrets.sh— the private-key pattern starts with-----, whichgrepparsed as an option bundle.greperrored, the error was swallowed by2>/dev/null || true,hitscame back empty, and the check reported pass on files that plainly matched. Fixed with-e, and verified by planting a real key in a tracked file and watching the check fail. This is the guard that stops a private key reaching a public repo; it had been inert..ansible-lint—yaml[line-length]moved towarn_list, matching what.yamllintalready declared. An SSH public key is a single 575-character token that cannot be wrapped without risking silent base64 corruption.
Notes -- how AAP reaches the VMs (#4)
- No bastion and no
virtctl. AAP runs on the same cluster as the VMs, each VM has a headless Service giving stable in-cluster DNS, and there is no NetworkPolicy between the namespaces — so it is plainsshto port 22 at the addressprovision_vm.ymlalready registers.virtctl sshis the laptop path, because a laptop is outside the cluster; the execution environment does not ship the binary. - The kubernetes backend will not accept a bare host + token despite
advertising those keys — it builds its client through client-go's
clientcmd, where they are only overrides on a base config. The playbook synthesises a kubeconfig and passesconfig_path;insecuremust be passed separately because the backend ignoresinsecure-skip-tls-verifyfrom the file. ansible.controller4.8.0 has nocontroller_oauthtoken; the parameter isaap_token. A gateway token fromansible.platform.tokenreturns 401 against/api/controller/v2/on AAP 2.6, so the playbook uses basic auth likeplaybooks/config.yml— and then has no token to leak or clean up.
Added -- execution environment with terraform (#31)
execution-environment.yml— the image AAP runs this repo's playbooks on, built onee-supported-rhel9(AAP 2.6). It exists for one reason: Phase 3 (#4) drivesterraform/ocpvirt/throughansible.builtin.command, and no stock execution environment ships the terraform binary. Terraform 1.15.8 is downloaded and sha256-verified rather than installed from the HashiCorp yum repo — one pinned version, one checked artifact, no extra repo config on a UBI base with no subscription.curlandunzipare already in the base image.- The base image is pinned by digest, not tag.
latestmoves, and the registry publishes no immutable tag matching whatlatestcurrently resolves to (itsversion/releaselabels are absent fromRepoTags), so the digest is the only thing that names one build. This followsaap_config. dependencies.exclude.python: [systemd-python]. ansible-builder introspects every collection in the image, not just the ones requested.ee-supported-rhel9shipsansible.eda, whoserequirements.txtlistssystemd-pythonfor its journald event source; no wheel is published, so pip builds from source and fails withCannot find libsystemd or libsystemd-journalon a UBI base with nosystemd-devel. Nothing here has a journald event source, so the dependency is pure collateral from the base image and is excluded rather than compiled. (aap.lightspeed.patchingcompiles it instead — correct there, because that EE is onee-minimalwhere the dependency arrives through a collection in use.)options.package_manager_path: /usr/bin/microdnf—ee-supported-rhel9ships microdnf, not dnf, and ansible-builder defaults to/usr/bin/dnf.utilities/build-ee.sh— the build entry point. Stages~/.ansible.cfginto the gitignored.ee-build/so the galaxy stage can install certified collections, asserting first that it is a real file: ansible-builder'sCOPYdoes not follow symlinks, so a symlinked config silently yields an image with no Hub token. It is staged rather than referenced in place because an absolute/home/<user>/path is not portable and a trackedansible.cfgat the repo root would shadow~/.ansible.cfgand break certified installs machine-wide. The token reaches the galaxy build stage only; the published image carries no credential.- The script verifies the built image as UID 1000, which is who AAP runs a
job as —
terraform versionmust execute, and every collection pinned incollections/requirements.ymlmust be present at exactly that version. The in-Containerfile check cannot do this: ansible-builder emitsUSER 1000after everyappend_finalstep, so those steps all run as root. inventory/group_vars/aap/controller_execution_environments.yml— registersquay.io/zigfreed/sales-demos-ee:v1.0.0in AAP, applied byplaybooks/config.ymlvia the dispatch role like every other object. It lives ingroup_vars/rather thandemos/ocpvirt/because dispatch readsgroup_varsimplicitly and nothing loadsdemos/ocpvirt/yet; it can move when #4 adds a loader. A public quay repository on purpose, so the cluster pulls it with no image pull secret and no AAP registry credential.collections/requirements.yml—cloud.terraform4.0.0 pinned. The binary, not this collection, is the hard requirement for Phase 3, but pinning it keeps the module set identical on both entry points and letsansible-lintresolve it locally..claude/skills/sales-demos-ee-build/— build, verify, and publish the EE. No playbook, deliberately: likecollections-syncit touches a laptop and a registry, never a demo environment, so it must never run from AAP. Carries the immutable-tag rule and the build gotchas.
Added -- public SSH and HTTP access (#29)
terraform/ocpvirt/variables.tf—demo_ssh_public_keyvariable. When set, cloud-init injects the key viassh_authorized_keysand disables password-based SSH (ssh_pwauth: false). A public key is not a credential, so it lives in each environment'sconnection.ymlbesidelinux_admin_username, not in the vault. TheaccessCredentials+qemuGuestAgentmechanism was tried first but the RHEL 9 cloud image's guest agent fails with "failed to create directory '/home/cloud-user/.ssh': File exists" — a QEMU guest agentmkdirbug — and theguest-execfallback is disabled by RHEL 9's security policy. Cloud-init works reliably; the trade-off is that key rotation requires a VM restart rather than a live push.terraform/ocpvirt/variables.tf—openshift_apps_domainvariable, the*.appsingress domain used to construct Route hostnames at plan time. Required for HTTP access; without it the Route and web Service are skipped.terraform/ocpvirt/main.tf—-webClusterIP Service (port 80) androute.openshift.io/v1Route per Linux VM. The headless Service is unchanged (in-cluster DNS for AAP inventory). The Route returns 503 until httpd is installed by the AAP demo content (#5); that is expected, not a bug.terraform/ocpvirt/outputs.tf—web_url(the Route URL, null whenopenshift_apps_domainis unset) andssh_command(thevirtctl sshcommand for the current VM, null whenos_typeexcludes linux).inventory/group_vars/{sandbox,demo}/connection.yml—demo_ssh_public_keyandopenshift_apps_domainfields added to both environments.
Notes -- NodePort spike (#29)
- NodePort was spiked on the RHDP sandbox cluster and is filtered. The RHDP
firewall blocks high ports —
ssh -p <nodePort> cloud-user@<public-ip>returns "No route to host". SSH access usesvirtctl sshinstead, which tunnels over the Kubernetes API (port 6443, confirmed open). The spike Service was created, tested, and deleted in a single session; no residue remains.
Added
terraform/ocpvirt/— Phase 1. Provisions Linux and Windows VMs sized bysd1.*cluster instance types, each with a headless Service giving a stable in-cluster DNS name, since an OpenShift Virt VM has no plan-time-knowable address. Thelinux_inventory/windows_inventoryoutput shape is preserved field-for-field fromdc1.azure/terraform, which Phases 3 and 4 consume. A precondition enforces the guest-memory budget so an over-budget request fails inplanrather than leaving a VMPending. Verified on the sandbox: VMRunningandReadyin 5m47s, PVCBound,terraform planclean. (#2)
Fixed
terraform/ocpvirt/—terraform plancould never come back clean, so the module could not be trusted to report real drift. Two independent causes, both cases of the cluster owning fields Terraform believed were its own:- The namespace drifted forever. OpenShift's SCC controller stamps every
namespace with the UID/GID/MCS ranges it allocated plus the derived
pod-security level; Terraform planned to strip all four on every run and the
controller put them straight back. Applying it would also have handed the
guests a different UID range than the one their pods were admitted under.
Now ignored via
lifecycle, as cluster-owned. spec.template.metadataisx-kubernetes-preserve-unknown-fields, so the provider has no schema and infers the object type from the manifest — making the key set load-bearing. KubeVirt's webhook addskubevirt.io/pci-topology-versionand a nullcreationTimestamp, which the manifest never declared, so plan failed reading the refreshed object back and apply failed with "Provider produced inconsistent result".computed_fieldsdoes not help here: it can override a value but cannot add a missing attribute. Both keys are now declared, withcomputed_fieldsstill covering their values.playbooks/install_cnv.yml— installs OpenShift Virtualization: namespace, OperatorGroup,kubevirt-hyperconvergedSubscription on thestablechannel, and theHyperConvergedCR, then waits for the operator to reportAvailableand the RHEL boot-source DataSource to beReady. Idempotent, no prompts, every input viaextra_vars. Discovers the cluster's default StorageClass at run time rather than hard-coding one, so it is not tied to a single catalog item. Deliberately does not enable hugepages, KSM, or workload partitioning — each writes a MachineConfig and reboots the node, and AAP is co-resident on the only node in this catalog item. (#1)playbooks/setup.yml— Phase 0 entry point; currently importsinstall_cnv.yml. The AAP bootstrap half of #1 imports here when it lands, so the skill, the README table, and the future job template never re-point..claude/skills/ocpvirt-setup/SKILL.md— first in-repo skill. Preflight checks, a cluster-side check for whether CNV is already present, then invokesplaybooks/setup.yml. No business logic, per the two-entry-point contract. Ends in a verification step that queries the cluster for thekubevirt.io/cdi/hco/instancetypeAPI groups, theu1instance-type shapes the sizing tiers depend on, anddevices.kubevirt.io/kvmon the node — a green Ansible recap is not treated as proof. (#1)-
kubernetes.core.k8sandkubernetes.core.k8s_infoadded to.ansible-lintmock_modulesso the offline CI lint gate can resolve them. -
Initial repository skeleton for the two-axis layout:
demos/by demo,terraform/by platform,inventory/group_vars/by environment. docs/plan/ocpvirt-demo-plan.md— environment research confirming the RHDP "Ansible Product Demo" catalog item supports OpenShift Virtualization, plus the phase-by-phase implementation plan.ROADMAP.mdcovering phases 0–4 and the t-shirt sizing tiers.- Secrets convention:
inventory/group_vars/<env>/secrets.ymlas the single place every environment-specific value lives, withsecrets.yml.exampleas the repo's only.examplefile. .gitignoreas the first commit, so no environment-specific value can enter history.- CI lint gate ported and adapted from
aap_config: yamllint, ansible-lint, a secret-hygiene guard, and a portability check on in-repo skills. utilities/check-no-secrets.sh— enforces the pre-push audit automatically. Matches the shape of real credentials so docs and.exampleplaceholders pass while genuine values fail the build..yamllint,.ansible-lint, and pinnedcollections/requirements.yml.- GitHub CODEOWNERS, PR template, issue templates, and security policy.
-
.claude/skills/collections-sync/SKILL.md— pins, installs, and verifies the collections, failing loudly on drift. No playbook, deliberately: it touches the laptop's collection path, never a demo environment, so it must never run from AAP. (#8) -
Shared AAP configuration ported from
ericcames/aap_configintoinventory/group_vars/aap/—aap_settings.yml(dispatch_include_wildcard_vars,aap_configuration_secure_logging),aap_organizations.yml,controller_settings.yml(Automation Analytics and subscriptions), andgateway_settings.yml(thecustom_login_infosign-in banner). Values verbatim; comments adapted to this repo, which has two environments and no export tooling. Every key was verified present on the live AAP 2.6 catalog item first, so the standing "aap_config targets 2.7, do not copy its settings verbatim" caution does not apply to these files. Collection pins already matched exactly. Nothing consumes these variables until the AAP bootstrap half of #1 lands. (#14) vaulted_subscriptions_client_idandvaulted_subscriptions_client_secretadded tosecrets.yml.example.controller_settings.ymlrequires both in every environment or the apply fails with an undefined-variable error. (#14)
Added — first-time setup and run logging (#26)
.claude/skills/sales-demos-first-time/SKILL.md— one-time setup for a new machine. Audits what exists, guides what is missing, and validates each step by exercising the real path (inventory resolution, the vault, and the Hub token lookup together) rather than checking files exist.- It is explicit that the vault password cannot be created by a new user.
group_vars/aap/secrets.ymlis committed but encrypted, so without~/secrets/.vault_pass_sales_demosnothing decrypts and every playbook fails. It has to be handed over; there is no derivation and no recovery. - Run logs now go to
~/ansible-logs/, outside the repo, viaANSIBLE_LOG_PATH. Documented inREADME.md, theocpvirt-setupskill, and the first-time skill. Outside the repo on purpose: this repo is public, and keeping logs out entirely beats relying on an ignore rule. A defensivelogs/+*.logrule is added anyway in case someone pointsANSIBLE_LOG_PATHat the working tree. - Not
tee. In a pipeline the exit status comes fromtee, not fromansible-playbook, so a failed run reports success. This is recorded because it caused a real misread during Phase 0.ANSIBLE_LOG_PATHalso works without anansible.cfg, which matters since a project-local one is banned.
Changed
CLAUDE.mdandREADME.mdnow state plainly that this repo is self-contained: every skill it needs lives in.claude/skills/, nothing depends on a plugin or another repo's skills, and nothing that does should be added. The skill-authoring guidance points atocpvirt-setupas the in-repo example rather than at an external repo. (#26)
Added — config-as-code apply and validate (#24)
playbooks/config.ymlapplies the AAP objects defined ininventory/group_vars/;playbooks/validate.ymlis the same play in check mode. Until now nothing in this repo raninfra.aap_configuration.dispatch, so the objects ported in #14 and the sign-in logos from #20 had never been executed against a real AAP.- Both are thin — the work is
include_role: infra.aap_configuration.dispatch, with variables arriving implicitly frominventory/group_vars/. Basic auth viaaap_username/aap_password; no OAuth token is minted, so there is nothing to leak and nothing to clean up in analways:block. - The environment guard from #16 moves to
playbooks/tasks/assert_target_environment.ymlrather than being copied a third time;install_cnv.ymladopts it and still runs atchanged=0. - Verified against the sandbox by running it: check mode
ok=36 changed=4, then the applyok=37 changed=2, then confirmed against the AAP API — organization created, banner set, andcustom_logobyte-identical to the committeddocs/images/logo-sandbox.png.b64. - Documented check-mode caveat: some roles' "wait for the object to exist" tasks
report
FAILED - RETRYINGunder check mode because nothing was created for them to find. The play still succeeds; treat check mode as a strong signal, not a contract.
Changed — what the vault actually holds (#22)
automation_hub_tokenis no longer stored in the vault. Nothing consumed it —ansible-galaxy collection installreads~/.ansible.cfgitself, which is the authoritative copy of that token and is shared across every repo.inventory/group_vars/aap/main.ymlnow reads it with anansible.builtin.inilookup against~/.ansible.cfg, matching howaap.as.codeandaap-skillsalready do it. A vaulted second copy would have gone stale silently on the next rotation, with nothing to detect the drift.- Reads
~/.ansible.cfg, not~/.ansible/ansible.cfg. Both hold the same token today, but the latter is a frozen leftover from when the former was a symlink, and will drift.aap-skillsstill points at the stale path. - Known limitation, documented in the file: the lookup resolves on the controller, so it will not resolve inside an AAP execution environment. The only consumer is the AAP bootstrap, which is inherently laptop-side.
- The Automation Analytics credentials are now real, not
CHANGEME.vaulted_subscriptions_client_idand_client_secretare required bycontroller_settings.ymlfor every environment (#14) but were seeded as placeholders. Copied fromaap_config's qa vault — the same Red Hat service account — by piping betweenansible-vaultinvocations, so the values never touched a plaintext file or shell history. - Consequence recorded in the vault file's header: they now live in two vaults with no shared secret store, so rotating the service account means updating both.
secrets.yml.examplestaysCHANGEMEfor the analytics keys — it is a template, not a value store — and now explains that the Hub token is not there.
Added — per-environment sign-in logos (#20)
inventory/group_vars/<env>/gateway_settings.ymlsets the gateway'scustom_logoto an environment-badged version of the AAP lockup, so the sign-in page shows which environment you are entering. Green forsandbox, red fordemo— the environment you break, and the one you show customers, reusingaap_config's severity convention.utilities/make-env-logo.py, ported fromaap_configwith this repo's two environments in place of its dev/qa/prod. Extends the official product lockup rather than replacing it, so Red Hat branding survives and only a badge is added. Needs Pillow, ImageMagick with the librsvg delegate, and Red Hat Display.docs/images/—aap-logo-white.svgplus the generatedlogo-{sandbox,demo}.pngand their base64 sidecars, all committed so they render on GitHub and so a clone does not need ImageMagick to apply the config.custom_logochanges the sign-in page only, never the post-login masthead, which is a bundled UI asset rather than a setting. Confirmed on AAP 2.6: 44 gateway settings exist and none of them mark the environment after login.- Relies on
dispatch_include_wildcard_varsmerginggateway_settings_allwithgateway_settings_<env>. The sharedcustom_login_infobanner stays ingroup_vars/aap/, and the per-environment files set onlycustom_logo— verified disjoint, since merging is per-key and a scalar in both would mean the environment file wins rather than combines. The setting reaches a real gateway only once an AAP apply playbook exists (the open half of #1).
Changed — secrets model (#18)
secrets.ymlis now vault-encrypted and committed, not gitignored plaintext, matchingaap_config. One file atinventory/group_vars/aap/secrets.yml, vault-idsales.demos, loaded for every environment because it sits in theaapgroup directory. Replaces the per-environment gitignored files.- It holds credentials only. Per-environment credentials are keyed under
env_secretsby environment name; eachconnection.ymlselects its slice withenv_secrets[aap_env_name], which is also what keeps--limit demofrom reaching sandbox's credentials (#16). connection.ymlnow carries the environment-specific non-secrets in committed plaintext —aap_hostname,openshift_api_url, usernames, namespaces. It previously held structure only. A new RHDP environment is now a two-file edit: thatconnection.ymlplus two keys in the vault.- RHDP URLs are no longer treated as sensitive.
*.dyn.redhatworkshops.iohostnames are ephemeral demo-platform addresses, not customer-identifying, and are committed in the clear on purpose — that is what lets the vaulted file hold credentials only. The RHDP-hostname pattern is removed fromutilities/check-no-secrets.sh. This reverses a rule previously stated inCLAUDE.md,CONTRIBUTING.md,README.md, and the plan doc, all updated. utilities/check-no-secrets.shguard inverted. The "no trackedsecrets.yml" rule is replaced by "a trackedsecrets.ymlmust begin with$ANSIBLE_VAULT", checked against the committed blob rather than the working tree. Sincesecrets.ymlis no longer gitignored, this is the only thing preventing a plaintext credential file from being pushed. Every other pattern — bearer tokens, private keys, AWS and GitHub tokens, quay credentials — is unchanged. Verified by triggering it: a staged plaintextsecrets.ymlfails with exit 1..gitignoredrops theinventory/group_vars/*/secrets.ymlrule and adds vault password patterns. The password itself lives outside the repo at~/secrets/.vault_pass_sales_demos, following the same convention asaap_config's.vault_pass_<env>files..claude/skills/ocpvirt-setup/SKILL.md— two real breakages fixed, not just wording. Its preflight assertedgit check-ignoresucceeds onsecrets.yml, which is now exactly backwards; and its preflight and verification blocksyaml.safe_loaded the secrets file directly, which fails on ciphertext. Both now resolve credentials throughansible … -m debugwith--vault-id, so the--limitselects the environment by the same path the playbook takes.
Fixed
--limit demosilently targetedsandbox. Both environment groups ininventory/hosts.ymlpointed at the same host,localhost.--limitfilters which hosts run, not whichgroup_varsload, so a host in two environment groups loaded both environments' variables — and same-level groups resolve alphabetically with the later name winning, sosandboxalways beatdemo. Asking fordemoreturned sandbox's hostname and sandbox's bearer token with no warning, which meant thedemoenvironment could not be targeted at all. Each environment now has its own host (sandbox-local,demo-local), sogroup_varsstop merging. Adding ademo/secrets.ymlwould not have fixed this;sandboxstill won. (#16)- Playbooks target
hosts: aapand assert that exactly one environment is in scope, so a run without--limitfails closed instead of configuring both environments at once. An optional-e target_env=<env>makes the play verify the inventory resolved to the environment the caller intended.--limit sandboxand--limit demoare unchanged as invocations. (#16)
Changed
aap_organization_nameininventory/group_vars/aap/main.ymlmovedDefault→IT Service Automation, matching the organizationaap_organizations.ymldeclares, so the repo names one organization rather than two. A fresh RHDP environment shipsDefaultandAnsible Product Demos (APD), so the first apply creates it. (#14)- Every collection in
collections/requirements.ymlis now pinned to an exact version.ansible.platform(2.7.20260604),ansible.controller(4.8.0),kubernetes.core(6.4.0), andredhat.openshift_virtualization(2.3.0) were floating, so two laptops could resolve different code. Pins record the versions Phase 0 was validated against, not the newest published. (#8) infra.aap_configurationpin moved 4.2.0 → 4.7.0 to match what is installed and used. Nothing in this repo consumes it yet; revisit when the AAP bootstrap half of #1 lands. (#8).gitignorenow covers.ansible/, ansible-lint's artifact directory. Collections install to~/.ansible/collectionsand are never vendored here. (#8)inventory/hosts.ymlpinsansible_python_interpreterto{{ ansible_playbook_python }}. Interpreter discovery otherwise picks whatever/usr/binpython it finds first, which on Fedora can be an older minor version without thekubernetesclient. Pinned in the inventory rather than anansible.cfg, which would shadow~/.ansible.cfgand break certified collection installs. (#1)docs/plan/ocpvirt-demo-plan.mdrecords the Phase 0 validation run. The original research stands — it correctly reportedkubevirt-hyperconvergedas available in the operator catalog, not installed — but the doc read as a plan with nothing confirming it had been executed. Now states outright that a freshly provisioned environment has nokubevirt.ioAPI group, and adds the observed versions and timings, confirmation of theu1instance-type shapes the sizing tiers depend on, the decision to discover the default StorageClass rather than hard-code it, and a note that OpenShift version and cluster ID are per-environment samples rather than properties of the catalog item. (#9)
Removed
- Three ansible-lint-generated module mocks that were tracked under
.ansible/collections/ansible_collections/. They are regenerated from.ansible-lintmock_moduleson every run, so tracking them only guaranteed they would go stale. (#8)
Notes
aap_config'sdeploy-{dev,qa,prod}workflows were deliberately not ported and will not be (#7). CI is a PR gate only; nothing deploys from GitHub Actions. Deploys run viaansible-playbook— wrapped by a skill locally, or as an AAP job template — which keeps every environment-specific value in the gitignoredsecrets.ymlwith no second copy in GitHub Environment secrets.