Kuiper API Configuration
After Kuiper is installed, the kuiper role can drive the Kuiper REST API to apply
initial configuration idempotently — no Config Editor GUI, no manual clicks. This
work is split across two phases of the role: configure applies settings on the
Kuiper instance itself (credentials, LDAP, environments, package sources, settings),
and fleet applies fleet topology (machine registration, groups and their
membership, certificate deployment) — the state that changes whenever the fleet
does. For the full endpoint map (documented and undocumented) see the
API Reference.
Running it
Both phases have their own tags and are not part of --tags install. A full
desired-state apply runs both:
The fleet phase supersets three sub-tags for targeted day-2 runs — machines
(registration), groups (group create + machine membership) and certificates
(import + deploy/bind) — so "we added an app server" is just:
fleet assumes configure has run at least once: group service accounts and the
certificate deploy credential are stored Kuiper credentials created there.
Both phases are safe to run against a load-balanced Kuiper pair as a whole:
the desired state lives in the single shared Kuiper database, so the role
elects one leader (sorted-first among the play hosts running the role) and the
others skip. A --limit to a single host always applies — election is
play-scoped.
Both phases run on the target host against https://localhost/Kuiper, so they need
no external network access and are unaffected by the certificate's SAN/hostname.
Authentication
Both phases share one login (tasks/kuiper_api_auth.yml), so a combined run
authenticates once. Which of the two modes it uses is chosen by whether
kuiper_api_username resolves to anything — vault-first, with
KUIPER_API_USERNAME / KUIPER_API_PASSWORD env-var fallback, mirroring the
install-user convention.
Set — an explicit API login. The role POSTs api/auth/v1 and every call
carries the returned bearer token. kuiper_api_password is then mandatory: a
username with no password fails fast with a message saying so, rather than
silently falling through to the other mode.
kuiper_api_username: AADDS\svc-kuiper-api # sAMAccountName, NOT the UPN
kuiper_api_password: "{{ vaulted_kuiper_api_password }}"
Unset — the connecting Windows identity. With neither variable set there is
no login POST and no token; every REST call sets use_default_credential and
authenticates over Negotiate/Kerberos as the Windows identity the WinRM session
already runs under, which must itself have Kuiper access. Nothing else changes —
the same desired-state inputs apply either way.
Three things that will bite you if ignored:
- Use the sAMAccountName, which AD truncates to 20 characters. The UPN form
(
user@domain) returns401. - Prefer a dedicated, least-privilege Kuiper user over a domain admin. MFA- enrolled and Duo/OIDC users cannot authenticate through the API.
- On a fresh install, only the bootstrap account is a Kuiper user. The account
used to first log in (in the IRE build, the local
azcusikpr001\azureuser) is the only Kuiper user until a domain account is added. API auth as a domain account returns401until that account has been added as a Kuiper user — so the first configure run must use the bootstrap account, or the install/bootstrap must add the domain account as a Kuiper user.
The MVC-driven features always need an explicit login
Not everything in these phases is REST. Kuiper's MVC controllers are Forms-authenticated — the helper posts a username and password to the login form — so they have no Kerberos path and the identity-less mode above cannot reach them. Two features are affected:
- Fleet certificates (import and deploy)
- System Pulse client registration
The list used to be longer: Kuiper August 2026 (119.x) moved security-group
management and Interconnect group creation onto plain REST routes
(api/security-groups/v1 and POST api/groups/v1/Interconnect), so those
features now authenticate like every other REST call.
Each one preflights kuiper_api_username / kuiper_api_password and fails fast
with a message naming the feature when they are absent, so a run never gets part
way in and then dies at the login form. Supply the pair whenever any of these
features is in the desired state — which, in the IRE build, is every run.
The workgroup path narrows this further: machine actions against a non-domain-joined host run as the logged-in API identity, so there the login must be a local account whose username and password match a local Administrator on that host. There is no identity-less variant of that flow.
The Kerberos mode is not yet live-verified against a real instance. Under
WinRM the session holds a network logon token and the API call goes to
https://localhost/Kuiper, so the usual double-hop and LSA loopback-check
caveats apply; running the phase as SYSTEM (the machine account) may turn out to
be the workable form. The explicit login is the proven path.
Desired-state inputs
Each feature reads a list variable and applies it create-if-absent. Leave a
list empty (the default) and that feature is a safe no-op. The list entries mirror
the API request body exactly, so the API Reference and Epic's
API spec are the schema. The machines, groups and certificates features below
belong to the fleet phase; everything else runs under configure.
Deployment settings (kuiper_infrastructure_deployment_settings)
The Deployment section of the Infrastructure Settings page, PATCH-if-changed
on the api/settings/v1/infrastructuredeployment resource (available starting
in Kuiper May 2026). The documented attributes are enableNtlm and
maximumConcurrentActions; include only the ones to manage. It runs first
among the configure features.
The motivating attribute is enableNtlm — the UI's "Remote
authentication" toggle. Kuiper's deploy engine defaults to Kerberos, which
cannot authenticate to a non-domain-joined (workgroup) machine, so NTLM must
be enabled before any machine action targets one (Epic asks that you mention
SLG 10983425 to your Epic representative before enabling it):
WinRM TrustedHosts on the Kuiper server (kuiper_winrm_trusted_hosts)
The Kuiper server's own WSMan client refuses to send NTLM credentials to a host it cannot verify via Kerberos unless that host is in its TrustedHosts list — the second prerequisite for managing workgroup machines. List each workgroup machine's short name and FQDN:
Entries are added if absent and never removed. Unlike the API-driven features
this is host-local state, applied on every Kuiper server in the play (not
just the config leader) under both the configure and fleet tags.
Credentials (kuiper_configure_credentials)
kuiper_configure_credentials:
- attributes:
name: svc-kuiper-sql
password: "{{ vaulted_sql_password }}"
domain: AADDS
accountType: SQL Server # Windows | MSA | SQL Server | Oracle | LDAP
associatedApplications: ["Caboodle"]
- attributes:
name: svc-kuiper-ldap
password: "{{ vaulted_ldap_password }}"
accountType: LDAP
associatedUseCases: ["LDAP"]
Idempotency is create-if-absent: existing credentials (matched by lowercased
name) are left untouched. Because the API never returns a stored password, a
password change cannot be made idempotent and is intentionally not attempted —
delete and recreate the credential if you must rotate it through this path.
A Windows credential associated with an application doubles as Kuiper's
deploy credential: when Kuiper deploys to a machine of that application
(packages, certificates), it opens
a PSSession to the machine as that account, which must be a local Administrator
there. On Azure/AADDS that grant comes from the AADDC Computers GPO link created
by playbooks/create-ad-ous.yml. Certificate deployment preflights this: it
fails fast with a "run --tags configure first" message when Kuiper has no
stored credentials at all, instead of failing mid-deploy.
LDAP connections (kuiper_configure_ldap_connections)
Create-if-absent, matched on friendlyName. Each needs a bindUserId referencing
a credential created above (with the LDAP use case).
kuiper_configure_ldap_connections:
- attributes:
url: "ldaps://dc.aadds.example.org:636"
distinguishedName: "dc=aadds,dc=example,dc=org"
bindUserId: 1
friendlyName: "AADDS"
recursionEnabled: false
Environments (kuiper_environments)
A map keyed by environment name (create-if-absent, matched on the key). Each value
holds the environment's attributes plus an optional attachToGroups list of group
display names (consumed by the fleet phase — see
Environment membership — and stripped from
the environment record itself). Add more environments as sibling keys.
kuiper_environments:
MILKYWAY:
environmentType: Production # Production | NonProduction | Training | Replicated
hostname: MILKYWAY
port: 16350
fixedKey: "{{ vaulted_fixed_key }}"
useDefaultEmp: true # inherit the global default EMP (see below)
attachToGroups: ["HSW Prod", "IC Prod"]
EMP record. Attaching an environment to a Hyperspace Web group requires the
environment to have an EMP record. Either set the global default EMP once in
kuiper_environment_settings
(empId + empPassword) and use useDefaultEmp: true (verified live to satisfy
the Hyperspace Web attach), or give the environment its own with useDefaultEmp:
false + empId + empPassword.
The environment "add" health check — the ESMP/ODB connection test the web UI runs
before it will save an environment — is a UI-only step. The REST
POST api/environments/v1 persists an environment unconditionally: it does not
resolve the hostname or connect to the ODB (verified live with a non-resolving
hostname and no database). This lets a placeholder environment be created before
the real Epic ODB exists, so packages and configuration can be staged against it
ahead of ODB provisioning. fixedKey is still required — a create without it
returns 403. Once the ODB is live, reconcile to the real hostname/port by
deleting the placeholder (DELETE api/environments/v1/{id} → 204) and
recreating, since the feature only ever adds.
Security groups (kuiper_security_groups)
Create-if-absent, matched on name (case-insensitive), through the REST
security-group API (api/security-groups/v1, Kuiper August 2026+ — see the
API Reference), the same
surface as the Super Administrator bootstrap. Earlier builds had no REST route for
this and went through the MVC Security portal. Each entry names the group, sets an
environment-type scope, and lists its permissions by security-point type:
kuiper_security_groups:
- name: "ODB Admins"
description: "Read/edit Environments and read/edit/run Environment Upgrades"
environmentTypes: [Production, NonProduction, Training] # -> the group's scope
domainGroups: [] # optional AD security-group display names to add
users: [] # optional user account names to add
permissions:
# "Environments" (an infrastructure feature — no application):
- type: ReadEnvironmentInfrastructureSecurityPoint # Read
- type: EditEnvironmentInfrastructureSecurityPoint # Edit (Modify)
# "Environment Upgrades" (the "Upgrade Console" application):
- {application: "Upgrade Console", type: ReadApplicationSecurityPoint} # Read
- {application: "Upgrade Console", type: ModifyGroupApplicationSecurityPoint} # Edit
- {application: "Upgrade Console", type: CreateRunEnvUpgrades} # run upgrades
Each permission is a security-point type — a catalog TypeFullName or its short
class name — with an application for a per-application point (omitted for an
infrastructure point). The role resolves each type to this instance's runtime
security-point id against the live Security/Settings catalog (the ids are
per-instance reference data, never hard-code them) and adds each point's declared
dependencies automatically, mirroring the UI: checking "ODB Upgrades"
(CreateRunEnvUpgrades) pulls in Read and Deploy a Group. environmentTypes
accepts any of Production, NonProduction, Training and sets the group's
matching scope booleans.
Idempotency has two paths, matched on name: a missing group is created with the
given permissions, scope and members; an existing group has only its members
(domainGroups/users) reconciled add-if-absent — its permissions and scope are
left as-is (to change those, delete and recreate, or use the UI). So the common
"grant this AD group access" flow is: create the AD group (e.g. with
playbooks/create-ad-groups.yml), reference it in domainGroups, and re-run
--tags configure — Kuiper's account search resolves it to DOMAIN\Group and adds
it. A group with no domainGroups/users is valid; empty kuiper_security_groups
makes the feature a no-op.
Machines (kuiper_fleet_machines / kuiper_machines_enroll_group)
Registers machines (servers) create-if-absent, matched on name. POST
api/machines/v3 with {type: Machine, attributes: {name, machineType,
requestedMaintenanceMode, associatedApplications}}. Application names are the
Kuiper display names with spaces (e.g. Hyperspace Web, Interconnect,
Kuiper) — the API rejects an unknown name. This differs from
kuiper_fleet_groups, whose application is the no-spaces form.
Two inputs are combined. An explicit list:
kuiper_fleet_machines:
- name: SERVER01
applications: ["Hyperspace Web"]
machineType: Windows # optional, defaults to kuiper_machine_default_machine_type
requestedMaintenanceMode: In service # optional, defaults to kuiper_machine_default_maintenance_mode
friendlyName: "..." # optional
notes: "..." # optional
Newly registered machines are created In service by default
(kuiper_machine_default_maintenance_mode, overridable per entry via
requestedMaintenanceMode). This applies only at create time — an existing
machine's maintenance mode is left untouched.
…and auto-enrollment of an inventory group, mapping each host's application
inventory tag to Kuiper application display names. A tag with no mapping registers
the machine with no association:
kuiper_machines_enroll_group: os_windows
kuiper_machine_application_map:
kuiper: ["Kuiper"]
hsw: ["Hyperspace Web"]
ahsw: ["Hyperspace Web"]
icfg: ["Interconnect"]
An explicit entry wins over an enrolled entry for the same name. The create does not enforce DNS resolution, so a host that is not yet resolvable from the Kuiper server can still be registered.
Groups (kuiper_fleet_groups)
Create-if-absent per application, matched on group name. application is the
Kuiper app name without spaces (e.g. HyperspaceWeb). A HyperspaceWeb group
requires a deploymentSetting — a create with just name/notes/tags is
rejected with "Unable to resolve application deployment setting" (verified live).
kuiper_fleet_groups:
- application: HyperspaceWeb
attributes:
name: "HSW Prod"
notes: "Created via API"
tags: ["Wave 1"]
deploymentSetting:
applicationDrive: "C:\\"
websiteName: "Default Web Site"
traceLogBaseDirectory: "C:\\"
useAppPoolIdentity: true # true => no app-pool creds needed
iisRequiresHttps: true
allowStandaloneLoginAccess: true
defaultActionStartOption: 0
To run the group's IIS app pools as a gMSA instead of the app-pool identity, set the app-pool user in the same deployment setting (verified live — the create accepts and persists these):
deploymentSetting:
useAppPoolIdentity: false
applicationPoolUserName: AADDS\hsw-gmsa$ # trailing $ marks an MSA;
validateCredentialsOnRemoteMachine: false # no password is needed or
# ... # accepted for one, and the
# (rest as above) # password-logon validation
# can never pass for a gMSA
A password account uses applicationPoolUserName + applicationPoolPassword
instead. The gMSA itself (and the AD group that lets the member hosts retrieve
it) comes from playbooks/create-gmsa.yml + extra_vars/gmsas.yml.
Other deploymentSetting booleans are accepted and persisted the same way — for
example areSessionsExternalOnly: true sets the group's "Treat all sessions as
external" option.
Perimeter authentication server. Setting isPerimeterServer: true checks the
group's "Set as perimeter authentication server" box — the per-group toggle
that supersedes "Treat all sessions as external" for Authentication Hyperspace Web.
The checkbox must first be exposed instance-wide with kuiper_enable_perimeter_hsw_ui
(see below); the configure phase runs before the fleet phase, so the UI is enabled
before the group is created. Epic enforces two rules on a perimeter server, and a
create that violates either 400s:
allowOAuth2Authorization: false— perimeter auth and OAuth2 are mutually exclusive ("Perimeter Auth and Oauth2 authorization cannot both be true").perimeterServerFixedKeymust be non-blank ("Configured as a perimeter server but fixed key override is blank"); Kuiper derivesisPerimeterServerFixedKeyDefinedfrom it. Store the key in the vault.
The IRE AHSW group sets all four (isPerimeterServer: true,
areSessionsExternalOnly: false, allowOAuth2Authorization: false,
perimeterServerFixedKey: "{{ kuiper_ahsw_perimeter_fixed_key }}"). As with every
group setting, these apply at create time — because groups are create-if-absent,
a changed value takes effect on the next environment rebuild, not on an existing
group.
Metric group (systemPulseMetricGroup)
A group's Metric group (Infrastructure Settings → the group's System Pulse
metric group) is the top-level attributes.systemPulseMetricGroup string — a
friendly application name (e.g. Hyperspace Web, Interconnect, System Pulse).
The values correspond to rows in I_DistributedMetricDefinitions (one per
application type); a fresh group defaults to Disabled. Set it alongside
name/notes:
Like every other group attribute it is applied at create time only. To change
it on an already-created group without a rebuild, PATCH
/api/groups/v1/{application}/{id} with the group's full attributes body (a
partial body 403s with "Unable to resolve application deployment setting"); the
role does not do this — it relies on the create-if-absent path plus the nightly
rebuild.
Satellite Only groups (System Pulse)
System Pulse is not a Kuiper-managed Epic application — it runs only the Satellite
monitoring agent — so its server is enrolled under the Satellite Only application
(kuiper_machine_application_map) and its group uses application: SatelliteOnly.
Unlike Interconnect, Satellite Only supports the REST group create, so it goes
through the normal REST path. It requires a deploymentSetting key; an empty
one ({}) is accepted and Kuiper fills the defaults, but set applicationDrive
(the group's Application drive) explicitly rather than relying on it:
- application: SatelliteOnly
machines: "{{ groups['app_systempulse'] }}"
attributes:
name: System Pulse
systemPulseMetricGroup: System Pulse
deploymentSetting:
applicationDrive: 'C:\'
Interconnect groups
Interconnect groups create through the same REST route as every other
application from Kuiper August 2026 (119.x) onward. Earlier builds refused it
— POST /api/groups/v{n}/Interconnect returned
400 "Creating a Interconnect application group is currently not supported" in
every route version — so the role used to reproduce the web UI's MVC
Groups/SaveWithValidate call instead. That fallback was removed on 2026-07-29.
Two things the MVC body needed that the REST create does not: a
DistributedMetricDefinitionId (use the systemPulseMetricGroup friendly string
instead) and a hand-built SecondaryApplicationGroups array — the server derives
the secondary groups from instances itself, one per entry, and returns them under
relationships.instances.
An Interconnect entry needs a name (plus optional notes). The Instances
Configuration and related deployment options are set through
attributes.deploymentSetting. directoryDrive, website and
commandRestrictionMnemonicOverrides were implicit defaults of the retired MVC
skeleton; REST applies no such defaults, so state them explicitly to get a group
identical to one created in the UI:
kuiper_fleet_groups:
- application: Interconnect
attributes:
name: "IC Prod"
notes: "Interconnect"
systemPulseMetricGroup: Interconnect
deploymentSetting:
directoryDrive: 'C:\'
website: Default Web Site
commandRestrictionMnemonicOverrides: [Interconnect]
instances: # the Instances Configuration:
interconnect-Event-Notification-WS: true # instance name -> IsChroniclesConfigured
interconnect-Mobile: true
interconnect-OAuth2: true
interconnect-Outgoing: true
Interconnect-PAuth: false # a non-Chronicles instance (still created)
needSetupMachineKey: true
enableWebSockets: true
An empty group (instances: {}, the default) is valid — no live Interconnect
server is required to create it. instances values are the per-instance
IsChroniclesConfigured flag, not "enabled". Every instance in the map is
created — each persists as one secondary application group named
<group>-<instance> (Kuiper stores the instance-name suffix) — so a non-Chronicles
instance is simply listed with the value false. The server expands the map into
the secondary groups itself and returns them under relationships.instances.
Idempotency is the same create-if-absent as other groups (matched on name, checked
via the REST API before creating). Things to know:
- System Pulse is auto-linked on save — the group's
systemPulseUrlIdis populated by Kuiper from the configured System Pulse server. It is not editable in the UI and has no REST lookup, so leave it unset. tagsmust already exist in Kuiper, or the save is rejected with no error. Leavetagsunset unless the tag has been created beforehand.- An existing group cannot be re-saved to change its instances — delete and
recreate. The v1 delete returns
403 "Deletion of this group was not bypassed with overrideWarning", so useDELETE api/groups/v2/Interconnect/{groupId}?overrideWarningDowntime=true&uninstallSatelliteDeployedPackages=false.
Group membership (machines)
Any group entry — every application, Interconnect included — may carry a machines
list of Kuiper machine names (hostnames). Those machines are made members of the
group, add-if-absent:
kuiper_fleet_groups:
- application: HyperspaceWeb
machines: "{{ groups['app_hsw'] }}" # inventory group -> no literal hostnames
attributes:
name: "HSW Prod"
# deploymentSetting: ...
Membership uses the REST API for every application (only Interconnect group
creation lacks a REST endpoint, not its membership). The machines must be
registered first (kuiper_fleet_machines / kuiper_machines_enroll_group) and
the group must exist, so this feature runs after both create paths. A requested
machine that isn't registered in Kuiper is skipped with a warning. See the
API Reference for the endpoints.
This also covers built-in groups: Kuiper servers must be members of the install's
own "Kuiper Servers" group (application Kuiper, group id 5 on a fresh
install) to get Satellite installed and communicate with the environment. The
group already exists, so the create step skips it and only membership is applied:
kuiper_fleet_groups:
- application: Kuiper
machines: "{{ groups['app_kuiper'] }}"
attributes:
name: Kuiper Servers
Non-domain-joined (workgroup) groups
A group whose machines are workgroup hosts needs no per-group deploy
identity: Kuiper's engine runs machine actions as the logged-in API
identity, so with the workgroup prerequisites in place — the API login being
a local account whose username+password match a local Administrator on the
target, enableNtlm,
and the machine in the Kuiper server's
TrustedHosts —
the group's certificate and package deploys work through the same routes as
domain-joined groups (live-proven 2026-07-24 against a workgroup Hyperspace Web
host). Earlier per-group mechanisms (a powershell_impersonation_credential
setting the MVC-only PowerShellImpersonationCredentialId, and a
remote_identity routing deploys through session-scoped "Use Remote Identity")
were removed once this was proven; they survive in git history.
Environment membership (attachToGroups)
Which environments attach to which groups is declared on the environment, not
the group: each kuiper_environments entry's
attachToGroups list names the groups (by display name) it attaches to,
add-if-absent. For example, to attach a pre-ODB placeholder environment to every
application group so packages and configuration can be staged before the real ODB
exists:
kuiper_environments:
DUMMY:
environmentType: NonProduction
hostname: dummy-odb
port: 16350
fixedKey: "{{ vaulted_fixed_key }}"
useDefaultEmp: true
attachToGroups: [HSW, AHSW, "Interconnect FG IRE"]
Like machine membership, environment membership uses the REST sub-route —
POST api/groups/v1/{application}/{groupId}/environments with a bare JSON array of
environment ids — and it works for every application, Interconnect included.
Attachment happens after group creation, add-if-absent: current attachments are read
from the single-group GET (relationships.environments.ids), and only absent ids
are posted, so a converged run is a no-op.
A Hyperspace Web environment must have an EMP record — inherit the global
default EMP with useDefaultEmp: true, or set empId/empPassword with
useDefaultEmp: false — or Kuiper refuses to attach it. Interconnect groups have no
such requirement.
Package deployment (deploy_latest_package)
Add deploy_latest_package: true to a group entry to deploy the latest available
package for that group's application to the group, add-if-absent, under the
packages tag (runs last in the fleet phase):
kuiper_fleet_groups:
- application: HyperspaceWeb
machines: "{{ groups['app_hsw'] }}"
deploy_latest_package: true
attributes:
name: "HSW Prod"
The available packages come from GET api/packages/v1/{application} — what Kuiper
has scanned from the Base Package Source,
so the installers must be staged there first. "Latest" is the highest version
(version-aware sort). The deploy posts a bare JSON array of package ids to
POST api/groups/v1/{application}/{groupId}/packages; idempotency reads the group's
relationships.packages.ids (same id space as the catalog) and posts only when the
target package is absent, so a converged run is a no-op — important, because
re-posting would restart a deploy and reboot machines.
Two query flags govern how the deploy runs, both true by default:
kuiper_package_deploy_downtime_tolerant→isDowntimeTolerant. This is the "install prerequisites" control:truelets Kuiper install prerequisites and reboot member machines as needed to complete the deploy;falsedoes neither.kuiper_package_deploy_ignore_warnings→ignoreWarnings.trueproceeds past voided release-authorization (RA) warnings;falsestalls until they are acknowledged.
The group's machines should be members and its environment attached before the package deploys, which is why this runs last. A group whose application has no scanned package is warned and skipped (e.g. an empty application list, or before staging).
Package storage on Azure Files
Two complementary knobs point Kuiper at the Azure Files SMB share:
# A package source Satellite/agents read packages from (create-if-absent by friendlyName)
kuiper_configure_package_sources:
- attributes:
path: "\\\\acct.file.core.windows.net\\share\\packages"
friendlyName: "Azure Files"
sourceType: "Default Source"
username: "AZURE\\svc-kuiper"
password: "{{ vaulted_azure_files_key }}"
throttlingProvider: "Kuiper"
# Satellite application-file storage (PATCH-if-changed).
# Changing applicationFilesPath once set needs kuiper_satellite_force: true.
kuiper_satellite_settings:
applicationFilesPath: "\\\\acct.file.core.windows.net\\share\\satellite"
kuiper_satellite_force: false
Base Package Source (kuiper_base_package_source) and staging (kuiper_base_package_files)
The Base Package Source is the built-in File Share package source Kuiper stages
the package files it distributes to the fleet from — default path
C:\ProgramData\Epic\ECSMTools\Packages. It is not a settings resource
(api/settings/v1/packages does not exist); it is a record in
api/settings/v1/packagesources alongside user-created sources, distinguished by
sourceType: "Base Package Source". kuiper_base_package_source repoints it
PATCH-if-changed (matched on that sourceType, so the record id — 1 on a fresh
install — is not assumed):
- The update is a JSON:API
PATCH api/settings/v1/packagesources/{id}with an{attributes: {...}}body. A flat body 400s (UnknownKeyInPrimaryDataObject);PUT, and collection-levelPATCH/PUT, all 405. Idempotency merges the desired keys over the current attributes and PATCHes only on a diff. - No credential is set on the source, by design. The REST API refuses a
passwordless MSA on update — changing the username without also supplying a
non-blank password 409s ("Changing the user name without also updating the
password is not allowed"), and
isMsais not a valid attribute. In the AADDS/Azure Files design Kuiper and its Satellite agents reach the share as their own identity (the Kuiper app-pool gMSA on the server; each app server's computer/service account elsewhere), covered by the storage account's default share-level permission. The credential fields are stripped from every request. maxConcurrentDownloadsmust be 1–200 on write, but a fresh Base Package Source reads0; an out-of-range effective value is coerced tokuiper_base_package_source_max_concurrent_downloads(default 10).
kuiper_base_package_files stages control-node files (typically application
installers) into that location so Kuiper can distribute them:
kuiper_base_package_files:
- "{{ shared_files_directory }}/Epic May 2026 Interconnect.msi"
- "{{ shared_files_directory }}/Epic May 2026 Hyperspace Web.EpicBase"
# Destination defaults to kuiper_base_package_source.path; override with:
# kuiper_base_package_source_stage_path: "\\\\acct.file.core.windows.net\\share\\Kuiper"
The copy runs as SYSTEM so the host's AADDS computer account authenticates
to the share — no key, password, or stored credential. It is idempotent by size (a
win_stat gate skips the copy when a same-size file is already present), which
matters because the transfer streams over WinRM: large installers are sent once, not
on every run. When the storage account's public data endpoint is disabled, the share
is reachable only from inside the VNet, so staging must run on an in-VNet host (the
Kuiper server), not the control node.
Satellite top-level settings (kuiper_satellite_settings / kuiper_satellite_publish)
The same kuiper_satellite_settings map (PATCH-if-changed) carries the
top-level toggles from Infrastructure Settings > Satellite. In the IRE
environment we enable Satellite as an application prerequisite, turn on System
Monitoring, and register the internal hostname agents use to reach the Kuiper
server:
kuiper_satellite_settings:
deployAsApplicationPrerequisite: true
useForMonitoring: true # "System Monitoring — Enable in Satellite"
internalKuiperHostnames: ["kuiper.ire-copier.internal"]
# Publish the Satellite build shipped with the install to production machines
# (the UI's "Publish <version>" button).
kuiper_satellite_publish: true
Attribute names worth knowing (mapped by reading the UI's
DistributedSettingViewModel.js on a live install):
useForMonitoringis the System Monitoring enable ("Use Satellite to collect performance counter and service status metrics and submit data to System Pulse");enableSystemMonitoringForClientApplicationsis the separate, dependent client-applications row and cannot be enabled without it.deployAsApplicationPrerequisiterequires a hostname: Kuiper rejects it unlessinternalKuiperHostnamesorexternalKuiperEndpointsis populated in the same (or an earlier) PATCH. Supplying them together in one PATCH works.- The hostname is a Kuiper setting, not DNS — the API does not check that it
resolves. In the IRE environment
kuiper.ire-copier.internalis an A record in theire-copier.internalprivate DNS zone (ire-hub-centralus-rg), created by Terraform.
kuiper_satellite_publish: true publishes the Satellite version bundled with
the Kuiper install via POST api/settings/v1/satellite/publish (see the
API Reference). It is
gated on the API's satelliteVersion, which is null until a version has been
published — so it fires exactly once per install. A stale published version
(after an in-place Kuiper upgrade) is not detectable through the API; that
re-publish still needs the UI's Publish button.
SU EEDS robot (kuiper_sueeds)
PATCH-if-changed. Supply all three on first setup — rootUrl can only be set
alongside credentials.
kuiper_sueeds:
rootUrl: "https://su-eeds-robots.epic.com/SubPath/"
username: "1"
password: "{{ vaulted_sueeds_password }}"
Behaviours confirmed against a live instance (2026-08-05):
rootUrlmust be an epic.com URL. Anything else is rejected with HTTP 400The EEDS URL must point to an Epic server.The check is on the domain only — the save performs no connectivity or credential test, so a placeholder host underepic.comstores fine.- The GET returns PascalCase keys (
RootUrl,Username) while the PATCH accepts camelCase; the role case-normalizes both sides of its drift comparison. - The API never returns the password, so it is excluded from the drift
comparison and re-sent only when
rootUrlorusernamedrifts — a converged run reports no change. To rotate the password without changing the readable fields, setkuiper_sueeds_force_secret_update: truefor one run (it forces a PATCH on every run while enabled).
Perimeter authentication UI (kuiper_enable_perimeter_hsw_ui)
kuiper_enable_perimeter_hsw_ui: true exposes the "Set as perimeter
authentication server" checkbox on Hyperspace Web groups via
POST api/v1/Groups/HyperspaceWeb/EnablePerimeterAuthenticationUI?enable=true
(see the API Reference).
This is the Ansible equivalent of Epic's Enable-PerimeterHSWebUI.ps1.
It is instance-wide and one-shot: it only unhides the option. Checking it on a
specific group is a separate, per-group action — set isPerimeterServer: true in
that group's deploymentSetting (with allowOAuth2Authorization: false and a
perimeterServerFixedKey; see the perimeter-server notes under the Groups section
above). The IRE AHSW group does this in place of "Treat all sessions as external".
The configure phase (this feature) runs before the fleet phase (group create), so
the box is exposed before any group tries to set it.
There is no API to read the current UI-enablement state, so the call is POSTed
whenever the input is true (Kuiper treats a repeat enable as a no-op) and the run
reports changed each time. An empty JSON body is required — a body-less POST 411s
(as with Satellite publish) — and the enable returns 204 No Content.
Environment settings (kuiper_environment_settings)
PATCH-if-changed against the single global EnvironmentSettings resource
(GET/PATCH api/settings/v1/environment — singular). This is Infrastructure
Settings > Environment: the global EMP/ESMP connection and the release directories
that upgrades stage releases into. It is distinct from kuiper_environments,
which manages the per-environment records (api/environments/v1).
kuiper_environment_settings:
productionReleaseDirectory: /epicfiles/prdfiles/release/prd
nonProductionReleaseDirectory: /epicfiles/nonprdfiles/release
empId: 1 # global default EMP...
empPassword: "{{ vaulted_global_emp }}" # ...inherited by useDefaultEmp: true envs
productionReleaseDirectory / nonProductionReleaseDirectory are the "Production
release directory" / "Non-production release directory" fields. Kuiper resolves
them on the Epic environment's server, so they are forward-slash paths, not
Windows paths. empId + empPassword set the global default EMP that any
kuiper_environments entry with
useDefaultEmp: true inherits — which is what lets such an environment satisfy the
Hyperspace Web attach without a per-environment EMP. empPassword, like other
password fields, is never returned by the API, so it re-PATCHes on every run. The attribute names were confirmed by reflecting
EnvironmentSettingsSchema and probing the live endpoint (the route is
environment, not the environments slug the settings-family list implies).
System Pulse server (kuiper_system_pulse)
PATCH-if-changed. Set defaultSystemPulseServer to the System Pulse scheme +
host; the role appends /SystemPulse/Services/DataFilerService.svc to build the
defaultSystemPulseServerUrl the API expects (a raw defaultSystemPulseServerUrl
is also accepted and passed through unchanged).
Settings features (Environment, Satellite, SU EEDS, System Pulse) are PATCH-if-changed: the step GETs current values and only PATCHes when the desired keys differ. Password fields, which the API never returns, are applied whenever supplied.
System Pulse client registration (kuiper_system_pulse_register)
Registers Kuiper's client ID + secret (the UI's "Copy Kuiper ID" / "Copy Secret" on Settings > System Pulse) in System Pulse's Client Token Authentication Settings page, so System Pulse accepts API calls coming from Kuiper — Kuiper uses these to disable System Pulse alerts during package deployments and to remove Hyperspace Web resources it uninstalls. This replaces the manual copy/paste flow Epic documents.
kuiper_system_pulse_register: true
# Optional overrides; both have working defaults:
# kuiper_system_pulse_register_url: "https://sp.example.org/SystemPulse" # default: kuiper_system_pulse.defaultSystemPulseServer + /SystemPulse
# kuiper_system_pulse_admin_username: ... # default: the Kuiper API login
# kuiper_system_pulse_admin_password: ...
# kuiper_system_pulse_client_friendly_name: Kuiper
Neither product has a REST API for this handshake, so the step combines two
internal surfaces (see the API Reference):
the ID and secret are read from Kuiper's MVC Settings/KuiperAuth action, and
the System Pulse entry is written through the ASP.NET page methods behind
Admin/ApiAuth.aspx, after logging in through System Pulse's Forms login
(Login/Login.aspx, backed by the AD membership provider — the username is a
sAMAccountName; a DOMAIN\ prefix is stripped). The system_pulse role writes
the Forms authentication.config this depends on. The System Pulse certificate
is validated by default (kuiper_system_pulse_validate_certs) — the
certificate_authority role's root-CA trust covers this fleet-wide.
Idempotency is create-if-absent matched on ClientId. Existing System Pulse client entries are re-sent with an empty secret, which System Pulse treats as "keep the stored secret" (the same contract its own settings page relies on); System Pulse never returns stored secrets.
System Pulse registration keys (kuiper_system_pulse_retrieve_keys)
Kuiper's GUI validates the System Pulse servers before saving them by pinging
two services on each: Services/DataFilerService.svc (anonymous) and
Services/DataProviderService.svc/Basic, which requires a message credential
verified against per-client HMAC registration keys. Those keys are established
by POST Settings/RetrieveSystemPulseKeys (MVC, no REST route), which the GUI
runs after a save — so on a purely API-configured instance the keys never
exist and every GUI save warns "Unable to ping DataProviderService.svc … An
error occurred when verifying security for the message."
With kuiper_system_pulse_retrieve_keys: true (the default) the role performs
that same post-save step after applying kuiper_system_pulse: it runs the GUI's
validation (Settings/ValidateSystemPulseSettings) against the stored URLs, and
only when validation warns does it retrieve the keys and re-validate. A warning
that survives is reported but never fails the run.
The DataProvider warning is expected, on any server
Kuiper's validation also pings DataProviderService.svc/Basic, whose
Communication-Security-Full binding is TransportWithMessageCredential —
and Kuiper's ping sends no message credential. A System Pulse server built
entirely with Epic's Configuration Editor returns the identical "An error
occurred when verifying security for the message" warning (verified
2026-07-29 against a hand-installed server), so it reflects Epic's own
configuration rather than anything this automation does or misses.
Retrieving the registration keys does not clear it. Neither the data-filing
path (DataFilerService.svc, which validates clean) nor the
client token registration
depends on that endpoint. Do not "fix" the warning by downgrading those
endpoints to TransportSecurity: that silently removes client
authentication from the DataProvider service.
Requires an explicit API login (MVC Forms authentication, like the other
MVC-driven features). Set kuiper_system_pulse_retrieve_keys: false to skip.
Adding features
The phases are structured to grow. Each phase dispatcher (tasks/configure/main.yml,
tasks/fleet/main.yml) authenticates once via the shared tasks/kuiper_api_auth.yml
(a reusable kuiper_api_headers fact), then includes one file per feature guarded by
its input list. To add a feature (domains, map groups, channels, …):
- Add a
kuiper_configure_<feature>: []default indefaults/main.yml. - Copy
tasks/configure/credentials.ymlas the pattern — GET existing, build an identity set, POST only what's missing,no_logon anything with secrets. - Add a guarded
include_tasksfor it in the owning phase'smain.yml— instance settings go inconfigure, fleet topology infleet— and, if it needs a new tag, add that tag to the phase include's tag union intasks/main.yml.
The recommended build order (Tier 1 first) is LDAP connections → domains → credentials → environments → machines → groups → group membership → map groups, then deployment plumbing (package sources, channels, registration keys, service groups, Satellite install) and infra settings.
Certificates (kuiper_fleet_certificates)
Importing certificates into Kuiper's own store (Tools > Certificates) has no REST
API — the certificate controller only backs MVC dialogs. The role imports them
through the same MVC endpoint the web
UI uses (Certificates/Upload), create-if-absent matched on thumbprint, and
can then deploy each certificate to Kuiper machines and bind it to an IIS site
there (Certificates/AddCertificateToServer — the UI's "Add to Servers" flow).
Every entry names exactly one source of certificate material — src, a PFX
already sitting on the controller, or request, a certificate the run issues for
itself through the certificate_authority role.
An entry giving both, or neither, fails the preflight:
kuiper_fleet_certificates:
- src: files/deploy-signing.pfx # .pfx/.p12/.cer on the controller
passphrase: "{{ vaulted_pfx_password }}" # omit for a .cer with no private key
- src: "{{ kuiper_cert_staging_directory }}/{{ groups['app_hsw'] | sort | first }}.pfx"
passphrase: "{{ certificate_authority_cert_export_pwd }}" # vaulted; shared with the export step
machines: "{{ groups['app_hsw'] }}" # deploy to these Kuiper machines...
binding: # ...and bind in IIS there (optional —
siteName: Default Web Site # omit `binding` to deploy without one).
port: 443 # Defaults: kuiper_certificate_binding_defaults
Never put the passphrase in plaintext: reference a vaulted variable. In the IRE
environment the export password lives once, encrypted, in
group_vars/platform_azure/vault.yml (certificate_authority_cert_export_pwd),
where it both overrides the certificate_authority role's export-password
default (so export_certificate_windows produces the PFX with it) and feeds
this feature's passphrase — the two steps can never drift. Deriving machines
from inventory groups (one certificate per server-type group, with
certificate_authority_cert_sans covering every member — see
group_vars/app_hsw/vars.yml) keeps host names out of the desired state: adding
a machine to the group extends both the next issued certificate's SANs and where
Kuiper deploys it.
Whichever source an entry uses, the material ends up as base64 the Kuiper server
parses in memory (with EphemeralKeySet, so the private key never touches a
certificate store) to read its thumbprint. That thumbprint is compared against the
store, and only if absent is the upload body written on the target and posted, so
a converged run touches nothing and reports changed=0. The passphrase is built
into the request body on the target, never rendered into an Ansible file, and the
body file is deleted after the upload. The MVC login account must be non-MFA, like
the REST API account.
Issuing a certificate in the run (request)
Instead of pre-provisioning a PFX, an entry can carry a request map and have the
run mint the certificate itself: the CSR, signing, import and PFX export all happen
through the certificate_authority role on the Kuiper server (or
request.delegate_host), and Kuiper then distributes the result to the entry's
machines exactly as it would a pre-provisioned one. That order is the point — a
workgroup or DMZ target cannot enrol itself, and with the ansible_ca signer the
signing happens on the controller, so no target-side AD enrolment is involved at
all.
A request entry also needs a name — the certificate's logical name, and the
key the role reuses it by:
kuiper_fleet_certificates:
- name: hsw # required with request:
request:
common_name: azcusihsw001.aadds.example.org
sans: [azcusihsw001.aadds.example.org, "DNS:hsw.example.org"]
machines: "{{ groups['app_hsw'] }}"
binding:
siteName: Default Web Site
port: 443
The request map is merged over kuiper_certificate_request_defaults. The keys
are the certificate_authority inputs this feature exposes — signer
(ansible_ca, the default, or microsoft_ca), common_name, sans, template
and ca_config for a Microsoft CA, include_default_names, include_ip_san,
validity_days, delegate_host and renew_before_days — and
ansible-doc -t role kuiper documents each one with its default. Three of them
behave differently here than they do in the certificate_authority role itself:
include_default_namesisfalse. That role derives default SANs from the issuing host, which under delegation is the Kuiper server — not the machine the certificate is for. Name the subject and SANs explicitly.delegate_hostempty means the Kuiper host, not the controller.signer: ansible_caneeds the CA vault file loaded (ca_certificate/ca_private_key, fromextra_vars/certificate_authority.yml).microsoft_cainstead needs atemplatethe issuing machine account may enroll for.
Because certificate_authority reissues unconditionally every time it is called,
renewal is this role's gate: before issuing, it looks in the issuing host's
LocalMachine\My store for a certificate with a private key, more than
renew_before_days (default 30) of validity left, and the friendly name
<kuiper_certificate_friendly_name_prefix> - <name> - <kuiper host>. A hit skips
issuance and exports that certificate instead. The friendly name is therefore the
reuse key — changing kuiper_certificate_friendly_name_prefix (default Ansible)
or an entry's name reissues it. kuiper_certificate_keep_copies (default 2)
bounds how many superseded copies stay in the store.
The exported PFX is transient: it is written to kuiper_certificate_export_dir
(default C:\Windows\Temp\kuiper_certs), read as base64, and deleted in the same
run. kuiper_certificate_export_password /
KUIPER_CERTIFICATE_EXPORT_PASSWORD protects it; leave it empty and a random
password is generated per run, which is enough precisely because the file never
outlives the task.
The request path is not yet live-verified against the IRE environment. The
src path is unchanged and remains the proven one.
Machine deployment is idempotent against the certificate's AssociatedServers
(the store's record of where each certificate is deployed and bound): a machine
already carrying the certificate with the desired site and port is never touched.
Three prerequisites:
- Kuiper connects to the machine over a PSSession (WinRM) authenticated with a stored Windows credential associated with the machine's application — without one the deploy fails with "Failed to establish a PSSession connection".
- That credential must be a local Administrator on the machine. On Azure/AADDS
the default
AADDC Computers GPO, linked toOU=Serversbyplaybooks/create-ad-ous.yml, grantsAAD DC Administratorsthis membership. - An IIS binding requires IIS on the machine (otherwise: "Certificate added successfully, but failed to bind to a port. IIS prerequisites not installed"). The machine must also be resolvable from and reachable over WinRM by the Kuiper server — DMZ machines need the firewall to allow WinRM from the Kuiper server and a DNS record in the managed-domain zone.
Non-domain-joined (workgroup) targets need no per-entry configuration: the deploy runs as the logged-in API identity, which authenticates to the workgroup box via pass-through NTLM when the workgroup prerequisites are in place — see Non-domain-joined (workgroup) groups.
This is distinct from the Kuiper server / app-pool TLS certificate, which the
certificate_authority role manages.