Skip to content

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:

ansible-playbook --limit=<kuiper-host> playbooks/<name>.yml --tags configure,fleet

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:

ansible-playbook --limit=<kuiper-host> playbooks/<name>.yml --tags machines,groups,certificates

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) returns 401.
  • 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 returns 401 until 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:

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):

kuiper_infrastructure_deployment_settings:
  enableNtlm: true

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:

kuiper_winrm_trusted_hosts:
  - azcusiahsw001
  - azcusiahsw001.aadds.ire-copier.sapphirehealth.org

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").
  • perimeterServerFixedKey must be non-blank ("Configured as a perimeter server but fixed key override is blank"); Kuiper derives isPerimeterServerFixedKeyDefined from 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:

    attributes:
      name: HSW
      systemPulseMetricGroup: Hyperspace Web
      deploymentSetting:
        # ...

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 systemPulseUrlId is 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.
  • tags must already exist in Kuiper, or the save is rejected with no error. Leave tags unset 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 use DELETE 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-routePOST 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_tolerantisDowntimeTolerant. This is the "install prerequisites" control: true lets Kuiper install prerequisites and reboot member machines as needed to complete the deploy; false does neither.
  • kuiper_package_deploy_ignore_warningsignoreWarnings. true proceeds past voided release-authorization (RA) warnings; false stalls 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):

kuiper_base_package_source:
  path: "\\\\acct.file.core.windows.net\\share\\Kuiper"
  • The update is a JSON:API PATCH api/settings/v1/packagesources/{id} with an {attributes: {...}} body. A flat body 400s (UnknownKeyInPrimaryDataObject); PUT, and collection-level PATCH/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 isMsa is 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.
  • maxConcurrentDownloads must be 1–200 on write, but a fresh Base Package Source reads 0; an out-of-range effective value is coerced to kuiper_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):

  • useForMonitoring is the System Monitoring enable ("Use Satellite to collect performance counter and service status metrics and submit data to System Pulse"); enableSystemMonitoringForClientApplications is the separate, dependent client-applications row and cannot be enabled without it.
  • deployAsApplicationPrerequisite requires a hostname: Kuiper rejects it unless internalKuiperHostnames or externalKuiperEndpoints is 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.internal is an A record in the ire-copier.internal private 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):

  • rootUrl must be an epic.com URL. Anything else is rejected with HTTP 400 The 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 under epic.com stores 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 rootUrl or username drifts — a converged run reports no change. To rotate the password without changing the readable fields, set kuiper_sueeds_force_secret_update: true for 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.

kuiper_enable_perimeter_hsw_ui: true

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).

kuiper_system_pulse:
  defaultSystemPulseServer: "https://systempulse.ire-copier.internal"

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, …):

  1. Add a kuiper_configure_<feature>: [] default in defaults/main.yml.
  2. Copy tasks/configure/credentials.yml as the pattern — GET existing, build an identity set, POST only what's missing, no_log on anything with secrets.
  3. Add a guarded include_tasks for it in the owning phase's main.yml — instance settings go in configure, fleet topology in fleet — and, if it needs a new tag, add that tag to the phase include's tag union in tasks/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_names is false. 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_host empty means the Kuiper host, not the controller.
  • signer: ansible_ca needs the CA vault file loaded (ca_certificate / ca_private_key, from extra_vars/certificate_authority.yml). microsoft_ca instead needs a template the 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 to OU=Servers by playbooks/create-ad-ous.yml, grants AAD DC Administrators this 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.