Declarative Keystone Identity Management (CRD)¶
Problem Description¶
Every OpenStack-Helm chart that owns a service catalog entry creates it with
three one-shot jobs from Helm-toolkit: <service>-ks-service from the
ks-service manifest, <service>-ks-user from the ks-user manifest and
<service>-ks-endpoints from the ks-endpoints manifest. Thirty-four
charts render a ks-user job, twenty-seven a ks-service job and twenty-seven a
ks-endpoints job. Each of those eighty-eight jobs runs a pod in the OpenStack
namespace that takes the Keystone administrative credentials from a secret
as environment variables and runs openstack CLI commands with them.
This has several drawbacks.
The administrative credentials are copied into every chart that owns a catalog entry. Thirty-four charts render their own
<service>-keystone-adminsecret, and each secret is read by up to three of these pods.The jobs run once. Most are Helm hooks that run on install and on every upgrade. They have no reconciliation loop, no
statusand no way to repair drift. If a user is deleted, a role assignment removed or an endpoint changed outside the chart, nothing notices until the nexthelm upgrade.Provisioning is written as shell scripts around the CLI.
ks-endpoints.shfinds a service by searching the output ofopenstack service list -f csvfor a line that ends in,<name>,<type>, and finds its endpoint with a regular expression overopenstack endpoint list. A service name or type that contains a comma, or a regular expression character such as a dot, changes what the pattern matches. The endpoint pattern also requires the literal field,True,, so a disabled endpoint is not found and a second one is created.ks-service.shstops as soon as a service with that name and type exists, so a changed description orenabledflag is never applied.ks-endpoints.shdeletes and recreates an endpoint when its URL changes. This throws away the endpoint ID, although the Identity API can update a URL in place. It also deletes every duplicate it finds and then creates a new endpoint, so all of the old IDs are lost.Each ks-endpoints job runs three containers per service type, one per interface, and every container pays the full client startup cost.
Keystone invalidates every token a user holds whenever the password is written, even when the new value is the same.
ks-user.shtherefore first tries to authenticate with the new password, looks forHTTP 401in the error output, and writes the password only if that attempt failed. Every failed attempt counts against[security_compliance] lockout_failure_attempts, and the job makes the attempt on everyhelm upgrade, whether the password changed or not.
Proposed Change¶
The Keystone chart gains eight custom resource definitions and the identity controller, a small program that reconciles them. A consumer chart renders custom resources that describe the identity objects it needs, using values it already declares. The identity controller creates those objects in Keystone. The administrative credentials stay in the chart that owns Keystone.
The MariaDB and RabbitMQ charts already offer this pattern. The MariaDB spec
replaces the per-chart db-init job with Database, User, Grant
and Connection resources. The RabbitMQ spec replaces rabbit-init
with a group of topology resources.
Keystone chart: custom resource definitions¶
Eight custom resource definitions in the
keystone.osh.openstack.org/v1alpha1 API group. The resources they define
are namespaced. They are rendered by keystone/templates/crds.yaml and gated
on .Values.manifests.crds:
Kind |
Describes |
|---|---|
|
A Keystone domain. |
|
A project within a domain. |
|
A role, global or domain-specific. |
|
A group of users. |
|
A user account, with its password in a secret. |
|
A role granted to a user or a group, on a project or a domain. |
|
A service catalog entry. |
|
The admin, internal and public URLs of a service in one region. |
The API group is per service, like the MariaDB and RabbitMQ groups, and for the
same reason: all three groups need a User kind, so a single flat
osh.openstack.org group could not hold them. Every kind belongs to the
keystone category, so kubectl get keystone lists the identity objects
in a namespace.
Each kind identifies a Keystone object by name, because a name is what a chart knows when it renders. Keystone generates the IDs.
services, endpoints and roles are already resource names in other
API groups: the first two in core v1 and the third in
rbac.authorization.k8s.io. Those three kinds therefore have to be addressed
by their full names, such as services.keystone.osh.openstack.org.
metadata.name is the Kubernetes name, chosen by the chart that renders the
resource. spec.name is the Keystone name. The two are independent for two
reasons. First, several charts need the same Keystone objects: the service
domain, the service project, and the admin, service and member
roles. Each chart declares its own resource for them, such as
cinder-service and glance-service. Because reconciliation looks an
object up and creates it only if it is missing, all of those resources converge
on one Keystone object. Second, a Keystone name does not have to be a valid
Kubernetes name. Cinder’s cinder_nova user and Heat’s heat_trustee user
contain an underscore, so each template lower-cases the Keystone name and
replaces every underscore with a hyphen.
Every kind has three common spec fields.
keystoneRef.name names the Keystone deployment that the resource belongs
to. When it is unset or empty, the resource belongs to this controller. When it
names a different deployment, the resource belongs to another controller, and
this controller ignores it completely and does not write its status. Status
belongs to the controller that serves a resource, and two controllers would
keep overwriting each other’s status, so a resource this controller ignores has
no condition at all. There is one exception: the controller removes its own
finalizer from such a resource, because leaving it would make the resource
impossible to delete.
deletionPolicy is either retain or delete, and defaults to
retain. Only delete adds a finalizer and removes the Keystone object.
The default is retain for two reasons. Deleting a Project orphans every
resource that belongs to it in every service, and helm uninstall of one
chart must not do that. And a shared object has several declarations: if Cinder
and Octavia both declare the service domain and Cinder is then uninstalled,
delete would remove the domain that Octavia is still using. The controller
cannot prevent that, because each resource records only what its own chart
asked for. Counting the resources that name the same Keystone object would
still be wrong, since it would count only the ones in this namespace and miss
every object created outside the charts.
The consumer chart templates therefore never set deletionPolicy, and
everything they render keeps the retain default. delete is meaningful
only for an object that exactly one resource declares, such as a service user
or an endpoint, and setting it is a deliberate choice in an override.
retryInterval is the shortest delay before a failed resource is tried
again. It is written as a Go duration, such as 30s. When it is unset, the
controller uses its own configured backoff.
Every kind has a status subresource. It holds observedGeneration and a
conditions list of metav1.Condition. Each condition has the same fields
as in the MariaDB and RabbitMQ groups, so readiness is read the same way in all
three. Six kinds also record the ID that Keystone assigned, in a field named
after the kind: domainID, projectID, roleID, groupID,
userID and serviceID. Endpoint records endpointIDs, one ID per
interface. RoleAssignment records no ID, because a grant has none.
status:
observedGeneration: 3
userID: 9a0e0f2c1b7d4e5f8a3c2b1d0e9f8a7b
conditions:
- type: Ready
status: "True" # True | False | Unknown
reason: Reconciled # Reconciled | ReconcileError
message: created user cinder in domain service
lastTransitionTime: "2026-08-24T19:20:00Z"
observedGeneration: 3
Readiness is the condition with type Ready. This is what kubectl wait
--for=condition=Ready reads and what the printer columns show.
lastTransitionTime records when status last changed. The Keystone ID is
stored so that a deployer can match a resource against the output of a command
such as openstack user show.
Example¶
These are the resources the Cinder chart renders for one of its service users
and for its catalog entry, in the order the controller handles them. That user
holds the admin, service and member roles. The shared domain,
project and roles are declared by every chart that needs them:
apiVersion: keystone.osh.openstack.org/v1alpha1
kind: Domain
metadata:
name: cinder-service
spec:
keystoneRef:
name: keystone
name: service
description: Domain for service
---
apiVersion: keystone.osh.openstack.org/v1alpha1
kind: Project
metadata:
name: cinder-service
spec:
keystoneRef:
name: keystone
name: service
domain: service
description: Service Project for service
---
apiVersion: keystone.osh.openstack.org/v1alpha1
kind: Role
metadata:
name: cinder-admin
spec:
keystoneRef:
name: keystone
name: admin
---
apiVersion: keystone.osh.openstack.org/v1alpha1
kind: Role
metadata:
name: cinder-service
spec:
keystoneRef:
name: keystone
name: service
---
apiVersion: keystone.osh.openstack.org/v1alpha1
kind: Role
metadata:
name: cinder-member
spec:
keystoneRef:
name: keystone
name: member
---
apiVersion: keystone.osh.openstack.org/v1alpha1
kind: User
metadata:
name: cinder-cinder
spec:
keystoneRef:
name: keystone
name: cinder
domain: service
description: Service User for RegionOne/service/cinder
defaultProject:
name: service
domain: service
passwordSecret:
name: cinder-keystone-user
key: OS_PASSWORD
---
apiVersion: keystone.osh.openstack.org/v1alpha1
kind: RoleAssignment
metadata:
name: cinder-cinder-admin
spec:
keystoneRef:
name: keystone
role:
name: admin
user:
name: cinder
domain: service
project:
name: service
domain: service
---
apiVersion: keystone.osh.openstack.org/v1alpha1
kind: RoleAssignment
metadata:
name: cinder-cinder-service
spec:
keystoneRef:
name: keystone
role:
name: service
user:
name: cinder
domain: service
project:
name: service
domain: service
---
apiVersion: keystone.osh.openstack.org/v1alpha1
kind: RoleAssignment
metadata:
name: cinder-cinder-member
spec:
keystoneRef:
name: keystone
role:
name: member
user:
name: cinder
domain: service
project:
name: service
domain: service
---
apiVersion: keystone.osh.openstack.org/v1alpha1
kind: Service
metadata:
name: cinder-volumev3
spec:
keystoneRef:
name: keystone
name: cinderv3
type: volumev3
description: "RegionOne: cinderv3 (volumev3) service"
---
apiVersion: keystone.osh.openstack.org/v1alpha1
kind: Endpoint
metadata:
name: cinder-volumev3
spec:
keystoneRef:
name: keystone
service:
name: cinderv3
type: volumev3
region: RegionOne
urls:
admin: http://cinder-api.openstack.svc.cluster.local:8776/v3
internal: http://cinder-api.openstack.svc.cluster.local:8776/v3
public: http://cinder.openstack.svc.cluster.local/v3
User and its password¶
User.spec does not hold a password. spec.passwordSecret names a secret
and a key instead. The key defaults to OS_PASSWORD, which is the key of the
<service>-keystone-<userClass> openrc secrets that every consumer chart
already renders. A chart that adopts this path therefore needs no new secret.
The User points at the secret its service already reads, so the two cannot
end up with different passwords.
The controller writes the password the same way ks-user.sh does, and for
the same reason. It first tries to authenticate as the user with the wanted
password, and it writes the password only if that attempt returns 401. What
changes is how often that probe runs. The controller folds the value of the
secret into its hash of the desired state, so the probe runs only when the
password has changed or during a periodic resync, not on every sweep.
Group and the group subject¶
A role is granted to a subject, which in Keystone is either a user or a
group. RoleAssignment.spec therefore sets exactly one of spec.user and
spec.group, and an x-kubernetes-validations expression enforces that in
the same way as the project or domain scope. None of the charts converted here
sets spec.group, because a service account is a user. Group is
therefore the one kind that no chart renders.
Group exists for federated deployments, where there is no user to name.
There, identities come from an identity provider as shadow users that Keystone
creates on first login. Nothing can be declared in advance, and this chart
holds no password for them.
What a federated deployment declares instead is the group, and the roles that group holds. Which users end up in that group is decided by a Keystone mapping: a set of rules that translates the assertion an identity provider sends into local attributes, including the groups a user is placed in. The mapping is itself a Keystone object, declared by one of the federation kinds listed as a follow-up below:
apiVersion: keystone.osh.openstack.org/v1alpha1
kind: Group
metadata:
name: federated-admins
spec:
keystoneRef:
name: keystone
name: federated-admins
domain: service
description: Administrators mapped in from the identity provider
---
apiVersion: keystone.osh.openstack.org/v1alpha1
kind: RoleAssignment
metadata:
name: federated-admins-admin
spec:
keystoneRef:
name: keystone
role:
name: admin
group:
name: federated-admins
domain: service
domain:
name: service
spec.group is defined here rather than with the rest of the federation
kinds, because it is the only part of federation that changes a kind this
change already defines. Adding it later would not break anything: spec.user
is not a required field, and the constraint lives entirely in one validation
rule, so only that rule would change.
Group membership is not a field. In a federated deployment the members are whatever the mapping produces, and a list of users would be a second place to describe a relationship the mapping already owns. Local group membership does need such a list, and nothing declared here uses local groups.
Endpoint and its region¶
Endpoint holds all three interfaces of one service in one region, as the
example above shows. One resource is used instead of three because the three
interfaces belong together. They change together and they are read together,
and the job being replaced already handles them as one unit, split over three
containers. An interface that is left out is not published.
The service is identified by both its name and its type. This is the same pair
that ks-endpoints.sh searches for, and neither part alone is unique. An
Endpoint deliberately does not reference a Service resource. The
controller looks the pair up in Keystone, so an Endpoint also works for a
service that this chart did not declare.
Regions are created automatically. Nothing here needs more of a region than its
name, so a ninth kind would exist only as a prerequisite for Endpoint. The
controller creates the region when it is missing, instead of relying on the
Identity API to create it, so the behaviour is the same on every Keystone
version.
Keystone chart: the identity controller¶
keystone/templates/bin/_identity_controller.py.tpl is a single-file Python
program. It is deployed by
keystone/templates/deployment-identity-controller.yaml and gated on
.Values.manifests.deployment_identity_controller. It follows the MariaDB
database controller that the MariaDB chart already ships: the same namespaced
RBAC role, the same environment variable convention, and the same
poll-and-reconcile structure.
It runs on the openstack-client image that the chart already pulls for its
bootstrap job, and nothing has to be added to that image. Kubernetes is reached
with the official kubernetes client, and Keystone with openstacksdk.
Both libraries are already installed there. openstacksdk is the library the
openstack CLI itself uses, and it has two advantages over sending HTTP
requests directly. It loads its authentication plugin from the environment, so
whatever identity.openrc configures works without the controller knowing
which plugin is in use. Its identity proxy also returns typed objects and typed
exceptions, instead of status codes that the caller has to interpret.
The password probe described above is the one place where a status code still
matters. It has to tell a rejected password from an unreachable Keystone, so it
asks the underlying keystoneauth1 session for a token and catches
Unauthorized directly. Writing the password after a network failure would
invalidate every token the user holds for no reason.
The openstack CLI is not used, because running it means starting a new
process for every object, every time the controller runs, and it reduces API
errors to an exit code and a line of text.
The controller polls instead of watching. One sweep covers all eight kinds and
runs every RECONCILE_INTERVAL seconds. Watching eight kinds from a
single-threaded script would need eight parallel streams, plus handling of
resourceVersion and 410 Gone. That is a large part of the program, and
it would only save a few seconds of latency on a small number of objects.
Within one sweep the kinds are reconciled in this order: Domain,
Project, Role, Group, User, RoleAssignment, Service,
Endpoint. The order matters, because a project needs its domain, a role
assignment needs its role, its subject and its project, and an endpoint needs
its service. Dependencies between resources are handled by this order and by
retrying, not by reading the status of other resources. Keystone is the source
of truth, and a resource that fails because its dependency does not exist yet
succeeds in a later sweep.
Six of the eight kinds are handled as a lookup, followed by a create or a
patch. RoleAssignment needs no lookup, because the grant itself is a
PUT. Endpoint is looked up by service, interface and region rather than
by a name:
Kind |
Calls the controller makes |
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Lookups are filtered queries, for example ?name=cinder&domain_id=..., and
the API is asked to do the filtering. Nothing is matched with a pattern over
text output, which removes the fragility of the current grep calls. A role
assignment is a PUT, so it is idempotent by definition, and the other kinds
are idempotent because they look an object up before updating it. The
controller stores a hash of the desired state to skip requests that would
change nothing, and a periodic forced resync repairs changes made outside the
controller. The jobs can only repair drift when a
deployer runs helm upgrade.
Two behaviours of the current scripts are deliberately not kept. First, an
Endpoint whose URL changed is updated with a patch, so it keeps its ID.
Second, duplicate endpoints for the same service, region and interface are
reported in the Ready message instead of being deleted. The controller
adopts one of them, chosen by lowest ID, and names the others in the message. A
controller should not silently delete objects that it did not create.
The controller authenticates with the administrative credentials in the
Keystone chart’s own keystone-keystone-admin secret, over the internal
identity endpoint. When secrets.tls.ca names a CA bundle, that bundle is
mounted and passed to the connection for verification.
The deployment runs one replica and replaces its pod rather than rolling it,
because the controller does no leader election. Two controllers probing the
same user’s password would double the failed-login count that
lockout_failure_attempts sees.
Consumer charts declare their own resources¶
Each chart declares its identity objects in its own
templates/identity-entities.yaml, gated by manifests.identity_entities.
There is deliberately no Helm-toolkit manifest that generates them. The users,
roles and catalog entries of a chart are part of that chart’s contract, and a
reader should be able to see them without following an indirection into shared
code. This also lets each chart differ from the others. Cinder publishes one,
two or three service types, and Heat owns a second user for its trustee,
without adding arguments to a shared macro.
The templates do not hardcode any name or credential:
{{- range $userClass := $serviceUsers }}
{{- $auth := index $envAll.Values.endpoints.identity.auth $userClass }}
...
name: {{ $auth.username }}
domain: {{ $auth.user_domain_name }}
passwordSecret:
name: {{ index $envAll.Values.secrets.identity $userClass }}
{{- end }}
endpoints.identity.auth.<userClass> holds the username, the domains, the
project and the roles. secrets.identity.<userClass> names the password
secret. endpoints.identity.hosts.default names the Keystone used for
keystoneRef. The URLs come from
helm-toolkit.endpoints.keystone_endpoint_uri_lookup, the helper that the
ks-endpoints job already uses, so the catalog entry of a chart does not change.
The only new value in a consumer chart is one manifests boolean.
For every service user, a converted chart renders a User and the
Project that user needs. It also renders a Domain for the user domain
and for the project domain, unless that domain is Default, which always
exists. It adds a Role and a RoleAssignment for each role in
endpoints.identity.auth.<userClass>.role, and for the member role that
ks-user.sh always adds. It renders a Service for every service type the
chart owns, and an Endpoint for each type that the ks-endpoints job
publishes, which is not always all of them. These are the same objects the
three jobs create, but each one is now a separate resource that can be
inspected.
Ordering and readiness¶
In a consumer chart, enabling manifests.identity_entities removes three
jobs and adds nothing to any pod. Those jobs were also
kubernetes-entrypoint dependencies: every component that reaches the
Keystone catalog waited for the ks_user and ks_endpoints jobs of its own
chart, and helm-toolkit.utils.dependency_jobs_filter drops a disabled job
from those lists. Nothing would then make a component wait.
The custom resources take that role instead. Each override adds a
custom_resources dependency for the same components, on the User and
the Endpoint the chart declares:
dependencies:
static:
api:
custom_resources:
- apiVersion: keystone.osh.openstack.org/v1alpha1
kind: User
name: cinder-cinder
conditions:
- type: Ready
This waits for the work itself rather than for a job that did the work, which
is the more direct statement of the same requirement. kubernetes-entrypoint
selects a condition by type, so conditions names the Ready condition
this group reports. The MariaDB group already depends on readiness this way.
The RBAC follows automatically.
helm-toolkit.snippets.kubernetes_pod_rbac_roles emits one rule per
dependency, taking the API group from the entry’s apiVersion and the
resource from its kind. This group needs nothing added, because all eight
kinds are pluralised as the lowercase kind plus an s. The rule is derived
per dependency, rather than granted for a whole group, on purpose:
apiGroups accepts either an exact group or *, and * also covers the
core group, which would give every init container get access to Secrets.
Who may create a RoleAssignment¶
A RoleAssignment is a request to grant a role, and the controller carries
it out with the Keystone administrative credentials. Anyone who can create one
in the controller’s namespace can therefore grant any role, including
admin, to any user. It has to be treated like the administrative secret
itself, and Kubernetes RBAC on roleassignments.keystone.osh.openstack.org
is where that is done. Write access to it must be granted no more widely than
access to the secret.
Kubernetes cannot help here. Its own RBAC refuses to let a subject create a
Role granting permissions the subject does not already hold, unless that
subject has the escalate verb. No such check exists for a custom resource,
because nothing tells the API server that this kind is privileged.
Three properties limit the reach of a resource that is created anyway.
The controller reads only its own namespace, so a RoleAssignment in any
other namespace is never reconciled. It also honours keystoneRef, so a
resource naming a different Keystone deployment is ignored.
The controller’s own RBAC role grants get, list, watch, update
and patch on the eight kinds, and create and delete on none of
them. It can only act on resources that something else created, and cannot
invent an assignment of its own.
The boundary is not new. Every one of the eighty-eight jobs this change replaces mounts the same administrative secret into a pod in the same namespace, so being able to create objects in the OpenStack namespace already means being able to grant any role in Keystone. What changes is the shape of the request: a small declarative object that a reviewer can read, in place of a pod running a shell script.
Backward compatibility¶
manifests.identity_entities defaults to false in every consumer chart,
and manifests.deployment_identity_controller defaults to false in the
Keystone chart. With these defaults every consumer chart renders exactly as it
does today, and no existing deployment gains a pod, an RBAC rule or a restart.
The Keystone chart does render the eight definitions, because
manifests.crds defaults to true as in the MariaDB and RabbitMQ charts,
but they do nothing while no resources exist. They also carry
helm.sh/resource-policy: keep, so uninstalling the Keystone release does
not cascade-delete the identity resources in the cluster. The hash of the bin
configmap is used in pod template annotations, so the controller’s entry in
that configmap is gated on manifests.deployment_identity_controller too.
The two provisioning paths cannot be used together. Rendering fails with an
explicit message if manifests.identity_entities is enabled together with
manifests.job_ks_user, manifests.job_ks_service or
manifests.job_ks_endpoints, because both paths would write the same
password and compete over the same catalog entry. Nothing is needed in the
other direction, because helm-toolkit.utils.dependency_jobs_filter already
removes a disabled job from the dependencies of other jobs.
Keystone’s own catalog entry does not change. keystone-manage bootstrap
creates it in the chart’s db-sync job, and that same command creates the admin
account the controller authenticates with.
The <service>-keystone-admin secrets are still rendered. This change
removes their largest group of readers, which is the three ks jobs in every
chart. But twenty-three charts also read them elsewhere, for example in Nova’s
cell setup, Cinder’s internal tenant job and every rally-test pod. Removing
these secrets is therefore a follow-up.
Implementation¶
Assignee(s)¶
- Primary assignee:
kozhukalov (Vladimir Kozhukalov <kozhukalov@gmail.com>)
Work Items¶
Add the eight custom resource definitions, the identity controller, its deployment and RBAC, and the supporting values to the Keystone chart.
Convert the six charts that own a catalog entry and are deployed by the check job below: Glance, Cinder, Placement, Nova, Neutron and Heat. Each chart gains a
templates/identity-entities.yaml, amanifests.identity_entitiesswitch and an override that enables the path.Add a check pipeline job that deploys the compute kit and Cinder with the new provisioning path enabled.
Follow-ups, deliberately out of scope here:
Heat’s
ks-user-domainjob, which creates a domain, a domain-scoped user and a domain-scoped role assignment with ks-domain-user.sh.Domainand a domain-scopedRoleAssignmentcan express it, but converting that job is separate from the three jobs every chart renders.The remaining charts that own a catalog entry.
Retire the three jobs, their manifests and their scripts once every chart is converted, and remove the per-chart administrative openrc secrets with them.
The federation kinds:
IdentityProvider,Mapping,ProtocolandServiceProvider. They are a separate change. All four are created withPUTand an ID chosen by the caller, so they need no lookup before create, no ID instatusand no comparison for drift. The rules of aMappingare Keystone’s own JSON dialect, sospec.rulesshould carryx-kubernetes-preserve-unknown-fields: trueinstead of repeating a schema that would fall behind. Deleting anIdentityProvideralso leaves every shadow user created through it without a provider, so theretaindefault matters even more there.System-scoped role assignments,
PUT /v3/system/(users|groups)/{id}/roles/{role}. The scope is currently exactly one of a project or a domain.
Alternatives¶
Make the three jobs idempotent and re-runnable. Re-running them is not the
problem, because they can already be re-run. They would still copy
administrative credentials into eighty-eight namespaced pods, still report no
status, and still notice drift only during helm upgrade.
Build the controller on an operator framework such as kopf. kopf turns handlers into decorated Python functions, watches the resources for the caller, manages finalizers, and retries with backoff. It would remove the sweep loop, the desired-state bookkeeping and the finalizer handling from this controller: about 260 of its roughly 1000 lines. The eight handlers that do the Keystone work would be the same either way, because that is where the work is.
What counts against it is that watching resources makes the password harder to
follow, not easier. A rotation changes the secret and not the User, so an
event-driven handler would have to watch secrets as well and map each one back
to the resources that name it, or fall back to a timer, which is the loop
again. Reading the secret while computing the desired state needs neither.
Keep these custom resources, but reconcile them with ``shell-operator``.
The charts would render exactly the resources described above, and
shell-operator would run a hook on each change instead of the controller
running a loop. A hook is any executable, so the hooks could be written in
Python and use openstacksdk in the same way this controller does.
That is what makes the alternative weak: the hooks would be the reconcile
logic. The same code still has to be written, maintained and shipped in an
image, so the framework replaces the loop and nothing else.
shell-operator runs a hook as a new process for each invocation, so every
invocation authenticates to Keystone again, where one long-running controller
holds a single session.
Reconcile the Keystone objects with Crossplane and its OpenStack provider.
crossplane-contrib/provider-openstack is generated with Upjet from the
OpenStack Terraform provider, and it covers every object this spec needs:
ProjectV3, where a domain is a project with isDomain: true, plus
RoleV3, GroupV3, UserV3, RoleAssignmentV3, ServiceV3 and
EndpointV3. It reads the user password from a secret, reconciles on its own
schedule and writes the result into each resource’s status. The deployment
environment would install Crossplane and the provider, the Keystone chart would
render a ProviderConfig, and the consumer charts would render managed
resources instead of the kinds defined here. No controller would have to be
written at all.
Three things count against it.
A managed resource always creates its object, and it cannot take over an object
that already exists. The external name of every identity resource in the
provider is the ID that Keystone returns after the create, so adopting an
existing object means writing that ID into the crossplane.io/external-name
annotation, and a chart does not know it. The design above depends on the
opposite behaviour. Several charts declare the service domain, the
service project and the admin, service and member roles, and
they converge on one Keystone object because reconciliation looks the object up
before creating it. With managed resources the first chart would create the
domain and every later one would fail, because Keystone requires domain names
to be unique and answers the second create with a conflict.
The provider names a domain only by ID, which a chart cannot know for the same
reason. A domainIdRef field that resolves a domain through the name of
another managed resource is open for review upstream and is in no release yet.
See the domain reference PR.
A password changed outside Kubernetes is not repaired. The provider cannot read
a password back out of Keystone, so UserV3 carries no password in its
observed state and has nothing to compare against. The controller in this spec
learns it by authenticating as the user.
Documentation Impact¶
The installation documentation gains a section about declarative identity
management and how to enable it. Chart value references are generated from the
values.yaml comments, so the new keys are documented there.