2 # Copyright 2021 Canonical Ltd.
4 # Licensed under the Apache License, Version 2.0 (the "License"); you may
5 # not use this file except in compliance with the License. You may obtain
6 # a copy of the License at
8 # http://www.apache.org/licenses/LICENSE-2.0
10 # Unless required by applicable law or agreed to in writing, software
11 # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
12 # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
13 # License for the specific language governing permissions and limitations
16 # For those usages not covered by the Apache License, Version 2.0 please
17 # contact: legal@canonical.com
19 # To get in touch with the maintainers, please contact:
20 # osm-charmers@lists.launchpad.net
23 # This file populates the Actions tab on Charmhub.
24 # See https://juju.is/docs/some-url-to-be-determined/ for a checklist and guidance.
26 """Keystone charm module."""
29 from datetime
import datetime
31 from charms
.observability_libs
.v0
.kubernetes_service_patch
import KubernetesServicePatch
32 from config_validator
import ValidationError
33 from ops
import pebble
34 from ops
.charm
import ActionEvent
, CharmBase
, ConfigChangedEvent
, UpdateStatusEvent
35 from ops
.main
import main
36 from ops
.model
import ActiveStatus
, BlockedStatus
, Container
, MaintenanceStatus
39 from config
import ConfigModel
, MysqlConnectionData
, get_environment
, validate_config
40 from interfaces
import KeystoneServer
, MysqlClient
42 logger
= logging
.getLogger(__name__
)
45 # We expect the keystone container to use the default port
48 KEY_SETUP_FILE
= "/etc/keystone/key-setup"
49 CREDENTIAL_KEY_REPOSITORY
= "/etc/keystone/credential-keys/"
50 FERNET_KEY_REPOSITORY
= "/etc/keystone/fernet-keys/"
51 KEYSTONE_USER
= "keystone"
52 KEYSTONE_GROUP
= "keystone"
53 FERNET_MAX_ACTIVE_KEYS
= 3
54 KEYSTONE_FOLDER
= "/etc/keystone/"
57 class CharmError(Exception):
58 """Charm error exception."""
61 class KeystoneCharm(CharmBase
):
62 """Keystone Charm operator."""
64 on
= cluster
.ClusterEvents()
66 def __init__(self
, *args
) -> None:
67 super().__init
__(*args
)
68 event_observe_mapping
= {
69 self
.on
.keystone_pebble_ready
: self
._on
_config
_changed
,
70 self
.on
.config_changed
: self
._on
_config
_changed
,
71 self
.on
.update_status
: self
._on
_update
_status
,
72 self
.on
.cluster_keys_changed
: self
._on
_cluster
_keys
_changed
,
73 self
.on
["keystone"].relation_joined
: self
._publish
_keystone
_info
,
74 self
.on
["db"].relation_changed
: self
._on
_config
_changed
,
75 self
.on
["db"].relation_broken
: self
._on
_config
_changed
,
76 self
.on
["db-sync"].action
: self
._on
_db
_sync
_action
,
78 for event
, observer
in event_observe_mapping
.items():
79 self
.framework
.observe(event
, observer
)
80 self
.cluster
= cluster
.Cluster(self
)
81 self
.mysql_client
= MysqlClient(self
, relation_name
="db")
82 self
.keystone
= KeystoneServer(self
, relation_name
="keystone")
83 self
.service_patch
= KubernetesServicePatch(self
, [(f
"{self.app.name}", PORT
)])
86 def container(self
) -> Container
:
87 """Property to get keystone container."""
88 return self
.unit
.get_container("keystone")
90 def _on_db_sync_action(self
, event
: ActionEvent
):
91 process
= self
.container
.exec(["keystone-manage", "db_sync"])
94 event
.set_results({"output": "db-sync was successfully executed."})
95 except pebble
.ExecError
as e
:
96 error_message
= f
"db-sync action failed with code {e.exit_code} and stderr {e.stderr}."
97 logger
.error(error_message
)
98 event
.fail(error_message
)
100 def _publish_keystone_info(self
, _
):
101 """Handler for keystone-relation-joined."""
102 if self
.unit
.is_leader():
103 config
= ConfigModel(**dict(self
.config
))
104 self
.keystone
.publish_info(
105 host
=f
"http://{self.app.name}:{PORT}/v3",
107 user_domain_name
=config
.user_domain_name
,
108 project_domain_name
=config
.project_domain_name
,
109 username
=config
.service_username
,
110 password
=config
.service_password
,
111 service
=config
.service_project
,
112 keystone_db_password
=config
.keystone_db_password
,
113 region_id
=config
.region_id
,
114 admin_username
=config
.admin_username
,
115 admin_password
=config
.admin_password
,
116 admin_project_name
=config
.admin_project
,
119 def _on_config_changed(self
, _
: ConfigChangedEvent
) -> None:
120 """Handler for config-changed event."""
121 if self
.container
.can_connect():
123 self
._handle
_fernet
_key
_rotation
()
125 self
.unit
.status
= ActiveStatus()
126 except CharmError
as e
:
127 self
.unit
.status
= BlockedStatus(str(e
))
128 except ValidationError
as e
:
129 self
.unit
.status
= BlockedStatus(str(e
))
131 logger
.info("pebble socket not available, deferring config-changed")
132 self
.unit
.status
= MaintenanceStatus("waiting for pebble to start")
134 def _on_update_status(self
, event
: UpdateStatusEvent
) -> None:
135 """Handler for update-status event."""
136 if self
.container
.can_connect():
137 self
._handle
_fernet
_key
_rotation
()
139 logger
.info("pebble socket not available, deferring config-changed")
141 self
.unit
.status
= MaintenanceStatus("waiting for pebble to start")
143 def _on_cluster_keys_changed(self
, _
) -> None:
144 """Handler for ClusterKeysChanged event."""
145 self
._handle
_fernet
_key
_rotation
()
147 def _handle_fernet_key_rotation(self
) -> None:
148 """Handles fernet key rotation.
150 First, the function writes the existing keys in the relation to disk.
151 Then, if the unit is the leader, checks if the keys should be rotated
155 if self
.unit
.is_leader():
156 if not self
.cluster
.get_keys():
158 self
._fernet
_keys
_rotate
_and
_sync
()
160 def _key_write(self
) -> None:
161 """Write keys to container from the relation data."""
162 if self
.unit
.is_leader():
164 keys
= self
.cluster
.get_keys()
166 logger
.debug('"key_repository" not in relation data yet...')
169 for key_repository
in [FERNET_KEY_REPOSITORY
, CREDENTIAL_KEY_REPOSITORY
]:
170 self
._create
_keys
_folders
()
171 for key_number
, key
in keys
[key_repository
].items():
172 logger
.debug(f
"writing key {key_number} in {key_repository}")
173 file_path
= f
"{key_repository}{key_number}"
174 if self
._file
_changed
(file_path
, key
):
179 group
=KEYSTONE_GROUP
,
182 self
.container
.push(KEY_SETUP_FILE
, "")
184 def _file_changed(self
, file_path
: str, content
: str) -> bool:
185 """Check if file in container has changed its value.
187 This function checks if the file exists in the container. If it does,
188 then it checks if the content of that file is equal to the content passed to
189 this function. If the content is equal, the function returns False, otherwise True.
192 file_path (str): File path in the container.
193 content (str): Content of the file.
196 bool: True if the content of the file has changed, or the file doesn't exist in
197 the container. False if the content passed to this function is the same as
200 if self
._file
_exists
(file_path
):
201 old_content
= self
.container
.pull(file_path
).read()
202 if old_content
== content
:
206 def _create_keys_folders(self
) -> None:
207 """Create folders for Key repositories."""
208 fernet_key_repository_found
= False
209 credential_key_repository_found
= False
210 for file in self
.container
.list_files(KEYSTONE_FOLDER
):
211 if file.type == pebble
.FileType
.DIRECTORY
:
212 if file.path
== CREDENTIAL_KEY_REPOSITORY
:
213 credential_key_repository_found
= True
214 if file.path
== FERNET_KEY_REPOSITORY
:
215 fernet_key_repository_found
= True
216 if not fernet_key_repository_found
:
217 self
.container
.make_dir(
218 FERNET_KEY_REPOSITORY
,
224 if not credential_key_repository_found
:
225 self
.container
.make_dir(
226 CREDENTIAL_KEY_REPOSITORY
,
228 group
=KEYSTONE_GROUP
,
233 def _fernet_keys_rotate_and_sync(self
) -> None:
234 """Rotate and sync the keys if the unit is the leader and the primary key has expired.
236 The modification time of the staging key (key with index '0') is used,
237 along with the config setting "token-expiration" to determine whether to
240 The rotation time = token-expiration / (max-active-keys - 2)
241 where max-active-keys has a minimum of 3.
243 if not self
.unit
.is_leader():
246 fernet_key_file
= self
.container
.list_files(f
"{FERNET_KEY_REPOSITORY}0")[0]
247 last_rotation
= fernet_key_file
.last_modified
.timestamp()
248 except pebble
.APIError
:
250 "Fernet key rotation requested but key repository not " "initialized yet"
254 config
= ConfigModel(**self
.config
)
255 rotation_time
= config
.token_expiration
// (FERNET_MAX_ACTIVE_KEYS
- 2)
257 now
= datetime
.now().timestamp()
258 if last_rotation
+ rotation_time
> now
:
259 # No rotation to do as not reached rotation time
260 logger
.debug("No rotation needed")
261 self
._key
_leader
_set
()
263 # now rotate the keys and sync them
264 self
._fernet
_rotate
()
265 self
._key
_leader
_set
()
267 logger
.info("Rotated and started sync of fernet keys")
269 def _key_leader_set(self
) -> None:
270 """Read current key sets and update peer relation data.
272 The keys are read from the `FERNET_KEY_REPOSITORY` and `CREDENTIAL_KEY_REPOSITORY`
273 directories. Note that this function will fail if it is called on the unit that is
277 for key_repository
in [FERNET_KEY_REPOSITORY
, CREDENTIAL_KEY_REPOSITORY
]:
278 disk_keys
[key_repository
] = {}
279 for file in self
.container
.list_files(key_repository
):
280 key_content
= self
.container
.pull(f
"{key_repository}{file.name}").read()
281 disk_keys
[key_repository
][file.name
] = key_content
282 self
.cluster
.save_keys(disk_keys
)
284 def _fernet_rotate(self
) -> None:
285 """Rotate Fernet keys.
287 To rotate the Fernet tokens, and create a new staging key, it calls (as the
290 keystone-manage fernet_rotate
292 Note that we do not rotate the Credential encryption keys.
294 Note that this does NOT synchronise the keys between the units. This is
295 performed in `self._key_leader_set`.
297 logger
.debug("Rotating Fernet tokens")
307 logger
.debug(f
'Executing command: {" ".join(exec_command)}')
308 self
.container
.exec(exec_command
).wait()
309 logger
.info("Fernet keys successfully rotated.")
310 except pebble
.ExecError
as e
:
311 logger
.error(f
"Fernet Key rotation failed: {e}")
312 logger
.error("Exited with code %d. Stderr:", e
.exit_code
)
313 for line
in e
.stderr
.splitlines():
314 logger
.error(" %s", line
)
316 def _key_setup(self
) -> None:
317 """Initialize Fernet and Credential encryption key repositories.
319 To setup the key repositories:
321 keystone-manage fernet_setup
322 keystone-manage credential_setup
324 In addition we migrate any credentials currently stored in database using
325 the null key to be encrypted by the new credential key:
327 keystone-manage credential_migrate
329 Note that we only want to do this once, so we touch an empty file
330 (KEY_SETUP_FILE) to indicate that it has been done.
332 if self
._file
_exists
(KEY_SETUP_FILE
) or not self
.unit
.is_leader():
335 logger
.debug("Setting up key repositories for Fernet tokens and Credential encryption.")
337 for command
in ["fernet_setup", "credential_setup"]:
346 logger
.debug(f
'Executing command: {" ".join(exec_command)}')
347 self
.container
.exec(exec_command
).wait()
348 self
.container
.push(KEY_SETUP_FILE
, "")
349 logger
.info("Key repositories initialized successfully.")
350 except pebble
.ExecError
as e
:
351 logger
.error("Failed initializing key repositories.")
352 logger
.error("Exited with code %d. Stderr:", e
.exit_code
)
353 for line
in e
.stderr
.splitlines():
354 logger
.error(" %s", line
)
356 def _file_exists(self
, path
: str) -> bool:
357 """Check if a file exists in the container.
360 path (str): Path of the file to be checked.
363 bool: True if the file exists, else False.
367 _
= self
.container
.pull(path
)
369 except pebble
.PathError
:
371 exist_str
= "exists" if file_exists
else 'doesn"t exist'
372 logger
.debug(f
"File {path} {exist_str}.")
375 def _safe_restart(self
) -> None:
376 """Safely restart the keystone service.
378 This function (re)starts the keystone service after doing some safety checks,
379 like validating the charm configuration, checking the mysql relation is ready.
381 validate_config(self
.config
)
382 self
._check
_mysql
_data
()
383 # Workaround: OS_AUTH_URL is not ready when the entrypoint restarts apache2.
384 # The function `self._patch_entrypoint` fixes that.
385 self
._patch
_entrypoint
()
388 def _patch_entrypoint(self
) -> None:
389 """Patches the entrypoint of the Keystone service.
391 The entrypoint that restarts apache2, expects immediate communication to OS_AUTH_URL.
392 This does not happen instantly. This function patches the entrypoint to wait until a
393 curl to OS_AUTH_URL succeeds.
395 installer_script
= self
.container
.pull("/app/start.sh").read()
396 wait_until_ready_command
= "until $(curl --output /dev/null --silent --head --fail $OS_AUTH_URL); do echo '...'; sleep 5; done"
398 "/app/start-patched.sh",
399 installer_script
.replace(
400 "source setup_env", f
"source setup_env && {wait_until_ready_command}"
405 def _check_mysql_data(self
) -> None:
406 """Check if the mysql relation is ready.
409 CharmError: Error raised if the mysql relation is not ready.
411 if self
.mysql_client
.is_missing_data_in_unit() and not self
.config
.get("mysql-uri"):
412 raise CharmError("mysql relation is missing")
414 def _replan(self
) -> None:
415 """Replan keystone service.
417 This function starts the keystone service if it is not running.
418 If the service started already, this function will restart the
419 service if there are any changes to the layer.
421 mysql_data
= MysqlConnectionData(
422 self
.config
.get("mysql-uri")
423 or f
"mysql://root:{self.mysql_client.root_password}@{self.mysql_client.host}:{self.mysql_client.port}/"
426 "summary": "keystone layer",
427 "description": "pebble config layer for keystone",
430 "override": "replace",
431 "summary": "keystone service",
432 "command": "/app/start-patched.sh",
433 "startup": "enabled",
434 "environment": get_environment(self
.app
.name
, self
.config
, mysql_data
),
438 self
.container
.add_layer("keystone", layer
, combine
=True)
439 self
.container
.replan()
442 if __name__
== "__main__": # pragma: no cover