flux_local.manifest
Representation of the contents of a cluster.
A manifest may be built directly from the local context of a cluster, or may be serialized and stored and checked into the cluster for use in other applications e.g. such as writing management plan for resources.
1"""Representation of the contents of a cluster. 2 3A manifest may be built directly from the local context of a cluster, or may be 4serialized and stored and checked into the cluster for use in other applications 5e.g. such as writing management plan for resources. 6""" 7 8import base64 9from dataclasses import dataclass, field 10import logging 11from pathlib import Path 12from typing import Any, Optional, cast, ClassVar 13 14import aiofiles 15from mashumaro.codecs.yaml import yaml_decode, yaml_encode 16from mashumaro import DataClassDictMixin, field_options 17from mashumaro.config import BaseConfig 18 19from .exceptions import InputException 20 21__all__ = [ 22 "read_manifest", 23 "write_manifest", 24 "Manifest", 25 "Cluster", 26 "Kustomization", 27 "HelmRepository", 28 "HelmRelease", 29 "HelmChart", 30 "ConfigMap", 31 "Secret", 32] 33 34_LOGGER = logging.getLogger(__name__) 35 36 37# Match a prefix of apiVersion to ensure we have the right type of object. 38# We don't check specific versions for forward compatibility on upgrade. 39FLUXTOMIZE_DOMAIN = "kustomize.toolkit.fluxcd.io" 40KUSTOMIZE_DOMAIN = "kustomize.config.k8s.io" 41HELM_REPO_DOMAIN = "source.toolkit.fluxcd.io" 42HELM_RELEASE_DOMAIN = "helm.toolkit.fluxcd.io" 43GIT_REPOSITORY_DOMAIN = "source.toolkit.fluxcd.io" 44OCI_REPOSITORY_DOMAIN = "source.toolkit.fluxcd.io" 45CRD_KIND = "CustomResourceDefinition" 46SECRET_KIND = "Secret" 47CONFIG_MAP_KIND = "ConfigMap" 48DEFAULT_NAMESPACE = "flux-system" 49VALUE_PLACEHOLDER_TEMPLATE = "..PLACEHOLDER_{name}.." 50HELM_RELEASE = "HelmRelease" 51HELM_REPO_KIND = "HelmRepository" 52HELM_CHART = "HelmChart" 53GIT_REPOSITORY = "GitRepository" 54HELM_REPOSITORY = "HelmRepository" 55OCI_REPOSITORY = "OCIRepository" 56KUSTOMIZE_KIND = "Kustomization" 57 58 59REPO_TYPE_DEFAULT = "default" 60REPO_TYPE_OCI = "oci" 61 62# Strip any annotations from kustomize that contribute to diff noise when 63# objects are re-ordered in the output 64STRIP_ATTRIBUTES = [ 65 "config.kubernetes.io/index", 66 "internal.config.kubernetes.io/index", 67] 68 69 70def _check_version(doc: dict[str, Any], version: str) -> None: 71 """Assert that the resource has the specified version.""" 72 if not (api_version := doc.get("apiVersion")): 73 raise InputException(f"Invalid object missing apiVersion: {doc}") 74 if not api_version.startswith(version): 75 raise InputException(f"Invalid object expected '{version}': {doc}") 76 77 78@dataclass 79class BaseManifest(DataClassDictMixin): 80 """Base class for all manifest objects.""" 81 82 def compact_dict(self) -> dict[str, Any]: 83 """Return a compact dictionary representation of the object. 84 85 This is similar to `dict()` but with a specific implementation for serializing 86 with variable fields removed. 87 """ 88 return self.to_dict() 89 90 @classmethod 91 def parse_yaml(cls, content: str) -> "BaseManifest": 92 """Parse a serialized manifest.""" 93 return yaml_decode(content, cls) 94 95 def yaml(self, exclude: dict[str, Any] | None = None) -> str: 96 """Return a YAML string representation of compact_dict.""" 97 return yaml_encode(self, self.__class__) # type: ignore[return-value] 98 99 class Config(BaseConfig): 100 omit_none = True 101 102 103@dataclass(frozen=True, order=True) 104class NamedResource: 105 """Identifier for a kubernetes resource.""" 106 107 kind: str 108 namespace: str | None 109 name: str 110 111 @property 112 def namespaced_name(self) -> str: 113 if self.namespace: 114 return f"{self.namespace}/{self.name}" 115 return self.name 116 117 def __str__(self) -> str: 118 """Return the kind and namespaced name concatenated as an id.""" 119 return f"{self.kind}/{self.namespaced_name}" 120 121 122@dataclass 123class RawObject(BaseManifest): 124 """Raw kubernetes object.""" 125 126 kind: str 127 """The kind of the object.""" 128 129 api_version: str 130 """The apiVersion of the object.""" 131 132 name: str 133 """The name of the object.""" 134 135 namespace: str | None 136 """The namespace of the object.""" 137 138 spec: dict[str, Any] | None = None 139 """The spec of the object.""" 140 141 @classmethod 142 def parse_doc(cls, doc: dict[str, Any]) -> "RawObject": 143 """Parse a RawObject from a raw kubernetes object.""" 144 if not (api_version := doc.get("apiVersion")): 145 raise InputException("Invalid object missing apiVersion: {doc}") 146 if not (metadata := doc.get("metadata")): 147 raise InputException(f"Invalid object missing metadata: {doc}") 148 if not (name := metadata.get("name")): 149 raise InputException(f"Invalid object missing metadata.name: {doc}") 150 return cls( 151 kind=doc["kind"], 152 api_version=api_version, 153 name=name, 154 namespace=metadata.get("namespace", DEFAULT_NAMESPACE), 155 spec=doc.get("spec", {}), 156 ) 157 158 159@dataclass 160class HelmChart(BaseManifest): 161 """A representation of an instantiation of a chart for a HelmRelease.""" 162 163 kind: ClassVar[str] = HELM_CHART 164 """The kind of the object.""" 165 166 name: str 167 """The name of the chart within the HelmRepository.""" 168 169 version: Optional[str] = field(metadata={"serialize": "omit"}) 170 """The version of the chart.""" 171 172 repo_name: str 173 """The short name of the repository.""" 174 175 repo_namespace: str 176 """The namespace of the repository.""" 177 178 repo_kind: str = HELM_REPO_KIND 179 """The kind of the soruceRef of the repository (e.g. HelmRepository, GitRepository).""" 180 181 @classmethod 182 def parse_doc(cls, doc: dict[str, Any], default_namespace: str) -> "HelmChart": 183 """Parse a HelmChart from a HelmRelease resource object.""" 184 _check_version(doc, HELM_RELEASE_DOMAIN) 185 if not (spec := doc.get("spec")): 186 raise InputException(f"Invalid {cls} missing spec: {doc}") 187 chart_ref = spec.get("chartRef") 188 chart = spec.get("chart") 189 if not chart_ref and not chart: 190 raise InputException( 191 f"Invalid {cls} missing spec.chart or spec.chartRef: {doc}" 192 ) 193 if chart_ref: 194 if not (kind := chart_ref.get("kind")): 195 raise InputException(f"Invalid {cls} missing spec.chartRef.kind: {doc}") 196 if not (name := chart_ref.get("name")): 197 raise InputException(f"Invalid {cls} missing spec.chartRef.name: {doc}") 198 199 return cls( 200 name=name, 201 version=None, 202 repo_name=name, 203 repo_namespace=chart_ref.get("namespace", default_namespace), 204 repo_kind=kind, 205 ) 206 if not (chart_spec := chart.get("spec")): 207 raise InputException(f"Invalid {cls} missing spec.chart.spec: {doc}") 208 if not (chart := chart_spec.get("chart")): 209 raise InputException(f"Invalid {cls} missing spec.chart.spec.chart: {doc}") 210 version = chart_spec.get("version") 211 if not (source_ref := chart_spec.get("sourceRef")): 212 raise InputException( 213 f"Invalid {cls} missing spec.chart.spec.sourceRef: {doc}" 214 ) 215 if "name" not in source_ref: 216 raise InputException(f"Invalid {cls} missing sourceRef fields: {doc}") 217 return cls( 218 name=chart, 219 version=version, 220 repo_name=source_ref["name"], 221 repo_namespace=source_ref.get("namespace", default_namespace), 222 repo_kind=source_ref.get("kind", HELM_REPO_KIND), 223 ) 224 225 @property 226 def repo_full_name(self) -> str: 227 """Identifier for the HelmRepository.""" 228 return f"{self.repo_namespace}-{self.repo_name}" 229 230 @property 231 def chart_name(self) -> str: 232 """Identifier for the HelmChart.""" 233 return f"{self.repo_full_name}/{self.name}" 234 235 236@dataclass 237class ValuesReference(BaseManifest): 238 """A reference to a resource containing values for a HelmRelease.""" 239 240 kind: str 241 """The kind of resource.""" 242 243 name: str 244 """The name of the resource.""" 245 246 values_key: str = field( 247 metadata=field_options(alias="valuesKey"), default="values.yaml" 248 ) 249 """The key in the resource that contains the values.""" 250 251 target_path: Optional[str] = field( 252 metadata=field_options(alias="targetPath"), default=None 253 ) 254 """The path in the HelmRelease values to store the values.""" 255 256 optional: bool = False 257 """Whether the reference is optional.""" 258 259 260@dataclass 261class LocalObjectReference(BaseManifest): 262 """A reference to a local object.""" 263 264 name: str 265 """The name of the object.""" 266 267 268@dataclass 269class HelmRelease(BaseManifest): 270 """A representation of a Flux HelmRelease.""" 271 272 kind: ClassVar[str] = HELM_RELEASE 273 """The kind of the object.""" 274 275 name: str 276 """The name of the HelmRelease.""" 277 278 namespace: str 279 """The namespace that owns the HelmRelease.""" 280 281 chart: HelmChart 282 """A mapping to a specific helm chart for this HelmRelease.""" 283 284 target_namespace: str | None = field(metadata={"serialize": "omit"}, default=None) 285 """The namespace to target when performing the operation.""" 286 287 values: Optional[dict[str, Any]] = field( 288 metadata={"serialize": "omit"}, default=None 289 ) 290 """The values to install in the chart.""" 291 292 values_from: Optional[list[ValuesReference]] = field( 293 metadata={"serialize": "omit"}, default=None 294 ) 295 """A list of values to reference from an ConfigMap or Secret.""" 296 297 images: list[str] | None = field(default=None) 298 """The list of images referenced in the HelmRelease.""" 299 300 labels: dict[str, str] | None = field(metadata={"serialize": "omit"}, default=None) 301 """A list of labels on the HelmRelease.""" 302 303 disable_schema_validation: bool = field( 304 metadata={"serialize": "omit"}, default=False 305 ) 306 """Prevents Helm from validating the values against the JSON Schema.""" 307 308 disable_openapi_validation: bool = field( 309 metadata={"serialize": "omit"}, default=False 310 ) 311 """Prevents Helm from validating the values against the Kubernetes OpenAPI Schema.""" 312 313 @classmethod 314 def parse_doc(cls, doc: dict[str, Any]) -> "HelmRelease": 315 """Parse a HelmRelease from a kubernetes resource object.""" 316 _check_version(doc, HELM_RELEASE_DOMAIN) 317 if not (metadata := doc.get("metadata")): 318 raise InputException(f"Invalid {cls} missing metadata: {doc}") 319 if not (name := metadata.get("name")): 320 raise InputException(f"Invalid {cls} missing metadata.name: {doc}") 321 if not (namespace := metadata.get("namespace")): 322 raise InputException(f"Invalid {cls} missing metadata.namespace: {doc}") 323 chart = HelmChart.parse_doc(doc, namespace) 324 spec = doc["spec"] 325 values_from: list[ValuesReference] | None = None 326 if values_from_dict := spec.get("valuesFrom"): 327 values_from = [ 328 ValuesReference.from_dict(subdoc) for subdoc in values_from_dict 329 ] 330 disable_schema_validation = any( 331 bag.get("disableSchemaValidation") 332 for key in ("install", "upgrade") 333 if (bag := spec.get(key)) is not None 334 ) 335 disable_openapi_validation = any( 336 bag.get("disableOpenAPIValidation") 337 for key in ("install", "upgrade") 338 if (bag := spec.get(key)) is not None 339 ) 340 return HelmRelease( 341 name=name, 342 namespace=namespace, 343 target_namespace=spec.get("targetNamespace"), 344 chart=chart, 345 values=spec.get("values"), 346 values_from=values_from, 347 labels=metadata.get("labels"), 348 disable_schema_validation=disable_schema_validation, 349 disable_openapi_validation=disable_openapi_validation, 350 ) 351 352 @property 353 def release_name(self) -> str: 354 """Identifier for the HelmRelease.""" 355 return f"{self.namespace}-{self.name}" 356 357 @property 358 def release_namespace(self) -> str: 359 """Actual namespace where the HelmRelease will be installed to.""" 360 if self.target_namespace: 361 return self.target_namespace 362 return self.namespace 363 364 @property 365 def repo_name(self) -> str: 366 """Identifier for the HelmRepository identified in the HelmChart.""" 367 return f"{self.chart.repo_namespace}-{self.chart.repo_name}" 368 369 @property 370 def namespaced_name(self) -> str: 371 """Return the namespace and name concatenated as an id.""" 372 return f"{self.namespace}/{self.name}" 373 374 @property 375 def resource_dependencies(self) -> list[NamedResource]: 376 """Return the list of input dependencies for the HelmRelease.""" 377 deps = [ 378 NamedResource( 379 kind=HELM_RELEASE, 380 name=self.name, 381 namespace=self.namespace, 382 ) 383 ] 384 if self.chart: 385 deps.append( 386 NamedResource( 387 kind=self.chart.repo_kind, 388 name=self.chart.repo_name, 389 namespace=self.chart.repo_namespace, 390 ) 391 ) 392 names_seen = set() 393 for ref in self.values_from or (): 394 if ref.name in names_seen: 395 continue 396 names_seen.add(ref.name) 397 deps.append( 398 NamedResource(kind=ref.kind, name=ref.name, namespace=self.namespace) 399 ) 400 return deps 401 402 403@dataclass 404class HelmRepository(BaseManifest): 405 """A representation of a flux HelmRepository.""" 406 407 kind: ClassVar[str] = HELM_REPO_KIND 408 """The kind of the object.""" 409 410 name: str 411 """The name of the HelmRepository.""" 412 413 namespace: str 414 """The namespace of owning the HelmRepository.""" 415 416 url: str 417 """The URL to the repository of helm charts.""" 418 419 repo_type: str | None = None 420 """The type of the HelmRepository.""" 421 422 @classmethod 423 def parse_doc(cls, doc: dict[str, Any]) -> "HelmRepository": 424 """Parse a HelmRepository from a kubernetes resource.""" 425 _check_version(doc, HELM_REPO_DOMAIN) 426 if not (metadata := doc.get("metadata")): 427 raise InputException(f"Invalid {cls} missing metadata: {doc}") 428 if not (name := metadata.get("name")): 429 raise InputException(f"Invalid {cls} missing metadata.name: {doc}") 430 if not (namespace := metadata.get("namespace")): 431 raise InputException(f"Invalid {cls} missing metadata.namespace: {doc}") 432 if not (spec := doc.get("spec")): 433 raise InputException(f"Invalid {cls} missing spec: {doc}") 434 if not (url := spec.get("url")): 435 raise InputException(f"Invalid {cls} missing spec.url: {doc}") 436 return cls( 437 name=name, 438 namespace=namespace, 439 url=url, 440 repo_type=spec.get("type", REPO_TYPE_DEFAULT), 441 ) 442 443 @property 444 def repo_name(self) -> str: 445 """Identifier for the HelmRepository.""" 446 return f"{self.namespace}-{self.name}" 447 448 def helm_chart_name(self, chart: HelmChart) -> str: 449 """Get the name or URL for a specific chart for helm template.""" 450 if self.repo_type == REPO_TYPE_OCI: 451 # For OCI repositories, we need to append the chart short name to the URL 452 return f"{self.url}/{chart.name}" 453 # For regular helm repositories, we just return the full chart name 454 return chart.chart_name 455 456 457@dataclass 458class GitRepositoryRef: 459 """GitRepositoryRef defines the Git ref used for pull and checkout operations.""" 460 461 branch: str | None = field(default=None) 462 """The Git branch to checkout, defaults to master.""" 463 464 tag: str | None = field(default=None) 465 """The Git tag to checkout.""" 466 467 semver: str | None = field(default=None) 468 """The Git tag semver expression.""" 469 470 commit: str | None = field(default=None) 471 """The Git commit SHA to checkout.""" 472 473 @classmethod 474 def parse_doc(cls, doc: dict[str, Any]) -> "GitRepositoryRef": 475 """Parse a GitRepositoryRef from a kubernetes resource.""" 476 return cls( 477 branch=doc.get("branch"), 478 tag=doc.get("tag"), 479 semver=doc.get("semver"), 480 commit=doc.get("commit"), 481 ) 482 483 @property 484 def ref_str(self) -> str | None: 485 """Get the reference string for the GitRepository.""" 486 if self.commit: 487 return f"commit:{self.commit}" 488 if self.tag: 489 return f"tag:{self.tag}" 490 if self.branch: 491 return f"branch:{self.branch}" 492 if self.semver: 493 return f"semver:{self.semver}" 494 return None 495 496 class Config(BaseConfig): 497 omit_none = True 498 499 500@dataclass 501class GitRepository(BaseManifest): 502 """GitRepository represents a Git repository.""" 503 504 kind: ClassVar[str] = GIT_REPOSITORY 505 """The kind of the object.""" 506 507 name: str 508 """The name of the GitRepository.""" 509 510 namespace: str 511 """The namespace of owning the GitRepository.""" 512 513 url: str 514 """The URL to the repository.""" 515 516 ref: GitRepositoryRef | None = None 517 """The Git reference to use for pull and checkout operations.""" 518 519 @classmethod 520 def parse_doc(cls, doc: dict[str, Any]) -> "GitRepository": 521 """Parse a GitRepxository from a kubernetes resource.""" 522 _check_version(doc, GIT_REPOSITORY_DOMAIN) 523 if not (metadata := doc.get("metadata")): 524 raise InputException(f"Invalid {cls} missing metadata: {doc}") 525 if not (name := metadata.get("name")): 526 raise InputException(f"Invalid {cls} missing metadata.name: {doc}") 527 if not (namespace := metadata.get("namespace")): 528 raise InputException(f"Invalid {cls} missing metadata.namespace: {doc}") 529 if not (spec := doc.get("spec")): 530 raise InputException(f"Invalid {cls} missing spec: {doc}") 531 if not (url := spec.get("url")): 532 raise InputException(f"Invalid {cls} missing spec.url: {doc}") 533 534 ref = None 535 if ref_dict := spec.get("ref"): 536 ref = GitRepositoryRef.parse_doc(ref_dict) 537 538 return cls( 539 name=name, 540 namespace=namespace, 541 url=url, 542 ref=ref, 543 ) 544 545 @property 546 def repo_name(self) -> str: 547 """Identifier for the GitRepository.""" 548 return f"{self.namespace}-{self.name}" 549 550 551@dataclass 552class OCIRepositoryRef: 553 """OCIRepositoryRef defines the image reference for the OCIRepository's URL.""" 554 555 digest: str | None = None 556 """The image digest to pull, takes precedence over SemVer.""" 557 558 tag: str | None = None 559 """The image tag to pull, defaults to latest.""" 560 561 semver: str | None = None 562 """The range of tags to pull selecting the latest within the range.""" 563 564 semver_filter: str | None = None 565 """A regex pattern to filter the tags within the SemVer range.""" 566 567 @classmethod 568 def parse_doc(cls, doc: dict[str, Any]) -> "OCIRepositoryRef": 569 """Parse a dictionary into an OCIRepositoryRef.""" 570 return cls( 571 digest=doc.get("digest"), 572 tag=doc.get("tag"), 573 semver=doc.get("semver"), 574 semver_filter=doc.get("semverFilter"), 575 ) 576 577 class Config(BaseConfig): 578 omit_none = True 579 580 581@dataclass 582class OCIRepository(BaseManifest): 583 """A representation of a flux OCIRepository.""" 584 585 kind: ClassVar[str] = OCI_REPOSITORY 586 """The kind of the object.""" 587 588 name: str 589 """The name of the OCIRepository.""" 590 591 namespace: str 592 """The namespace of owning the OCIRepository.""" 593 594 url: str 595 """The URL to the repository.""" 596 597 ref: OCIRepositoryRef | None = None 598 """The OCI reference (tag or digest) to use.""" 599 600 secret_ref: LocalObjectReference | None = field( 601 metadata=field_options(alias="secretRef"), default=None 602 ) 603 """The local secret reference.""" 604 605 @classmethod 606 def parse_doc(cls, doc: dict[str, Any]) -> "OCIRepository": 607 """Parse a HelmRepository from a kubernetes resource.""" 608 _check_version(doc, OCI_REPOSITORY_DOMAIN) 609 if not (metadata := doc.get("metadata")): 610 raise InputException(f"Invalid {cls} missing metadata: {doc}") 611 if not (name := metadata.get("name")): 612 raise InputException(f"Invalid {cls} missing metadata.name: {doc}") 613 if not (namespace := metadata.get("namespace")): 614 raise InputException(f"Invalid {cls} missing metadata.namespace: {doc}") 615 if not (spec := doc.get("spec")): 616 raise InputException(f"Invalid {cls} missing spec: {doc}") 617 if not (url := spec.get("url")): 618 raise InputException(f"Invalid {cls} missing spec.url: {doc}") 619 repo_ref: OCIRepositoryRef | None = None 620 if ref := spec.get("ref"): 621 repo_ref = OCIRepositoryRef.parse_doc(ref) 622 secret_ref: LocalObjectReference | None = None 623 if secret_ref_dict := spec.get("secretRef"): 624 secret_ref = LocalObjectReference.from_dict(secret_ref_dict) 625 return cls( 626 name=name, 627 namespace=namespace, 628 url=url, 629 ref=repo_ref, 630 secret_ref=secret_ref, 631 ) 632 633 def version(self) -> str | None: 634 """Get the version of the OCI repository.""" 635 if self.ref is not None: 636 if self.ref.semver_filter: 637 raise ValueError( 638 f"OCIRepository has unsupported field semvar_filter: {self.ref}" 639 ) 640 if self.ref.digest: 641 return self.ref.digest 642 if self.ref.tag: 643 return self.ref.tag 644 if self.ref.semver: 645 return self.ref.semver 646 return None 647 648 def versioned_url(self) -> str: 649 """Get the URL with the version.""" 650 if self.ref is None: 651 return self.url 652 if self.ref.digest: 653 return f"{self.url}@{self.ref.digest}" 654 if self.ref.tag: 655 return f"{self.url}:{self.ref.tag}" 656 if self.ref.semver: 657 return f"{self.url}:{self.ref.semver}" 658 return self.url 659 660 @property 661 def repo_name(self) -> str: 662 """Identifier for the OCIRepository.""" 663 return f"{self.namespace}-{self.name}" 664 665 666@dataclass 667class ConfigMap(BaseManifest): 668 """A ConfigMap is an API object used to store data in key-value pairs.""" 669 670 kind: ClassVar[str] = CONFIG_MAP_KIND 671 """The kind of the ConfigMap.""" 672 673 name: str 674 """The name of the ConfigMap.""" 675 676 namespace: str | None = None 677 """The namespace of the kustomization.""" 678 679 data: dict[str, Any] | None = field(metadata={"serialize": "omit"}, default=None) 680 """The data in the ConfigMap.""" 681 682 binary_data: dict[str, Any] | None = field( 683 metadata={"serialize": "omit"}, default=None 684 ) 685 """The binary data in the ConfigMap.""" 686 687 @classmethod 688 def parse_doc(cls, doc: dict[str, Any]) -> "ConfigMap": 689 """Parse a config map object from a kubernetes resource.""" 690 _check_version(doc, "v1") 691 if not (metadata := doc.get("metadata")): 692 raise InputException(f"Invalid {cls} missing metadata: {doc}") 693 if not (name := metadata.get("name")): 694 raise InputException(f"Invalid {cls} missing metadata.name: {doc}") 695 namespace = metadata.get("namespace") 696 return ConfigMap( 697 name=name, 698 namespace=namespace, 699 data=doc.get("data"), 700 binary_data=doc.get("binaryData"), 701 ) 702 703 704@dataclass 705class Secret(BaseManifest): 706 """A Secret contains a small amount of sensitive data.""" 707 708 kind: ClassVar[str] = SECRET_KIND 709 """The kind of the Secret.""" 710 711 name: str 712 """The name of the Secret.""" 713 714 namespace: str | None = None 715 """The namespace of the Secret.""" 716 717 data: dict[str, Any] | None = field(metadata={"serialize": "omit"}, default=None) 718 """The data in the Secret.""" 719 720 string_data: dict[str, Any] | None = field( 721 metadata={"serialize": "omit"}, default=None 722 ) 723 """The string data in the Secret.""" 724 725 @classmethod 726 def parse_doc(cls, doc: dict[str, Any], *, wipe_secrets: bool = True) -> "Secret": 727 """Parse a secret object from a kubernetes resource.""" 728 _check_version(doc, "v1") 729 if not (metadata := doc.get("metadata")): 730 raise InputException(f"Invalid {cls} missing metadata: {doc}") 731 if not (name := metadata.get("name")): 732 raise InputException(f"Invalid {cls} missing metadata.name: {doc}") 733 namespace = metadata.get("namespace") 734 # While secrets are not typically stored in the cluster, we replace with 735 # placeholder values anyway. 736 data = doc.get("data") 737 if data and wipe_secrets: 738 for key, value in data.items(): 739 data[key] = base64.b64encode( 740 VALUE_PLACEHOLDER_TEMPLATE.format(name=key).encode() 741 ) 742 string_data = doc.get("stringData") 743 if string_data and wipe_secrets: 744 for key, value in string_data.items(): 745 string_data[key] = VALUE_PLACEHOLDER_TEMPLATE.format(name=key) 746 return Secret( 747 name=name, namespace=namespace, data=data, string_data=string_data 748 ) 749 750 751@dataclass 752class SubstituteReference(BaseManifest): 753 """SubstituteReference contains a reference to a resource containing the variables name and value.""" 754 755 kind: str 756 """The kind of resource.""" 757 758 name: str 759 """The name of the resource.""" 760 761 optional: bool = False 762 """Whether the reference is optional.""" 763 764 765@dataclass 766class Kustomization(BaseManifest): 767 """A Kustomization is a set of declared cluster artifacts. 768 769 This represents a flux Kustomization that points to a path that 770 contains typical `kustomize` Kustomizations on local disk that 771 may be flat or contain overlays. 772 """ 773 774 kind: ClassVar[str] = KUSTOMIZE_KIND 775 """The kind of the object.""" 776 777 name: str 778 """The name of the kustomization.""" 779 780 namespace: str | None 781 """The namespace of the kustomization.""" 782 783 path: str 784 """The local repo path to the kustomization contents.""" 785 786 helm_repos: list[HelmRepository] = field(default_factory=list) 787 """The set of HelmRepositories represented in this kustomization.""" 788 789 oci_repos: list[OCIRepository] = field(default_factory=list) 790 """The set of OCIRepositories represented in this kustomization.""" 791 792 helm_releases: list[HelmRelease] = field(default_factory=list) 793 """The set of HelmRelease represented in this kustomization.""" 794 795 config_maps: list[ConfigMap] = field(default_factory=list) 796 """The list of config maps referenced in the kustomization.""" 797 798 secrets: list[Secret] = field(default_factory=list) 799 """The list of secrets referenced in the kustomization.""" 800 801 source_path: str | None = field(metadata={"serialize": "omit"}, default=None) 802 """Optional source path for this Kustomization, relative to the build path.""" 803 804 source_kind: str | None = field(metadata={"serialize": "omit"}, default=None) 805 """The sourceRef kind that provides this Kustomization e.g. GitRepository etc.""" 806 807 source_name: str | None = field(metadata={"serialize": "omit"}, default=None) 808 """The name of the sourceRef that provides this Kustomization.""" 809 810 source_namespace: str | None = field(metadata={"serialize": "omit"}, default=None) 811 """The namespace of the sourceRef that provides this Kustomization.""" 812 813 target_namespace: str | None = field(metadata={"serialize": "omit"}, default=None) 814 """The namespace to target when performing the operation.""" 815 816 contents: dict[str, Any] | None = field( 817 metadata={"serialize": "omit"}, default=None 818 ) 819 """Contents of the raw Kustomization document.""" 820 821 images: list[str] | None = field(default=None) 822 """The list of images referenced in the kustomization.""" 823 824 postbuild_substitute: Optional[dict[str, Any]] = field( 825 metadata={"serialize": "omit"}, default=None 826 ) 827 """A map of key/value pairs to substitute into the final YAML manifest, after building.""" 828 829 postbuild_substitute_from: Optional[list[SubstituteReference]] = field( 830 metadata={"serialize": "omit"}, default=None 831 ) 832 """A list of substitutions to reference from an ConfigMap or Secret.""" 833 834 depends_on: list[str] | None = field(metadata={"serialize": "omit"}, default=None) 835 """A list of namespaced names that this Kustomization depends on.""" 836 837 labels: dict[str, str] | None = field(metadata={"serialize": "omit"}, default=None) 838 """A list of labels on the Kustomization.""" 839 840 @classmethod 841 def parse_doc(cls, doc: dict[str, Any]) -> "Kustomization": 842 """Parse a partial Kustomization from a kubernetes resource.""" 843 _check_version(doc, FLUXTOMIZE_DOMAIN) 844 if not (metadata := doc.get("metadata")): 845 raise InputException(f"Invalid {cls} missing metadata: {doc}") 846 if not (name := metadata.get("name")): 847 raise InputException(f"Invalid {cls} missing metadata.name: {doc}") 848 if not (namespace := metadata.get("namespace")): 849 raise InputException(f"Invalid {cls} missing metadata.namespace: {doc}") 850 if not (spec := doc.get("spec")): 851 raise InputException(f"Invalid {cls} missing spec: {doc}") 852 path = spec.get("path", "") 853 source_path = metadata.get("annotations", {}).get("config.kubernetes.io/path") 854 source_ref = spec.get("sourceRef", {}) 855 postbuild = spec.get("postBuild", {}) 856 substitute_from: list[SubstituteReference] | None = None 857 if substitute_from_dict := postbuild.get("substituteFrom"): 858 substitute_from = [ 859 SubstituteReference(**subdoc) for subdoc in substitute_from_dict 860 ] 861 depends_on = [] 862 for dependency in spec.get("dependsOn", ()): 863 if not (dep_name := dependency.get("name")): 864 raise InputException(f"Invalid {cls} missing dependsOn.name: {doc}") 865 dep_namespace = dependency.get("namespace", namespace) 866 depends_on.append(f"{dep_namespace}/{dep_name}") 867 return Kustomization( 868 name=name, 869 namespace=namespace, 870 path=path, 871 source_path=source_path, 872 source_kind=source_ref.get("kind"), 873 source_name=source_ref.get("name"), 874 source_namespace=source_ref.get("namespace", namespace), 875 target_namespace=spec.get("targetNamespace"), 876 contents=doc, 877 postbuild_substitute=postbuild.get("substitute"), 878 postbuild_substitute_from=substitute_from, 879 depends_on=depends_on, 880 labels=metadata.get("labels"), 881 ) 882 883 @property 884 def id_name(self) -> str: 885 """Identifier for the Kustomization in tests""" 886 return f"{self.path}" 887 888 @property 889 def namespaced_name(self) -> str: 890 """Return the namespace and name concatenated as an id.""" 891 return f"{self.namespace}/{self.name}" 892 893 def validate_depends_on(self, all_ks: set[str]) -> None: 894 """Validate depends_on values are all correct given the list of Kustomizations.""" 895 depends_on = set(self.depends_on or {}) 896 if missing := (depends_on - all_ks): 897 _LOGGER.warning( 898 "Kustomization %s has dependsOn with invalid names: %s", 899 self.namespaced_name, 900 missing, 901 ) 902 self.depends_on = list(depends_on - missing) 903 904 def update_postbuild_substitutions(self, substitutions: dict[str, Any]) -> None: 905 """Update the postBuild.substitutions in the extracted values and raw doc contents.""" 906 if self.postbuild_substitute is None: 907 self.postbuild_substitute = {} 908 self.postbuild_substitute.update(substitutions) 909 if self.contents: 910 post_build = self.contents["spec"]["postBuild"] 911 if (substitute := post_build.get("substitute")) is None: 912 substitute = {} 913 post_build["substitute"] = substitute 914 substitute.update(substitutions) 915 916 917@dataclass 918class Cluster(BaseManifest): 919 """A set of nodes that run containerized applications. 920 921 Many flux git repos will only have a single flux cluster, though 922 a repo may also contain multiple (e.g. dev an prod). 923 """ 924 925 path: str 926 """The local git repo path to the Kustomization objects for the cluster.""" 927 928 kustomizations: list[Kustomization] = field(default_factory=list) 929 """A list of flux Kustomizations for the cluster.""" 930 931 @property 932 def id_name(self) -> str: 933 """Identifier for the Cluster in tests.""" 934 return f"{self.path}" 935 936 @property 937 def helm_repos(self) -> list[HelmRepository]: 938 """Return the list of HelmRepository objects from all Kustomizations.""" 939 return [ 940 repo 941 for kustomization in self.kustomizations 942 for repo in kustomization.helm_repos 943 ] 944 945 @property 946 def oci_repos(self) -> list[OCIRepository]: 947 """Return the list of OCIRepository objects from all Kustomizations.""" 948 return [ 949 repo 950 for kustomization in self.kustomizations 951 for repo in kustomization.oci_repos 952 ] 953 954 @property 955 def helm_releases(self) -> list[HelmRelease]: 956 """Return the list of HelmRelease objects from all Kustomizations.""" 957 return [ 958 release 959 for kustomization in self.kustomizations 960 for release in kustomization.helm_releases 961 ] 962 963 964@dataclass 965class Manifest(BaseManifest): 966 """Holds information about cluster and applications contained in a repo.""" 967 968 clusters: list[Cluster] 969 """A list of Clusters represented in the repo.""" 970 971 972async def read_manifest(manifest_path: Path) -> Manifest: 973 """Return the contents of a serialized manifest file. 974 975 A manifest file is typically created by `flux-local get cluster -o yaml` or 976 similar command. 977 """ 978 async with aiofiles.open(str(manifest_path)) as manifest_file: 979 content = await manifest_file.read() 980 if not content: 981 raise ValueError("validation error for Manifest file {manifest_path}") 982 return cast(Manifest, Manifest.parse_yaml(content)) 983 984 985async def write_manifest(manifest_path: Path, manifest: Manifest) -> None: 986 """Write the specified manifest content to disk.""" 987 content = manifest.yaml() 988 async with aiofiles.open(str(manifest_path), mode="w") as manifest_file: 989 await manifest_file.write(content) 990 991 992async def update_manifest(manifest_path: Path, manifest: Manifest) -> None: 993 """Write the specified manifest only if changed.""" 994 new_content = manifest.yaml() 995 async with aiofiles.open(str(manifest_path)) as manifest_file: 996 content = await manifest_file.read() 997 if content == new_content: 998 return 999 async with aiofiles.open(str(manifest_path), mode="w") as manifest_file: 1000 await manifest_file.write(new_content) 1001 1002 1003def parse_raw_obj(obj: dict[str, Any], *, wipe_secrets: bool = True) -> BaseManifest: 1004 """Parse a raw kubernetes object into a BaseManifest.""" 1005 if not (kind := obj.get("kind")): 1006 raise InputException(f"Invalid object missing kind: {obj}") 1007 if not (api_version := obj.get("apiVersion")): 1008 raise InputException(f"Invalid object missing apiVersion: {obj}") 1009 if kind == KUSTOMIZE_KIND and api_version.startswith(FLUXTOMIZE_DOMAIN): 1010 return Kustomization.parse_doc(obj) 1011 if kind == HELM_RELEASE: 1012 return HelmRelease.parse_doc(obj) 1013 if kind == HELM_REPOSITORY: 1014 return HelmRepository.parse_doc(obj) 1015 if kind == GIT_REPOSITORY: 1016 return GitRepository.parse_doc(obj) 1017 if kind == OCI_REPOSITORY: 1018 return OCIRepository.parse_doc(obj) 1019 if kind == CONFIG_MAP_KIND: 1020 return ConfigMap.parse_doc(obj) 1021 if kind == SECRET_KIND: 1022 return Secret.parse_doc(obj, wipe_secrets=wipe_secrets) 1023 return RawObject.parse_doc(obj) 1024 1025 1026def is_kustomization(obj: dict[str, Any]) -> bool: 1027 """Check if the object is a Kustomization.""" 1028 return obj.get("kind") == KUSTOMIZE_KIND and obj.get("apiVersion", "").startswith( 1029 KUSTOMIZE_DOMAIN 1030 ) 1031 1032 1033def _strip_attrs(metadata: dict[str, Any], strip_attributes: list[str]) -> None: 1034 """Update the resource object, stripping any requested labels to simplify diff.""" 1035 1036 for attr_key in ("annotations", "labels"): 1037 if not (val := metadata.get(attr_key)): 1038 continue 1039 for key in strip_attributes: 1040 if key in val: 1041 del val[key] 1042 if not val: 1043 del metadata[attr_key] 1044 break 1045 1046 1047def strip_resource_attributes( 1048 resource: dict[str, Any], strip_attributes: list[str] 1049) -> None: 1050 """Strip any annotations from kustomize that contribute to diff noise when objects are re-ordered in the output.""" 1051 _strip_attrs(resource["metadata"], strip_attributes) 1052 # Remove common noisy labels in commonly used templates 1053 if ( 1054 (spec := resource.get("spec")) 1055 and (templ := spec.get("template")) 1056 and (meta := templ.get("metadata")) 1057 ): 1058 _strip_attrs(meta, strip_attributes) 1059 if ( 1060 resource["kind"] == "List" 1061 and (items := resource.get("items")) 1062 and isinstance(items, list) 1063 ): 1064 for item in items: 1065 if not (item_meta := item.get("metadata")): 1066 continue 1067 _strip_attrs(item_meta, strip_attributes)
973async def read_manifest(manifest_path: Path) -> Manifest: 974 """Return the contents of a serialized manifest file. 975 976 A manifest file is typically created by `flux-local get cluster -o yaml` or 977 similar command. 978 """ 979 async with aiofiles.open(str(manifest_path)) as manifest_file: 980 content = await manifest_file.read() 981 if not content: 982 raise ValueError("validation error for Manifest file {manifest_path}") 983 return cast(Manifest, Manifest.parse_yaml(content))
Return the contents of a serialized manifest file.
A manifest file is typically created by flux-local get cluster -o yaml
or
similar command.
986async def write_manifest(manifest_path: Path, manifest: Manifest) -> None: 987 """Write the specified manifest content to disk.""" 988 content = manifest.yaml() 989 async with aiofiles.open(str(manifest_path), mode="w") as manifest_file: 990 await manifest_file.write(content)
Write the specified manifest content to disk.
965@dataclass 966class Manifest(BaseManifest): 967 """Holds information about cluster and applications contained in a repo.""" 968 969 clusters: list[Cluster] 970 """A list of Clusters represented in the repo."""
Holds information about cluster and applications contained in a repo.
Inherited Members
918@dataclass 919class Cluster(BaseManifest): 920 """A set of nodes that run containerized applications. 921 922 Many flux git repos will only have a single flux cluster, though 923 a repo may also contain multiple (e.g. dev an prod). 924 """ 925 926 path: str 927 """The local git repo path to the Kustomization objects for the cluster.""" 928 929 kustomizations: list[Kustomization] = field(default_factory=list) 930 """A list of flux Kustomizations for the cluster.""" 931 932 @property 933 def id_name(self) -> str: 934 """Identifier for the Cluster in tests.""" 935 return f"{self.path}" 936 937 @property 938 def helm_repos(self) -> list[HelmRepository]: 939 """Return the list of HelmRepository objects from all Kustomizations.""" 940 return [ 941 repo 942 for kustomization in self.kustomizations 943 for repo in kustomization.helm_repos 944 ] 945 946 @property 947 def oci_repos(self) -> list[OCIRepository]: 948 """Return the list of OCIRepository objects from all Kustomizations.""" 949 return [ 950 repo 951 for kustomization in self.kustomizations 952 for repo in kustomization.oci_repos 953 ] 954 955 @property 956 def helm_releases(self) -> list[HelmRelease]: 957 """Return the list of HelmRelease objects from all Kustomizations.""" 958 return [ 959 release 960 for kustomization in self.kustomizations 961 for release in kustomization.helm_releases 962 ]
A set of nodes that run containerized applications.
Many flux git repos will only have a single flux cluster, though a repo may also contain multiple (e.g. dev an prod).
932 @property 933 def id_name(self) -> str: 934 """Identifier for the Cluster in tests.""" 935 return f"{self.path}"
Identifier for the Cluster in tests.
937 @property 938 def helm_repos(self) -> list[HelmRepository]: 939 """Return the list of HelmRepository objects from all Kustomizations.""" 940 return [ 941 repo 942 for kustomization in self.kustomizations 943 for repo in kustomization.helm_repos 944 ]
Return the list of HelmRepository objects from all Kustomizations.
946 @property 947 def oci_repos(self) -> list[OCIRepository]: 948 """Return the list of OCIRepository objects from all Kustomizations.""" 949 return [ 950 repo 951 for kustomization in self.kustomizations 952 for repo in kustomization.oci_repos 953 ]
Return the list of OCIRepository objects from all Kustomizations.
955 @property 956 def helm_releases(self) -> list[HelmRelease]: 957 """Return the list of HelmRelease objects from all Kustomizations.""" 958 return [ 959 release 960 for kustomization in self.kustomizations 961 for release in kustomization.helm_releases 962 ]
Return the list of HelmRelease objects from all Kustomizations.
Inherited Members
766@dataclass 767class Kustomization(BaseManifest): 768 """A Kustomization is a set of declared cluster artifacts. 769 770 This represents a flux Kustomization that points to a path that 771 contains typical `kustomize` Kustomizations on local disk that 772 may be flat or contain overlays. 773 """ 774 775 kind: ClassVar[str] = KUSTOMIZE_KIND 776 """The kind of the object.""" 777 778 name: str 779 """The name of the kustomization.""" 780 781 namespace: str | None 782 """The namespace of the kustomization.""" 783 784 path: str 785 """The local repo path to the kustomization contents.""" 786 787 helm_repos: list[HelmRepository] = field(default_factory=list) 788 """The set of HelmRepositories represented in this kustomization.""" 789 790 oci_repos: list[OCIRepository] = field(default_factory=list) 791 """The set of OCIRepositories represented in this kustomization.""" 792 793 helm_releases: list[HelmRelease] = field(default_factory=list) 794 """The set of HelmRelease represented in this kustomization.""" 795 796 config_maps: list[ConfigMap] = field(default_factory=list) 797 """The list of config maps referenced in the kustomization.""" 798 799 secrets: list[Secret] = field(default_factory=list) 800 """The list of secrets referenced in the kustomization.""" 801 802 source_path: str | None = field(metadata={"serialize": "omit"}, default=None) 803 """Optional source path for this Kustomization, relative to the build path.""" 804 805 source_kind: str | None = field(metadata={"serialize": "omit"}, default=None) 806 """The sourceRef kind that provides this Kustomization e.g. GitRepository etc.""" 807 808 source_name: str | None = field(metadata={"serialize": "omit"}, default=None) 809 """The name of the sourceRef that provides this Kustomization.""" 810 811 source_namespace: str | None = field(metadata={"serialize": "omit"}, default=None) 812 """The namespace of the sourceRef that provides this Kustomization.""" 813 814 target_namespace: str | None = field(metadata={"serialize": "omit"}, default=None) 815 """The namespace to target when performing the operation.""" 816 817 contents: dict[str, Any] | None = field( 818 metadata={"serialize": "omit"}, default=None 819 ) 820 """Contents of the raw Kustomization document.""" 821 822 images: list[str] | None = field(default=None) 823 """The list of images referenced in the kustomization.""" 824 825 postbuild_substitute: Optional[dict[str, Any]] = field( 826 metadata={"serialize": "omit"}, default=None 827 ) 828 """A map of key/value pairs to substitute into the final YAML manifest, after building.""" 829 830 postbuild_substitute_from: Optional[list[SubstituteReference]] = field( 831 metadata={"serialize": "omit"}, default=None 832 ) 833 """A list of substitutions to reference from an ConfigMap or Secret.""" 834 835 depends_on: list[str] | None = field(metadata={"serialize": "omit"}, default=None) 836 """A list of namespaced names that this Kustomization depends on.""" 837 838 labels: dict[str, str] | None = field(metadata={"serialize": "omit"}, default=None) 839 """A list of labels on the Kustomization.""" 840 841 @classmethod 842 def parse_doc(cls, doc: dict[str, Any]) -> "Kustomization": 843 """Parse a partial Kustomization from a kubernetes resource.""" 844 _check_version(doc, FLUXTOMIZE_DOMAIN) 845 if not (metadata := doc.get("metadata")): 846 raise InputException(f"Invalid {cls} missing metadata: {doc}") 847 if not (name := metadata.get("name")): 848 raise InputException(f"Invalid {cls} missing metadata.name: {doc}") 849 if not (namespace := metadata.get("namespace")): 850 raise InputException(f"Invalid {cls} missing metadata.namespace: {doc}") 851 if not (spec := doc.get("spec")): 852 raise InputException(f"Invalid {cls} missing spec: {doc}") 853 path = spec.get("path", "") 854 source_path = metadata.get("annotations", {}).get("config.kubernetes.io/path") 855 source_ref = spec.get("sourceRef", {}) 856 postbuild = spec.get("postBuild", {}) 857 substitute_from: list[SubstituteReference] | None = None 858 if substitute_from_dict := postbuild.get("substituteFrom"): 859 substitute_from = [ 860 SubstituteReference(**subdoc) for subdoc in substitute_from_dict 861 ] 862 depends_on = [] 863 for dependency in spec.get("dependsOn", ()): 864 if not (dep_name := dependency.get("name")): 865 raise InputException(f"Invalid {cls} missing dependsOn.name: {doc}") 866 dep_namespace = dependency.get("namespace", namespace) 867 depends_on.append(f"{dep_namespace}/{dep_name}") 868 return Kustomization( 869 name=name, 870 namespace=namespace, 871 path=path, 872 source_path=source_path, 873 source_kind=source_ref.get("kind"), 874 source_name=source_ref.get("name"), 875 source_namespace=source_ref.get("namespace", namespace), 876 target_namespace=spec.get("targetNamespace"), 877 contents=doc, 878 postbuild_substitute=postbuild.get("substitute"), 879 postbuild_substitute_from=substitute_from, 880 depends_on=depends_on, 881 labels=metadata.get("labels"), 882 ) 883 884 @property 885 def id_name(self) -> str: 886 """Identifier for the Kustomization in tests""" 887 return f"{self.path}" 888 889 @property 890 def namespaced_name(self) -> str: 891 """Return the namespace and name concatenated as an id.""" 892 return f"{self.namespace}/{self.name}" 893 894 def validate_depends_on(self, all_ks: set[str]) -> None: 895 """Validate depends_on values are all correct given the list of Kustomizations.""" 896 depends_on = set(self.depends_on or {}) 897 if missing := (depends_on - all_ks): 898 _LOGGER.warning( 899 "Kustomization %s has dependsOn with invalid names: %s", 900 self.namespaced_name, 901 missing, 902 ) 903 self.depends_on = list(depends_on - missing) 904 905 def update_postbuild_substitutions(self, substitutions: dict[str, Any]) -> None: 906 """Update the postBuild.substitutions in the extracted values and raw doc contents.""" 907 if self.postbuild_substitute is None: 908 self.postbuild_substitute = {} 909 self.postbuild_substitute.update(substitutions) 910 if self.contents: 911 post_build = self.contents["spec"]["postBuild"] 912 if (substitute := post_build.get("substitute")) is None: 913 substitute = {} 914 post_build["substitute"] = substitute 915 substitute.update(substitutions)
A Kustomization is a set of declared cluster artifacts.
This represents a flux Kustomization that points to a path that
contains typical kustomize
Kustomizations on local disk that
may be flat or contain overlays.
The set of OCIRepositories represented in this kustomization.
Optional source path for this Kustomization, relative to the build path.
The sourceRef kind that provides this Kustomization e.g. GitRepository etc.
The namespace of the sourceRef that provides this Kustomization.
A map of key/value pairs to substitute into the final YAML manifest, after building.
A list of substitutions to reference from an ConfigMap or Secret.
841 @classmethod 842 def parse_doc(cls, doc: dict[str, Any]) -> "Kustomization": 843 """Parse a partial Kustomization from a kubernetes resource.""" 844 _check_version(doc, FLUXTOMIZE_DOMAIN) 845 if not (metadata := doc.get("metadata")): 846 raise InputException(f"Invalid {cls} missing metadata: {doc}") 847 if not (name := metadata.get("name")): 848 raise InputException(f"Invalid {cls} missing metadata.name: {doc}") 849 if not (namespace := metadata.get("namespace")): 850 raise InputException(f"Invalid {cls} missing metadata.namespace: {doc}") 851 if not (spec := doc.get("spec")): 852 raise InputException(f"Invalid {cls} missing spec: {doc}") 853 path = spec.get("path", "") 854 source_path = metadata.get("annotations", {}).get("config.kubernetes.io/path") 855 source_ref = spec.get("sourceRef", {}) 856 postbuild = spec.get("postBuild", {}) 857 substitute_from: list[SubstituteReference] | None = None 858 if substitute_from_dict := postbuild.get("substituteFrom"): 859 substitute_from = [ 860 SubstituteReference(**subdoc) for subdoc in substitute_from_dict 861 ] 862 depends_on = [] 863 for dependency in spec.get("dependsOn", ()): 864 if not (dep_name := dependency.get("name")): 865 raise InputException(f"Invalid {cls} missing dependsOn.name: {doc}") 866 dep_namespace = dependency.get("namespace", namespace) 867 depends_on.append(f"{dep_namespace}/{dep_name}") 868 return Kustomization( 869 name=name, 870 namespace=namespace, 871 path=path, 872 source_path=source_path, 873 source_kind=source_ref.get("kind"), 874 source_name=source_ref.get("name"), 875 source_namespace=source_ref.get("namespace", namespace), 876 target_namespace=spec.get("targetNamespace"), 877 contents=doc, 878 postbuild_substitute=postbuild.get("substitute"), 879 postbuild_substitute_from=substitute_from, 880 depends_on=depends_on, 881 labels=metadata.get("labels"), 882 )
Parse a partial Kustomization from a kubernetes resource.
884 @property 885 def id_name(self) -> str: 886 """Identifier for the Kustomization in tests""" 887 return f"{self.path}"
Identifier for the Kustomization in tests
889 @property 890 def namespaced_name(self) -> str: 891 """Return the namespace and name concatenated as an id.""" 892 return f"{self.namespace}/{self.name}"
Return the namespace and name concatenated as an id.
894 def validate_depends_on(self, all_ks: set[str]) -> None: 895 """Validate depends_on values are all correct given the list of Kustomizations.""" 896 depends_on = set(self.depends_on or {}) 897 if missing := (depends_on - all_ks): 898 _LOGGER.warning( 899 "Kustomization %s has dependsOn with invalid names: %s", 900 self.namespaced_name, 901 missing, 902 ) 903 self.depends_on = list(depends_on - missing)
Validate depends_on values are all correct given the list of Kustomizations.
905 def update_postbuild_substitutions(self, substitutions: dict[str, Any]) -> None: 906 """Update the postBuild.substitutions in the extracted values and raw doc contents.""" 907 if self.postbuild_substitute is None: 908 self.postbuild_substitute = {} 909 self.postbuild_substitute.update(substitutions) 910 if self.contents: 911 post_build = self.contents["spec"]["postBuild"] 912 if (substitute := post_build.get("substitute")) is None: 913 substitute = {} 914 post_build["substitute"] = substitute 915 substitute.update(substitutions)
Update the postBuild.substitutions in the extracted values and raw doc contents.
Inherited Members
404@dataclass 405class HelmRepository(BaseManifest): 406 """A representation of a flux HelmRepository.""" 407 408 kind: ClassVar[str] = HELM_REPO_KIND 409 """The kind of the object.""" 410 411 name: str 412 """The name of the HelmRepository.""" 413 414 namespace: str 415 """The namespace of owning the HelmRepository.""" 416 417 url: str 418 """The URL to the repository of helm charts.""" 419 420 repo_type: str | None = None 421 """The type of the HelmRepository.""" 422 423 @classmethod 424 def parse_doc(cls, doc: dict[str, Any]) -> "HelmRepository": 425 """Parse a HelmRepository from a kubernetes resource.""" 426 _check_version(doc, HELM_REPO_DOMAIN) 427 if not (metadata := doc.get("metadata")): 428 raise InputException(f"Invalid {cls} missing metadata: {doc}") 429 if not (name := metadata.get("name")): 430 raise InputException(f"Invalid {cls} missing metadata.name: {doc}") 431 if not (namespace := metadata.get("namespace")): 432 raise InputException(f"Invalid {cls} missing metadata.namespace: {doc}") 433 if not (spec := doc.get("spec")): 434 raise InputException(f"Invalid {cls} missing spec: {doc}") 435 if not (url := spec.get("url")): 436 raise InputException(f"Invalid {cls} missing spec.url: {doc}") 437 return cls( 438 name=name, 439 namespace=namespace, 440 url=url, 441 repo_type=spec.get("type", REPO_TYPE_DEFAULT), 442 ) 443 444 @property 445 def repo_name(self) -> str: 446 """Identifier for the HelmRepository.""" 447 return f"{self.namespace}-{self.name}" 448 449 def helm_chart_name(self, chart: HelmChart) -> str: 450 """Get the name or URL for a specific chart for helm template.""" 451 if self.repo_type == REPO_TYPE_OCI: 452 # For OCI repositories, we need to append the chart short name to the URL 453 return f"{self.url}/{chart.name}" 454 # For regular helm repositories, we just return the full chart name 455 return chart.chart_name
A representation of a flux HelmRepository.
423 @classmethod 424 def parse_doc(cls, doc: dict[str, Any]) -> "HelmRepository": 425 """Parse a HelmRepository from a kubernetes resource.""" 426 _check_version(doc, HELM_REPO_DOMAIN) 427 if not (metadata := doc.get("metadata")): 428 raise InputException(f"Invalid {cls} missing metadata: {doc}") 429 if not (name := metadata.get("name")): 430 raise InputException(f"Invalid {cls} missing metadata.name: {doc}") 431 if not (namespace := metadata.get("namespace")): 432 raise InputException(f"Invalid {cls} missing metadata.namespace: {doc}") 433 if not (spec := doc.get("spec")): 434 raise InputException(f"Invalid {cls} missing spec: {doc}") 435 if not (url := spec.get("url")): 436 raise InputException(f"Invalid {cls} missing spec.url: {doc}") 437 return cls( 438 name=name, 439 namespace=namespace, 440 url=url, 441 repo_type=spec.get("type", REPO_TYPE_DEFAULT), 442 )
Parse a HelmRepository from a kubernetes resource.
444 @property 445 def repo_name(self) -> str: 446 """Identifier for the HelmRepository.""" 447 return f"{self.namespace}-{self.name}"
Identifier for the HelmRepository.
449 def helm_chart_name(self, chart: HelmChart) -> str: 450 """Get the name or URL for a specific chart for helm template.""" 451 if self.repo_type == REPO_TYPE_OCI: 452 # For OCI repositories, we need to append the chart short name to the URL 453 return f"{self.url}/{chart.name}" 454 # For regular helm repositories, we just return the full chart name 455 return chart.chart_name
Get the name or URL for a specific chart for helm template.
Inherited Members
269@dataclass 270class HelmRelease(BaseManifest): 271 """A representation of a Flux HelmRelease.""" 272 273 kind: ClassVar[str] = HELM_RELEASE 274 """The kind of the object.""" 275 276 name: str 277 """The name of the HelmRelease.""" 278 279 namespace: str 280 """The namespace that owns the HelmRelease.""" 281 282 chart: HelmChart 283 """A mapping to a specific helm chart for this HelmRelease.""" 284 285 target_namespace: str | None = field(metadata={"serialize": "omit"}, default=None) 286 """The namespace to target when performing the operation.""" 287 288 values: Optional[dict[str, Any]] = field( 289 metadata={"serialize": "omit"}, default=None 290 ) 291 """The values to install in the chart.""" 292 293 values_from: Optional[list[ValuesReference]] = field( 294 metadata={"serialize": "omit"}, default=None 295 ) 296 """A list of values to reference from an ConfigMap or Secret.""" 297 298 images: list[str] | None = field(default=None) 299 """The list of images referenced in the HelmRelease.""" 300 301 labels: dict[str, str] | None = field(metadata={"serialize": "omit"}, default=None) 302 """A list of labels on the HelmRelease.""" 303 304 disable_schema_validation: bool = field( 305 metadata={"serialize": "omit"}, default=False 306 ) 307 """Prevents Helm from validating the values against the JSON Schema.""" 308 309 disable_openapi_validation: bool = field( 310 metadata={"serialize": "omit"}, default=False 311 ) 312 """Prevents Helm from validating the values against the Kubernetes OpenAPI Schema.""" 313 314 @classmethod 315 def parse_doc(cls, doc: dict[str, Any]) -> "HelmRelease": 316 """Parse a HelmRelease from a kubernetes resource object.""" 317 _check_version(doc, HELM_RELEASE_DOMAIN) 318 if not (metadata := doc.get("metadata")): 319 raise InputException(f"Invalid {cls} missing metadata: {doc}") 320 if not (name := metadata.get("name")): 321 raise InputException(f"Invalid {cls} missing metadata.name: {doc}") 322 if not (namespace := metadata.get("namespace")): 323 raise InputException(f"Invalid {cls} missing metadata.namespace: {doc}") 324 chart = HelmChart.parse_doc(doc, namespace) 325 spec = doc["spec"] 326 values_from: list[ValuesReference] | None = None 327 if values_from_dict := spec.get("valuesFrom"): 328 values_from = [ 329 ValuesReference.from_dict(subdoc) for subdoc in values_from_dict 330 ] 331 disable_schema_validation = any( 332 bag.get("disableSchemaValidation") 333 for key in ("install", "upgrade") 334 if (bag := spec.get(key)) is not None 335 ) 336 disable_openapi_validation = any( 337 bag.get("disableOpenAPIValidation") 338 for key in ("install", "upgrade") 339 if (bag := spec.get(key)) is not None 340 ) 341 return HelmRelease( 342 name=name, 343 namespace=namespace, 344 target_namespace=spec.get("targetNamespace"), 345 chart=chart, 346 values=spec.get("values"), 347 values_from=values_from, 348 labels=metadata.get("labels"), 349 disable_schema_validation=disable_schema_validation, 350 disable_openapi_validation=disable_openapi_validation, 351 ) 352 353 @property 354 def release_name(self) -> str: 355 """Identifier for the HelmRelease.""" 356 return f"{self.namespace}-{self.name}" 357 358 @property 359 def release_namespace(self) -> str: 360 """Actual namespace where the HelmRelease will be installed to.""" 361 if self.target_namespace: 362 return self.target_namespace 363 return self.namespace 364 365 @property 366 def repo_name(self) -> str: 367 """Identifier for the HelmRepository identified in the HelmChart.""" 368 return f"{self.chart.repo_namespace}-{self.chart.repo_name}" 369 370 @property 371 def namespaced_name(self) -> str: 372 """Return the namespace and name concatenated as an id.""" 373 return f"{self.namespace}/{self.name}" 374 375 @property 376 def resource_dependencies(self) -> list[NamedResource]: 377 """Return the list of input dependencies for the HelmRelease.""" 378 deps = [ 379 NamedResource( 380 kind=HELM_RELEASE, 381 name=self.name, 382 namespace=self.namespace, 383 ) 384 ] 385 if self.chart: 386 deps.append( 387 NamedResource( 388 kind=self.chart.repo_kind, 389 name=self.chart.repo_name, 390 namespace=self.chart.repo_namespace, 391 ) 392 ) 393 names_seen = set() 394 for ref in self.values_from or (): 395 if ref.name in names_seen: 396 continue 397 names_seen.add(ref.name) 398 deps.append( 399 NamedResource(kind=ref.kind, name=ref.name, namespace=self.namespace) 400 ) 401 return deps
A representation of a Flux HelmRelease.
A list of values to reference from an ConfigMap or Secret.
Prevents Helm from validating the values against the JSON Schema.
Prevents Helm from validating the values against the Kubernetes OpenAPI Schema.
314 @classmethod 315 def parse_doc(cls, doc: dict[str, Any]) -> "HelmRelease": 316 """Parse a HelmRelease from a kubernetes resource object.""" 317 _check_version(doc, HELM_RELEASE_DOMAIN) 318 if not (metadata := doc.get("metadata")): 319 raise InputException(f"Invalid {cls} missing metadata: {doc}") 320 if not (name := metadata.get("name")): 321 raise InputException(f"Invalid {cls} missing metadata.name: {doc}") 322 if not (namespace := metadata.get("namespace")): 323 raise InputException(f"Invalid {cls} missing metadata.namespace: {doc}") 324 chart = HelmChart.parse_doc(doc, namespace) 325 spec = doc["spec"] 326 values_from: list[ValuesReference] | None = None 327 if values_from_dict := spec.get("valuesFrom"): 328 values_from = [ 329 ValuesReference.from_dict(subdoc) for subdoc in values_from_dict 330 ] 331 disable_schema_validation = any( 332 bag.get("disableSchemaValidation") 333 for key in ("install", "upgrade") 334 if (bag := spec.get(key)) is not None 335 ) 336 disable_openapi_validation = any( 337 bag.get("disableOpenAPIValidation") 338 for key in ("install", "upgrade") 339 if (bag := spec.get(key)) is not None 340 ) 341 return HelmRelease( 342 name=name, 343 namespace=namespace, 344 target_namespace=spec.get("targetNamespace"), 345 chart=chart, 346 values=spec.get("values"), 347 values_from=values_from, 348 labels=metadata.get("labels"), 349 disable_schema_validation=disable_schema_validation, 350 disable_openapi_validation=disable_openapi_validation, 351 )
Parse a HelmRelease from a kubernetes resource object.
353 @property 354 def release_name(self) -> str: 355 """Identifier for the HelmRelease.""" 356 return f"{self.namespace}-{self.name}"
Identifier for the HelmRelease.
358 @property 359 def release_namespace(self) -> str: 360 """Actual namespace where the HelmRelease will be installed to.""" 361 if self.target_namespace: 362 return self.target_namespace 363 return self.namespace
Actual namespace where the HelmRelease will be installed to.
365 @property 366 def repo_name(self) -> str: 367 """Identifier for the HelmRepository identified in the HelmChart.""" 368 return f"{self.chart.repo_namespace}-{self.chart.repo_name}"
Identifier for the HelmRepository identified in the HelmChart.
370 @property 371 def namespaced_name(self) -> str: 372 """Return the namespace and name concatenated as an id.""" 373 return f"{self.namespace}/{self.name}"
Return the namespace and name concatenated as an id.
375 @property 376 def resource_dependencies(self) -> list[NamedResource]: 377 """Return the list of input dependencies for the HelmRelease.""" 378 deps = [ 379 NamedResource( 380 kind=HELM_RELEASE, 381 name=self.name, 382 namespace=self.namespace, 383 ) 384 ] 385 if self.chart: 386 deps.append( 387 NamedResource( 388 kind=self.chart.repo_kind, 389 name=self.chart.repo_name, 390 namespace=self.chart.repo_namespace, 391 ) 392 ) 393 names_seen = set() 394 for ref in self.values_from or (): 395 if ref.name in names_seen: 396 continue 397 names_seen.add(ref.name) 398 deps.append( 399 NamedResource(kind=ref.kind, name=ref.name, namespace=self.namespace) 400 ) 401 return deps
Return the list of input dependencies for the HelmRelease.
Inherited Members
160@dataclass 161class HelmChart(BaseManifest): 162 """A representation of an instantiation of a chart for a HelmRelease.""" 163 164 kind: ClassVar[str] = HELM_CHART 165 """The kind of the object.""" 166 167 name: str 168 """The name of the chart within the HelmRepository.""" 169 170 version: Optional[str] = field(metadata={"serialize": "omit"}) 171 """The version of the chart.""" 172 173 repo_name: str 174 """The short name of the repository.""" 175 176 repo_namespace: str 177 """The namespace of the repository.""" 178 179 repo_kind: str = HELM_REPO_KIND 180 """The kind of the soruceRef of the repository (e.g. HelmRepository, GitRepository).""" 181 182 @classmethod 183 def parse_doc(cls, doc: dict[str, Any], default_namespace: str) -> "HelmChart": 184 """Parse a HelmChart from a HelmRelease resource object.""" 185 _check_version(doc, HELM_RELEASE_DOMAIN) 186 if not (spec := doc.get("spec")): 187 raise InputException(f"Invalid {cls} missing spec: {doc}") 188 chart_ref = spec.get("chartRef") 189 chart = spec.get("chart") 190 if not chart_ref and not chart: 191 raise InputException( 192 f"Invalid {cls} missing spec.chart or spec.chartRef: {doc}" 193 ) 194 if chart_ref: 195 if not (kind := chart_ref.get("kind")): 196 raise InputException(f"Invalid {cls} missing spec.chartRef.kind: {doc}") 197 if not (name := chart_ref.get("name")): 198 raise InputException(f"Invalid {cls} missing spec.chartRef.name: {doc}") 199 200 return cls( 201 name=name, 202 version=None, 203 repo_name=name, 204 repo_namespace=chart_ref.get("namespace", default_namespace), 205 repo_kind=kind, 206 ) 207 if not (chart_spec := chart.get("spec")): 208 raise InputException(f"Invalid {cls} missing spec.chart.spec: {doc}") 209 if not (chart := chart_spec.get("chart")): 210 raise InputException(f"Invalid {cls} missing spec.chart.spec.chart: {doc}") 211 version = chart_spec.get("version") 212 if not (source_ref := chart_spec.get("sourceRef")): 213 raise InputException( 214 f"Invalid {cls} missing spec.chart.spec.sourceRef: {doc}" 215 ) 216 if "name" not in source_ref: 217 raise InputException(f"Invalid {cls} missing sourceRef fields: {doc}") 218 return cls( 219 name=chart, 220 version=version, 221 repo_name=source_ref["name"], 222 repo_namespace=source_ref.get("namespace", default_namespace), 223 repo_kind=source_ref.get("kind", HELM_REPO_KIND), 224 ) 225 226 @property 227 def repo_full_name(self) -> str: 228 """Identifier for the HelmRepository.""" 229 return f"{self.repo_namespace}-{self.repo_name}" 230 231 @property 232 def chart_name(self) -> str: 233 """Identifier for the HelmChart.""" 234 return f"{self.repo_full_name}/{self.name}"
A representation of an instantiation of a chart for a HelmRelease.
The kind of the soruceRef of the repository (e.g. HelmRepository, GitRepository).
182 @classmethod 183 def parse_doc(cls, doc: dict[str, Any], default_namespace: str) -> "HelmChart": 184 """Parse a HelmChart from a HelmRelease resource object.""" 185 _check_version(doc, HELM_RELEASE_DOMAIN) 186 if not (spec := doc.get("spec")): 187 raise InputException(f"Invalid {cls} missing spec: {doc}") 188 chart_ref = spec.get("chartRef") 189 chart = spec.get("chart") 190 if not chart_ref and not chart: 191 raise InputException( 192 f"Invalid {cls} missing spec.chart or spec.chartRef: {doc}" 193 ) 194 if chart_ref: 195 if not (kind := chart_ref.get("kind")): 196 raise InputException(f"Invalid {cls} missing spec.chartRef.kind: {doc}") 197 if not (name := chart_ref.get("name")): 198 raise InputException(f"Invalid {cls} missing spec.chartRef.name: {doc}") 199 200 return cls( 201 name=name, 202 version=None, 203 repo_name=name, 204 repo_namespace=chart_ref.get("namespace", default_namespace), 205 repo_kind=kind, 206 ) 207 if not (chart_spec := chart.get("spec")): 208 raise InputException(f"Invalid {cls} missing spec.chart.spec: {doc}") 209 if not (chart := chart_spec.get("chart")): 210 raise InputException(f"Invalid {cls} missing spec.chart.spec.chart: {doc}") 211 version = chart_spec.get("version") 212 if not (source_ref := chart_spec.get("sourceRef")): 213 raise InputException( 214 f"Invalid {cls} missing spec.chart.spec.sourceRef: {doc}" 215 ) 216 if "name" not in source_ref: 217 raise InputException(f"Invalid {cls} missing sourceRef fields: {doc}") 218 return cls( 219 name=chart, 220 version=version, 221 repo_name=source_ref["name"], 222 repo_namespace=source_ref.get("namespace", default_namespace), 223 repo_kind=source_ref.get("kind", HELM_REPO_KIND), 224 )
Parse a HelmChart from a HelmRelease resource object.
226 @property 227 def repo_full_name(self) -> str: 228 """Identifier for the HelmRepository.""" 229 return f"{self.repo_namespace}-{self.repo_name}"
Identifier for the HelmRepository.
231 @property 232 def chart_name(self) -> str: 233 """Identifier for the HelmChart.""" 234 return f"{self.repo_full_name}/{self.name}"
Identifier for the HelmChart.
Inherited Members
667@dataclass 668class ConfigMap(BaseManifest): 669 """A ConfigMap is an API object used to store data in key-value pairs.""" 670 671 kind: ClassVar[str] = CONFIG_MAP_KIND 672 """The kind of the ConfigMap.""" 673 674 name: str 675 """The name of the ConfigMap.""" 676 677 namespace: str | None = None 678 """The namespace of the kustomization.""" 679 680 data: dict[str, Any] | None = field(metadata={"serialize": "omit"}, default=None) 681 """The data in the ConfigMap.""" 682 683 binary_data: dict[str, Any] | None = field( 684 metadata={"serialize": "omit"}, default=None 685 ) 686 """The binary data in the ConfigMap.""" 687 688 @classmethod 689 def parse_doc(cls, doc: dict[str, Any]) -> "ConfigMap": 690 """Parse a config map object from a kubernetes resource.""" 691 _check_version(doc, "v1") 692 if not (metadata := doc.get("metadata")): 693 raise InputException(f"Invalid {cls} missing metadata: {doc}") 694 if not (name := metadata.get("name")): 695 raise InputException(f"Invalid {cls} missing metadata.name: {doc}") 696 namespace = metadata.get("namespace") 697 return ConfigMap( 698 name=name, 699 namespace=namespace, 700 data=doc.get("data"), 701 binary_data=doc.get("binaryData"), 702 )
A ConfigMap is an API object used to store data in key-value pairs.
688 @classmethod 689 def parse_doc(cls, doc: dict[str, Any]) -> "ConfigMap": 690 """Parse a config map object from a kubernetes resource.""" 691 _check_version(doc, "v1") 692 if not (metadata := doc.get("metadata")): 693 raise InputException(f"Invalid {cls} missing metadata: {doc}") 694 if not (name := metadata.get("name")): 695 raise InputException(f"Invalid {cls} missing metadata.name: {doc}") 696 namespace = metadata.get("namespace") 697 return ConfigMap( 698 name=name, 699 namespace=namespace, 700 data=doc.get("data"), 701 binary_data=doc.get("binaryData"), 702 )
Parse a config map object from a kubernetes resource.
Inherited Members
705@dataclass 706class Secret(BaseManifest): 707 """A Secret contains a small amount of sensitive data.""" 708 709 kind: ClassVar[str] = SECRET_KIND 710 """The kind of the Secret.""" 711 712 name: str 713 """The name of the Secret.""" 714 715 namespace: str | None = None 716 """The namespace of the Secret.""" 717 718 data: dict[str, Any] | None = field(metadata={"serialize": "omit"}, default=None) 719 """The data in the Secret.""" 720 721 string_data: dict[str, Any] | None = field( 722 metadata={"serialize": "omit"}, default=None 723 ) 724 """The string data in the Secret.""" 725 726 @classmethod 727 def parse_doc(cls, doc: dict[str, Any], *, wipe_secrets: bool = True) -> "Secret": 728 """Parse a secret object from a kubernetes resource.""" 729 _check_version(doc, "v1") 730 if not (metadata := doc.get("metadata")): 731 raise InputException(f"Invalid {cls} missing metadata: {doc}") 732 if not (name := metadata.get("name")): 733 raise InputException(f"Invalid {cls} missing metadata.name: {doc}") 734 namespace = metadata.get("namespace") 735 # While secrets are not typically stored in the cluster, we replace with 736 # placeholder values anyway. 737 data = doc.get("data") 738 if data and wipe_secrets: 739 for key, value in data.items(): 740 data[key] = base64.b64encode( 741 VALUE_PLACEHOLDER_TEMPLATE.format(name=key).encode() 742 ) 743 string_data = doc.get("stringData") 744 if string_data and wipe_secrets: 745 for key, value in string_data.items(): 746 string_data[key] = VALUE_PLACEHOLDER_TEMPLATE.format(name=key) 747 return Secret( 748 name=name, namespace=namespace, data=data, string_data=string_data 749 )
A Secret contains a small amount of sensitive data.
726 @classmethod 727 def parse_doc(cls, doc: dict[str, Any], *, wipe_secrets: bool = True) -> "Secret": 728 """Parse a secret object from a kubernetes resource.""" 729 _check_version(doc, "v1") 730 if not (metadata := doc.get("metadata")): 731 raise InputException(f"Invalid {cls} missing metadata: {doc}") 732 if not (name := metadata.get("name")): 733 raise InputException(f"Invalid {cls} missing metadata.name: {doc}") 734 namespace = metadata.get("namespace") 735 # While secrets are not typically stored in the cluster, we replace with 736 # placeholder values anyway. 737 data = doc.get("data") 738 if data and wipe_secrets: 739 for key, value in data.items(): 740 data[key] = base64.b64encode( 741 VALUE_PLACEHOLDER_TEMPLATE.format(name=key).encode() 742 ) 743 string_data = doc.get("stringData") 744 if string_data and wipe_secrets: 745 for key, value in string_data.items(): 746 string_data[key] = VALUE_PLACEHOLDER_TEMPLATE.format(name=key) 747 return Secret( 748 name=name, namespace=namespace, data=data, string_data=string_data 749 )
Parse a secret object from a kubernetes resource.