-
Notifications
You must be signed in to change notification settings - Fork 10
/
Copy pathdata_interfaces.py
3757 lines (2947 loc) · 139 KB
/
data_interfaces.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
# Copyright 2023 Canonical Ltd.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
r"""Library to manage the relation for the data-platform products.
This library contains the Requires and Provides classes for handling the relation
between an application and multiple managed application supported by the data-team:
MySQL, Postgresql, MongoDB, Redis, and Kafka.
### Database (MySQL, Postgresql, MongoDB, and Redis)
#### Requires Charm
This library is a uniform interface to a selection of common database
metadata, with added custom events that add convenience to database management,
and methods to consume the application related data.
Following an example of using the DatabaseCreatedEvent, in the context of the
application charm code:
```python
from charms.data_platform_libs.v0.data_interfaces import (
DatabaseCreatedEvent,
DatabaseRequires,
)
class ApplicationCharm(CharmBase):
# Application charm that connects to database charms.
def __init__(self, *args):
super().__init__(*args)
# Charm events defined in the database requires charm library.
self.database = DatabaseRequires(self, relation_name="database", database_name="database")
self.framework.observe(self.database.on.database_created, self._on_database_created)
def _on_database_created(self, event: DatabaseCreatedEvent) -> None:
# Handle the created database
# Create configuration file for app
config_file = self._render_app_config_file(
event.username,
event.password,
event.endpoints,
)
# Start application with rendered configuration
self._start_application(config_file)
# Set active status
self.unit.status = ActiveStatus("received database credentials")
```
As shown above, the library provides some custom events to handle specific situations,
which are listed below:
- database_created: event emitted when the requested database is created.
- endpoints_changed: event emitted when the read/write endpoints of the database have changed.
- read_only_endpoints_changed: event emitted when the read-only endpoints of the database
have changed. Event is not triggered if read/write endpoints changed too.
If it is needed to connect multiple database clusters to the same relation endpoint
the application charm can implement the same code as if it would connect to only
one database cluster (like the above code example).
To differentiate multiple clusters connected to the same relation endpoint
the application charm can use the name of the remote application:
```python
def _on_database_created(self, event: DatabaseCreatedEvent) -> None:
# Get the remote app name of the cluster that triggered this event
cluster = event.relation.app.name
```
It is also possible to provide an alias for each different database cluster/relation.
So, it is possible to differentiate the clusters in two ways.
The first is to use the remote application name, i.e., `event.relation.app.name`, as above.
The second way is to use different event handlers to handle each cluster events.
The implementation would be something like the following code:
```python
from charms.data_platform_libs.v0.data_interfaces import (
DatabaseCreatedEvent,
DatabaseRequires,
)
class ApplicationCharm(CharmBase):
# Application charm that connects to database charms.
def __init__(self, *args):
super().__init__(*args)
# Define the cluster aliases and one handler for each cluster database created event.
self.database = DatabaseRequires(
self,
relation_name="database",
database_name="database",
relations_aliases = ["cluster1", "cluster2"],
)
self.framework.observe(
self.database.on.cluster1_database_created, self._on_cluster1_database_created
)
self.framework.observe(
self.database.on.cluster2_database_created, self._on_cluster2_database_created
)
def _on_cluster1_database_created(self, event: DatabaseCreatedEvent) -> None:
# Handle the created database on the cluster named cluster1
# Create configuration file for app
config_file = self._render_app_config_file(
event.username,
event.password,
event.endpoints,
)
...
def _on_cluster2_database_created(self, event: DatabaseCreatedEvent) -> None:
# Handle the created database on the cluster named cluster2
# Create configuration file for app
config_file = self._render_app_config_file(
event.username,
event.password,
event.endpoints,
)
...
```
When it's needed to check whether a plugin (extension) is enabled on the PostgreSQL
charm, you can use the is_postgresql_plugin_enabled method. To use that, you need to
add the following dependency to your charmcraft.yaml file:
```yaml
parts:
charm:
charm-binary-python-packages:
- psycopg[binary]
```
### Provider Charm
Following an example of using the DatabaseRequestedEvent, in the context of the
database charm code:
```python
from charms.data_platform_libs.v0.data_interfaces import DatabaseProvides
class SampleCharm(CharmBase):
def __init__(self, *args):
super().__init__(*args)
# Charm events defined in the database provides charm library.
self.provided_database = DatabaseProvides(self, relation_name="database")
self.framework.observe(self.provided_database.on.database_requested,
self._on_database_requested)
# Database generic helper
self.database = DatabaseHelper()
def _on_database_requested(self, event: DatabaseRequestedEvent) -> None:
# Handle the event triggered by a new database requested in the relation
# Retrieve the database name using the charm library.
db_name = event.database
# generate a new user credential
username = self.database.generate_user()
password = self.database.generate_password()
# set the credentials for the relation
self.provided_database.set_credentials(event.relation.id, username, password)
# set other variables for the relation event.set_tls("False")
```
As shown above, the library provides a custom event (database_requested) to handle
the situation when an application charm requests a new database to be created.
It's preferred to subscribe to this event instead of relation changed event to avoid
creating a new database when other information other than a database name is
exchanged in the relation databag.
### Kafka
This library is the interface to use and interact with the Kafka charm. This library contains
custom events that add convenience to manage Kafka, and provides methods to consume the
application related data.
#### Requirer Charm
```python
from charms.data_platform_libs.v0.data_interfaces import (
BootstrapServerChangedEvent,
KafkaRequires,
TopicCreatedEvent,
)
class ApplicationCharm(CharmBase):
def __init__(self, *args):
super().__init__(*args)
self.kafka = KafkaRequires(self, "kafka_client", "test-topic")
self.framework.observe(
self.kafka.on.bootstrap_server_changed, self._on_kafka_bootstrap_server_changed
)
self.framework.observe(
self.kafka.on.topic_created, self._on_kafka_topic_created
)
def _on_kafka_bootstrap_server_changed(self, event: BootstrapServerChangedEvent):
# Event triggered when a bootstrap server was changed for this application
new_bootstrap_server = event.bootstrap_server
...
def _on_kafka_topic_created(self, event: TopicCreatedEvent):
# Event triggered when a topic was created for this application
username = event.username
password = event.password
tls = event.tls
tls_ca= event.tls_ca
bootstrap_server event.bootstrap_server
consumer_group_prefic = event.consumer_group_prefix
zookeeper_uris = event.zookeeper_uris
...
```
As shown above, the library provides some custom events to handle specific situations,
which are listed below:
- topic_created: event emitted when the requested topic is created.
- bootstrap_server_changed: event emitted when the bootstrap server have changed.
- credential_changed: event emitted when the credentials of Kafka changed.
### Provider Charm
Following the previous example, this is an example of the provider charm.
```python
class SampleCharm(CharmBase):
from charms.data_platform_libs.v0.data_interfaces import (
KafkaProvides,
TopicRequestedEvent,
)
def __init__(self, *args):
super().__init__(*args)
# Default charm events.
self.framework.observe(self.on.start, self._on_start)
# Charm events defined in the Kafka Provides charm library.
self.kafka_provider = KafkaProvides(self, relation_name="kafka_client")
self.framework.observe(self.kafka_provider.on.topic_requested, self._on_topic_requested)
# Kafka generic helper
self.kafka = KafkaHelper()
def _on_topic_requested(self, event: TopicRequestedEvent):
# Handle the on_topic_requested event.
topic = event.topic
relation_id = event.relation.id
# set connection info in the databag relation
self.kafka_provider.set_bootstrap_server(relation_id, self.kafka.get_bootstrap_server())
self.kafka_provider.set_credentials(relation_id, username=username, password=password)
self.kafka_provider.set_consumer_group_prefix(relation_id, ...)
self.kafka_provider.set_tls(relation_id, "False")
self.kafka_provider.set_zookeeper_uris(relation_id, ...)
```
As shown above, the library provides a custom event (topic_requested) to handle
the situation when an application charm requests a new topic to be created.
It is preferred to subscribe to this event instead of relation changed event to avoid
creating a new topic when other information other than a topic name is
exchanged in the relation databag.
"""
import copy
import json
import logging
from abc import ABC, abstractmethod
from collections import UserDict, namedtuple
from datetime import datetime
from enum import Enum
from typing import (
Callable,
Dict,
ItemsView,
KeysView,
List,
Optional,
Set,
Tuple,
Union,
ValuesView,
)
from ops import JujuVersion, Model, Secret, SecretInfo, SecretNotFoundError
from ops.charm import (
CharmBase,
CharmEvents,
RelationChangedEvent,
RelationCreatedEvent,
RelationEvent,
SecretChangedEvent,
)
from ops.framework import EventSource, Object
from ops.model import Application, ModelError, Relation, Unit
# The unique Charmhub library identifier, never change it
LIBID = "6c3e6b6680d64e9c89e611d1a15f65be"
# Increment this major API version when introducing breaking changes
LIBAPI = 0
# Increment this PATCH version before using `charmcraft publish-lib` or reset
# to 0 if you are raising the major API version
LIBPATCH = 41
PYDEPS = ["ops>=2.0.0"]
# Starting from what LIBPATCH number to apply legacy solutions
# v0.17 was the last version without secrets
LEGACY_SUPPORT_FROM = 17
logger = logging.getLogger(__name__)
Diff = namedtuple("Diff", "added changed deleted")
Diff.__doc__ = """
A tuple for storing the diff between two data mappings.
added - keys that were added
changed - keys that still exist but have new values
deleted - key that were deleted"""
PROV_SECRET_PREFIX = "secret-"
REQ_SECRET_FIELDS = "requested-secrets"
GROUP_MAPPING_FIELD = "secret_group_mapping"
GROUP_SEPARATOR = "@"
MODEL_ERRORS = {
"not_leader": "this unit is not the leader",
"no_label_and_uri": "ERROR either URI or label should be used for getting an owned secret but not both",
"owner_no_refresh": "ERROR secret owner cannot use --refresh",
}
##############################################################################
# Exceptions
##############################################################################
class DataInterfacesError(Exception):
"""Common ancestor for DataInterfaces related exceptions."""
class SecretError(DataInterfacesError):
"""Common ancestor for Secrets related exceptions."""
class SecretAlreadyExistsError(SecretError):
"""A secret that was to be added already exists."""
class SecretsUnavailableError(SecretError):
"""Secrets aren't yet available for Juju version used."""
class SecretsIllegalUpdateError(SecretError):
"""Secrets aren't yet available for Juju version used."""
class IllegalOperationError(DataInterfacesError):
"""To be used when an operation is not allowed to be performed."""
class PrematureDataAccessError(DataInterfacesError):
"""To be raised when the Relation Data may be accessed (written) before protocol init complete."""
##############################################################################
# Global helpers / utilities
##############################################################################
##############################################################################
# Databag handling and comparison methods
##############################################################################
def get_encoded_dict(
relation: Relation, member: Union[Unit, Application], field: str
) -> Optional[Dict[str, str]]:
"""Retrieve and decode an encoded field from relation data."""
data = json.loads(relation.data[member].get(field, "{}"))
if isinstance(data, dict):
return data
logger.error("Unexpected datatype for %s instead of dict.", str(data))
def get_encoded_list(
relation: Relation, member: Union[Unit, Application], field: str
) -> Optional[List[str]]:
"""Retrieve and decode an encoded field from relation data."""
data = json.loads(relation.data[member].get(field, "[]"))
if isinstance(data, list):
return data
logger.error("Unexpected datatype for %s instead of list.", str(data))
def set_encoded_field(
relation: Relation,
member: Union[Unit, Application],
field: str,
value: Union[str, list, Dict[str, str]],
) -> None:
"""Set an encoded field from relation data."""
relation.data[member].update({field: json.dumps(value)})
def diff(event: RelationChangedEvent, bucket: Optional[Union[Unit, Application]]) -> Diff:
"""Retrieves the diff of the data in the relation changed databag.
Args:
event: relation changed event.
bucket: bucket of the databag (app or unit)
Returns:
a Diff instance containing the added, deleted and changed
keys from the event relation databag.
"""
# Retrieve the old data from the data key in the application relation databag.
if not bucket:
return Diff([], [], [])
old_data = get_encoded_dict(event.relation, bucket, "data")
if not old_data:
old_data = {}
# Retrieve the new data from the event relation databag.
new_data = (
{key: value for key, value in event.relation.data[event.app].items() if key != "data"}
if event.app
else {}
)
# These are the keys that were added to the databag and triggered this event.
added = new_data.keys() - old_data.keys() # pyright: ignore [reportAssignmentType]
# These are the keys that were removed from the databag and triggered this event.
deleted = old_data.keys() - new_data.keys() # pyright: ignore [reportAssignmentType]
# These are the keys that already existed in the databag,
# but had their values changed.
changed = {
key
for key in old_data.keys() & new_data.keys() # pyright: ignore [reportAssignmentType]
if old_data[key] != new_data[key] # pyright: ignore [reportAssignmentType]
}
# Convert the new_data to a serializable format and save it for a next diff check.
set_encoded_field(event.relation, bucket, "data", new_data)
# Return the diff with all possible changes.
return Diff(added, changed, deleted)
##############################################################################
# Module decorators
##############################################################################
def leader_only(f):
"""Decorator to ensure that only leader can perform given operation."""
def wrapper(self, *args, **kwargs):
if self.component == self.local_app and not self.local_unit.is_leader():
logger.error(
"This operation (%s()) can only be performed by the leader unit", f.__name__
)
return
return f(self, *args, **kwargs)
wrapper.leader_only = True
return wrapper
def juju_secrets_only(f):
"""Decorator to ensure that certain operations would be only executed on Juju3."""
def wrapper(self, *args, **kwargs):
if not self.secrets_enabled:
raise SecretsUnavailableError("Secrets unavailable on current Juju version")
return f(self, *args, **kwargs)
return wrapper
def dynamic_secrets_only(f):
"""Decorator to ensure that certain operations would be only executed when NO static secrets are defined."""
def wrapper(self, *args, **kwargs):
if self.static_secret_fields:
raise IllegalOperationError(
"Unsafe usage of statically and dynamically defined secrets, aborting."
)
return f(self, *args, **kwargs)
return wrapper
def either_static_or_dynamic_secrets(f):
"""Decorator to ensure that static and dynamic secrets won't be used in parallel."""
def wrapper(self, *args, **kwargs):
if self.static_secret_fields and set(self.current_secret_fields) - set(
self.static_secret_fields
):
raise IllegalOperationError(
"Unsafe usage of statically and dynamically defined secrets, aborting."
)
return f(self, *args, **kwargs)
return wrapper
def legacy_apply_from_version(version: int) -> Callable:
"""Decorator to decide whether to apply a legacy function or not.
Based on LEGACY_SUPPORT_FROM module variable value, the importer charm may only want
to apply legacy solutions starting from a specific LIBPATCH.
NOTE: All 'legacy' functions have to be defined and called in a way that they return `None`.
This results in cleaner and more secure execution flows in case the function may be disabled.
This requirement implicitly means that legacy functions change the internal state strictly,
don't return information.
"""
def decorator(f: Callable[..., None]):
"""Signature is ensuring None return value."""
f.legacy_version = version
def wrapper(self, *args, **kwargs) -> None:
if version >= LEGACY_SUPPORT_FROM:
return f(self, *args, **kwargs)
return wrapper
return decorator
##############################################################################
# Helper classes
##############################################################################
class Scope(Enum):
"""Peer relations scope."""
APP = "app"
UNIT = "unit"
class SecretGroup(str):
"""Secret groups specific type."""
class SecretGroupsAggregate(str):
"""Secret groups with option to extend with additional constants."""
def __init__(self):
self.USER = SecretGroup("user")
self.TLS = SecretGroup("tls")
self.EXTRA = SecretGroup("extra")
def __setattr__(self, name, value):
"""Setting internal constants."""
if name in self.__dict__:
raise RuntimeError("Can't set constant!")
else:
super().__setattr__(name, SecretGroup(value))
def groups(self) -> list:
"""Return the list of stored SecretGroups."""
return list(self.__dict__.values())
def get_group(self, group: str) -> Optional[SecretGroup]:
"""If the input str translates to a group name, return that."""
return SecretGroup(group) if group in self.groups() else None
SECRET_GROUPS = SecretGroupsAggregate()
class CachedSecret:
"""Locally cache a secret.
The data structure is precisely reusing/simulating as in the actual Secret Storage
"""
KNOWN_MODEL_ERRORS = [MODEL_ERRORS["no_label_and_uri"], MODEL_ERRORS["owner_no_refresh"]]
def __init__(
self,
model: Model,
component: Union[Application, Unit],
label: str,
secret_uri: Optional[str] = None,
legacy_labels: List[str] = [],
):
self._secret_meta = None
self._secret_content = {}
self._secret_uri = secret_uri
self.label = label
self._model = model
self.component = component
self.legacy_labels = legacy_labels
self.current_label = None
@property
def meta(self) -> Optional[Secret]:
"""Getting cached secret meta-information."""
if not self._secret_meta:
if not (self._secret_uri or self.label):
return
try:
self._secret_meta = self._model.get_secret(label=self.label)
except SecretNotFoundError:
# Falling back to seeking for potential legacy labels
self._legacy_compat_find_secret_by_old_label()
# If still not found, to be checked by URI, to be labelled with the proposed label
if not self._secret_meta and self._secret_uri:
self._secret_meta = self._model.get_secret(id=self._secret_uri, label=self.label)
return self._secret_meta
##########################################################################
# Backwards compatibility / Upgrades
##########################################################################
# These functions are used to keep backwards compatibility on rolling upgrades
# Policy:
# All data is kept intact until the first write operation. (This allows a minimal
# grace period during which rollbacks are fully safe. For more info see the spec.)
# All data involves:
# - databag contents
# - secrets content
# - secret labels (!!!)
# Legacy functions must return None, and leave an equally consistent state whether
# they are executed or skipped (as a high enough versioned execution environment may
# not require so)
# Compatibility
@legacy_apply_from_version(34)
def _legacy_compat_find_secret_by_old_label(self) -> None:
"""Compatibility function, allowing to find a secret by a legacy label.
This functionality is typically needed when secret labels changed over an upgrade.
Until the first write operation, we need to maintain data as it was, including keeping
the old secret label. In order to keep track of the old label currently used to access
the secret, and additional 'current_label' field is being defined.
"""
for label in self.legacy_labels:
try:
self._secret_meta = self._model.get_secret(label=label)
except SecretNotFoundError:
pass
else:
if label != self.label:
self.current_label = label
return
# Migrations
@legacy_apply_from_version(34)
def _legacy_migration_to_new_label_if_needed(self) -> None:
"""Helper function to re-create the secret with a different label.
Juju does not provide a way to change secret labels.
Thus whenever moving from secrets version that involves secret label changes,
we "re-create" the existing secret, and attach the new label to the new
secret, to be used from then on.
Note: we replace the old secret with a new one "in place", as we can't
easily switch the containing SecretCache structure to point to a new secret.
Instead we are changing the 'self' (CachedSecret) object to point to the
new instance.
"""
if not self.current_label or not (self.meta and self._secret_meta):
return
# Create a new secret with the new label
content = self._secret_meta.get_content()
self._secret_uri = None
# It will be nice to have the possibility to check if we are the owners of the secret...
try:
self._secret_meta = self.add_secret(content, label=self.label)
except ModelError as err:
if MODEL_ERRORS["not_leader"] not in str(err):
raise
self.current_label = None
##########################################################################
# Public functions
##########################################################################
def add_secret(
self,
content: Dict[str, str],
relation: Optional[Relation] = None,
label: Optional[str] = None,
) -> Secret:
"""Create a new secret."""
if self._secret_uri:
raise SecretAlreadyExistsError(
"Secret is already defined with uri %s", self._secret_uri
)
label = self.label if not label else label
secret = self.component.add_secret(content, label=label)
if relation and relation.app != self._model.app:
# If it's not a peer relation, grant is to be applied
secret.grant(relation)
self._secret_uri = secret.id
self._secret_meta = secret
return self._secret_meta
def get_content(self) -> Dict[str, str]:
"""Getting cached secret content."""
if not self._secret_content:
if self.meta:
try:
self._secret_content = self.meta.get_content(refresh=True)
except (ValueError, ModelError) as err:
# https://bugs.launchpad.net/juju/+bug/2042596
# Only triggered when 'refresh' is set
if isinstance(err, ModelError) and not any(
msg in str(err) for msg in self.KNOWN_MODEL_ERRORS
):
raise
# Due to: ValueError: Secret owner cannot use refresh=True
self._secret_content = self.meta.get_content()
return self._secret_content
def set_content(self, content: Dict[str, str]) -> None:
"""Setting cached secret content."""
if not self.meta:
return
# DPE-4182: do not create new revision if the content stay the same
if content == self.get_content():
return
if content:
self._legacy_migration_to_new_label_if_needed()
self.meta.set_content(content)
self._secret_content = content
else:
self.meta.remove_all_revisions()
def get_info(self) -> Optional[SecretInfo]:
"""Wrapper function to apply the corresponding call on the Secret object within CachedSecret if any."""
if self.meta:
return self.meta.get_info()
def remove(self) -> None:
"""Remove secret."""
if not self.meta:
raise SecretsUnavailableError("Non-existent secret was attempted to be removed.")
try:
self.meta.remove_all_revisions()
except SecretNotFoundError:
pass
self._secret_content = {}
self._secret_meta = None
self._secret_uri = None
class SecretCache:
"""A data structure storing CachedSecret objects."""
def __init__(self, model: Model, component: Union[Application, Unit]):
self._model = model
self.component = component
self._secrets: Dict[str, CachedSecret] = {}
def get(
self, label: str, uri: Optional[str] = None, legacy_labels: List[str] = []
) -> Optional[CachedSecret]:
"""Getting a secret from Juju Secret store or cache."""
if not self._secrets.get(label):
secret = CachedSecret(
self._model, self.component, label, uri, legacy_labels=legacy_labels
)
if secret.meta:
self._secrets[label] = secret
return self._secrets.get(label)
def add(self, label: str, content: Dict[str, str], relation: Relation) -> CachedSecret:
"""Adding a secret to Juju Secret."""
if self._secrets.get(label):
raise SecretAlreadyExistsError(f"Secret {label} already exists")
secret = CachedSecret(self._model, self.component, label)
secret.add_secret(content, relation)
self._secrets[label] = secret
return self._secrets[label]
def remove(self, label: str) -> None:
"""Remove a secret from the cache."""
if secret := self.get(label):
try:
secret.remove()
self._secrets.pop(label)
except (SecretsUnavailableError, KeyError):
pass
else:
return
logging.debug("Non-existing Juju Secret was attempted to be removed %s", label)
################################################################################
# Relation Data base/abstract ancestors (i.e. parent classes)
################################################################################
# Base Data
class DataDict(UserDict):
"""Python Standard Library 'dict' - like representation of Relation Data."""
def __init__(self, relation_data: "Data", relation_id: int):
self.relation_data = relation_data
self.relation_id = relation_id
@property
def data(self) -> Dict[str, str]:
"""Return the full content of the Abstract Relation Data dictionary."""
result = self.relation_data.fetch_my_relation_data([self.relation_id])
try:
result_remote = self.relation_data.fetch_relation_data([self.relation_id])
except NotImplementedError:
result_remote = {self.relation_id: {}}
if result:
result_remote[self.relation_id].update(result[self.relation_id])
return result_remote.get(self.relation_id, {})
def __setitem__(self, key: str, item: str) -> None:
"""Set an item of the Abstract Relation Data dictionary."""
self.relation_data.update_relation_data(self.relation_id, {key: item})
def __getitem__(self, key: str) -> str:
"""Get an item of the Abstract Relation Data dictionary."""
result = None
# Avoiding "leader_only" error when cross-charm non-leader unit, not to report useless error
if (
not hasattr(self.relation_data.fetch_my_relation_field, "leader_only")
or self.relation_data.component != self.relation_data.local_app
or self.relation_data.local_unit.is_leader()
):
result = self.relation_data.fetch_my_relation_field(self.relation_id, key)
if not result:
try:
result = self.relation_data.fetch_relation_field(self.relation_id, key)
except NotImplementedError:
pass
if not result:
raise KeyError
return result
def __eq__(self, d: dict) -> bool:
"""Equality."""
return self.data == d
def __repr__(self) -> str:
"""String representation Abstract Relation Data dictionary."""
return repr(self.data)
def __len__(self) -> int:
"""Length of the Abstract Relation Data dictionary."""
return len(self.data)
def __delitem__(self, key: str) -> None:
"""Delete an item of the Abstract Relation Data dictionary."""
self.relation_data.delete_relation_data(self.relation_id, [key])
def has_key(self, key: str) -> bool:
"""Does the key exist in the Abstract Relation Data dictionary?"""
return key in self.data
def update(self, items: Dict[str, str]):
"""Update the Abstract Relation Data dictionary."""
self.relation_data.update_relation_data(self.relation_id, items)
def keys(self) -> KeysView[str]:
"""Keys of the Abstract Relation Data dictionary."""
return self.data.keys()
def values(self) -> ValuesView[str]:
"""Values of the Abstract Relation Data dictionary."""
return self.data.values()
def items(self) -> ItemsView[str, str]:
"""Items of the Abstract Relation Data dictionary."""
return self.data.items()
def pop(self, item: str) -> str:
"""Pop an item of the Abstract Relation Data dictionary."""
result = self.relation_data.fetch_my_relation_field(self.relation_id, item)
if not result:
raise KeyError(f"Item {item} doesn't exist.")
self.relation_data.delete_relation_data(self.relation_id, [item])
return result
def __contains__(self, item: str) -> bool:
"""Does the Abstract Relation Data dictionary contain item?"""
return item in self.data.values()
def __iter__(self):
"""Iterate through the Abstract Relation Data dictionary."""
return iter(self.data)
def get(self, key: str, default: Optional[str] = None) -> Optional[str]:
"""Safely get an item of the Abstract Relation Data dictionary."""
try:
if result := self[key]:
return result
except KeyError:
return default
class Data(ABC):
"""Base relation data mainpulation (abstract) class."""
SCOPE = Scope.APP
# Local map to associate mappings with secrets potentially as a group
SECRET_LABEL_MAP = {
"username": SECRET_GROUPS.USER,
"password": SECRET_GROUPS.USER,
"uris": SECRET_GROUPS.USER,
"tls": SECRET_GROUPS.TLS,
"tls-ca": SECRET_GROUPS.TLS,
}
def __init__(
self,
model: Model,
relation_name: str,
) -> None:
self._model = model
self.local_app = self._model.app
self.local_unit = self._model.unit
self.relation_name = relation_name
self._jujuversion = None
self.component = self.local_app if self.SCOPE == Scope.APP else self.local_unit
self.secrets = SecretCache(self._model, self.component)
self.data_component = None
@property
def relations(self) -> List[Relation]:
"""The list of Relation instances associated with this relation_name."""
return [
relation
for relation in self._model.relations[self.relation_name]
if self._is_relation_active(relation)
]
@property
def secrets_enabled(self):
"""Is this Juju version allowing for Secrets usage?"""
if not self._jujuversion:
self._jujuversion = JujuVersion.from_environ()
return self._jujuversion.has_secrets
@property
def secret_label_map(self):
"""Exposing secret-label map via a property -- could be overridden in descendants!"""
return self.SECRET_LABEL_MAP