apiVersion: v1 kind: Namespace metadata: labels: app.kubernetes.io/managed-by: kustomize app.kubernetes.io/name: image-controller control-plane: controller-manager name: image-controller --- apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: controller-gen.kubebuilder.io/version: v0.16.3 name: imagerepositories.appstudio.redhat.com spec: group: appstudio.redhat.com names: kind: ImageRepository listKind: ImageRepositoryList plural: imagerepositories singular: imagerepository scope: Namespaced versions: - additionalPrinterColumns: - jsonPath: .status.image.url name: Image type: string - jsonPath: .status.image.visibility name: Visibility type: string name: v1alpha1 schema: openAPIV3Schema: description: ImageRepository is the Schema for the imagerepositories API properties: apiVersion: description: |- APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources type: string kind: description: |- Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds type: string metadata: type: object spec: description: ImageRepositorySpec defines the desired state of ImageRepository properties: credentials: description: Credentials management. properties: regenerate-namespace-pull-token: description: |- RegenerateNamespacePullToken defines a request to refresh namespace pull robot credentials. The field gets cleared after the refresh. type: boolean regenerate-token: description: |- RegenerateToken defines a request to refresh image accessing credentials. Refreshes both, push and pull tokens. The field gets cleared after the refresh. type: boolean verify-linking: description: |- VerifyLinking defines a request to verify and fix secret linking in pipeline service account. The field gets cleared after fixing. type: boolean type: object image: description: Requested image repository configuration. properties: name: description: |- Name of the image within configured Quay organization. If ommited, then defaults to "cr-namespace/cr-name". This field cannot be changed after the resource creation. pattern: ^[a-z0-9][.a-z0-9_-]*(/[a-z0-9][.a-z0-9_-]*)*$ type: string visibility: description: |- Visibility defines whether the image is publicly visible. Allowed values are public and private. "public" is the default. enum: - public - private type: string type: object notifications: description: Notifications defines configuration for image repository notifications. items: properties: config: properties: email: description: Email is the email address to send notifications to. type: string url: description: Webhook is the URL to send notifications to. type: string type: object event: enum: - repo_push type: string method: enum: - email - webhook type: string title: type: string type: object type: array type: object status: description: ImageRepositoryStatus defines the observed state of ImageRepository properties: credentials: description: Credentials contain information related to image repository credentials. properties: generationTimestamp: description: GenerationTime shows timestamp when the current credentials were generated. format: date-time type: string pull-robot-account: description: |- PullRobotAccountName is present only if ImageRepository has labels that connect it to Application and Component. Holds name of the quay robot account with real (pull only) permissions from the generated repository. type: string pull-secret: description: |- PullSecretName is present only if ImageRepository has labels that connect it to Application and Component. Holds name of the dockerconfig secret with credentials to pull only from the generated repository. The secret might not be present in the same namespace as ImageRepository, but created in other environments. type: string push-robot-account: description: PushRobotAccountName holds name of the quay robot account with write (push and pull) permissions into the generated repository. type: string push-secret: description: PushSecretName holds name of the dockerconfig secret with credentials to push (and pull) into the generated repository. type: string type: object image: description: Image describes actual state of the image repository. properties: url: description: URL is the full image repository url to push into / pull from. type: string visibility: allOf: - enum: - public - private - enum: - public - private description: Visibility shows actual generated image repository visibility. type: string type: object message: description: |- Message shows error information for the request. It could contain non critical error, like failed to change image visibility, while the state is ready and image resitory could be used. type: string notifications: description: Notifications shows the status of the notifications configuration. items: description: NotificationStatus shows the status of the notification configuration. properties: title: type: string uuid: type: string type: object type: array state: description: |- State shows if image repository could be used. "ready" means repository was created and usable, "failed" means that the image repository creation request failed. type: string type: object type: object served: true storage: true subresources: status: {} --- apiVersion: v1 kind: ServiceAccount metadata: labels: app.kubernetes.io/managed-by: kustomize app.kubernetes.io/name: image-controller name: image-controller-controller-manager namespace: image-controller --- apiVersion: rbac.authorization.k8s.io/v1 kind: Role metadata: labels: app.kubernetes.io/managed-by: kustomize app.kubernetes.io/name: image-controller name: image-controller-leader-election-role namespace: image-controller rules: - apiGroups: - "" resources: - configmaps verbs: - get - list - watch - create - update - patch - delete - apiGroups: - coordination.k8s.io resources: - leases verbs: - get - list - watch - create - update - patch - delete - apiGroups: - "" resources: - events verbs: - create - patch --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole metadata: labels: app.kubernetes.io/managed-by: kustomize app.kubernetes.io/name: image-controller name: image-controller-imagerepository-editor-role rules: - apiGroups: - appstudio.redhat.com resources: - imagerepositories verbs: - create - delete - get - list - patch - update - watch - apiGroups: - appstudio.redhat.com resources: - imagerepositories/status verbs: - get --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole metadata: labels: app.kubernetes.io/managed-by: kustomize app.kubernetes.io/name: image-controller name: image-controller-imagerepository-viewer-role rules: - apiGroups: - appstudio.redhat.com resources: - imagerepositories verbs: - get - list - watch - apiGroups: - appstudio.redhat.com resources: - imagerepositories/status verbs: - get --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole metadata: name: image-controller-manager-role rules: - apiGroups: - "" resources: - configmaps - namespaces verbs: - get - list - watch - apiGroups: - "" resources: - secrets verbs: - create - delete - get - list - patch - update - watch - apiGroups: - "" resources: - serviceaccounts verbs: - create - get - list - patch - update - watch - apiGroups: - appstudio.redhat.com resources: - applications verbs: - get - list - update - watch - apiGroups: - appstudio.redhat.com resources: - applications/finalizers - imagerepositories/finalizers verbs: - update - apiGroups: - appstudio.redhat.com resources: - components verbs: - get - list - patch - update - watch - apiGroups: - appstudio.redhat.com resources: - imagerepositories verbs: - create - delete - get - list - patch - update - watch - apiGroups: - appstudio.redhat.com resources: - imagerepositories/status verbs: - get - patch - update --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole metadata: name: image-controller-metrics-auth-role rules: - apiGroups: - authentication.k8s.io resources: - tokenreviews verbs: - create - apiGroups: - authorization.k8s.io resources: - subjectaccessreviews verbs: - create --- apiVersion: rbac.authorization.k8s.io/v1 kind: RoleBinding metadata: labels: app.kubernetes.io/managed-by: kustomize app.kubernetes.io/name: image-controller name: image-controller-leader-election-rolebinding namespace: image-controller roleRef: apiGroup: rbac.authorization.k8s.io kind: Role name: image-controller-leader-election-role subjects: - kind: ServiceAccount name: image-controller-controller-manager namespace: image-controller --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRoleBinding metadata: labels: app.kubernetes.io/managed-by: kustomize app.kubernetes.io/name: image-controller name: image-controller-manager-rolebinding roleRef: apiGroup: rbac.authorization.k8s.io kind: ClusterRole name: image-controller-manager-role subjects: - kind: ServiceAccount name: image-controller-controller-manager namespace: image-controller --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRoleBinding metadata: name: image-controller-metrics-auth-rolebinding roleRef: apiGroup: rbac.authorization.k8s.io kind: ClusterRole name: image-controller-metrics-auth-role subjects: - kind: ServiceAccount name: image-controller-controller-manager namespace: image-controller --- apiVersion: v1 data: prune_images.py: | import argparse import itertools import json import logging import os import re import time from collections.abc import Iterator from http.client import HTTPResponse from typing import Any, Dict, List from urllib.error import HTTPError from urllib.parse import urlencode from urllib.request import Request, urlopen logging.basicConfig( format="%(asctime)s - %(levelname)s - %(message)s", level=logging.INFO ) LOGGER = logging.getLogger(__name__) QUAY_API_URL = "https://quay.io/api/v1" processed_repos_counter = itertools.count() auth_errors_limit = 1000 ImageRepo = Dict[str, Any] def get_quay_tags(quay_token: str, namespace: str, name: str) -> Dict[str, Any]: next_page = None resp: HTTPResponse auth_errors_counter = 0 all_tags = {} while True: query_args = {"limit": 100, "onlyActiveTags": True} if next_page is not None: query_args["page"] = next_page api_url = f"{QUAY_API_URL}/repository/{namespace}/{name}/tag/?{urlencode(query_args)}" request = Request(api_url, headers={ "Authorization": f"Bearer {quay_token}", }) try: with urlopen(request) as resp: if resp.status != 200: raise RuntimeError(resp.reason) json_data = json.loads(resp.read()) auth_errors_counter = 0 except HTTPError as ex: if ex.status == 401: if auth_errors_counter > auth_errors_limit: raise(ex) auth_errors_counter += 1 LOGGER.info("Auth error, will retry") time.sleep(1) continue auth_errors_counter = 0 if ex.status == 404: LOGGER.info("Repository doesn't exist anymore %s/%s", namespace, name) return {} if ex.status == 502 or ex.status == 504: LOGGER.info("Gateway error, will retry") time.sleep(1) continue LOGGER.info("Http %s error, will retry", ex.status) time.sleep(1) continue except json.JSONDecodeError: auth_errors_counter = 0 LOGGER.info("Json decoder error, will retry") continue tags = json_data.get("tags", []) # store only name & manifest_digest keys, as others aren't used and take memory all_tags.update({tag["name"]: tag["manifest_digest"] for tag in tags}) if not tags: LOGGER.debug("No tags found.") break page = json_data.get("page", None) additional = json_data.get("has_additional", False) if additional: next_page = page + 1 else: break return all_tags def delete_image_tag(quay_token: str, namespace: str, name: str, tag: str) -> None: api_url = f"{QUAY_API_URL}/repository/{namespace}/{name}/tag/{tag}" request = Request(api_url, method="DELETE", headers={ "Authorization": f"Bearer {quay_token}", }) resp: HTTPResponse auth_errors_counter = 0 while True: try: with urlopen(request) as resp: if resp.status != 200 and resp.status != 204: raise RuntimeError(resp.reason) except HTTPError as ex: if ex.status == 401: if auth_errors_counter > auth_errors_limit: raise(ex) auth_errors_counter += 1 LOGGER.info("Auth error, will retry") time.sleep(1) continue auth_errors_counter = 0 if ex.status == 502 or ex.status == 504: LOGGER.info("Gateway error, will retry") time.sleep(1) continue # ignore if not found if ex.status != 404: LOGGER.info("Http %s error, will retry", ex.status) time.sleep(1) continue break def manifest_exists(quay_token: str, namespace: str, name: str, manifest: str) -> bool: api_url = f"{QUAY_API_URL}/repository/{namespace}/{name}/manifest/{manifest}" request = Request(api_url, headers={ "Authorization": f"Bearer {quay_token}", }) resp: HTTPResponse manifest_exists = True auth_errors_counter = 0 while True: try: with urlopen(request) as resp: if resp.status != 200 and resp.status != 204: raise RuntimeError(resp.reason) except HTTPError as ex: if ex.status == 401: if auth_errors_counter > auth_errors_limit: raise(ex) auth_errors_counter += 1 LOGGER.info("Auth error, will retry") time.sleep(1) continue auth_errors_counter = 0 if ex.status == 502 or ex.status == 504: LOGGER.info("Gateway error, will retry") time.sleep(1) continue if ex.status == 404: manifest_exists = False else: LOGGER.info("Http %s error, will retry", ex.status) time.sleep(1) continue break return manifest_exists def remove_tags(tags_map: Dict[str, Any], quay_token: str, namespace: str, name: str, dry_run: bool = False) -> None: image_digests = set(tags_map.values()) # sha without any extension is clair report tag_regex = re.compile(r"^sha256-([0-9a-f]+)(\.sbom|\.att|\.src|\.sig|\.dockerfile)?$") manifests_checked = {} for tag_name in tags_map: # attestation or sbom image if (match := tag_regex.match(tag_name)) is not None: if f"sha256:{match.group(1)}" not in image_digests: # verify that manifest really doesn't exist, because if tag was removed, it won't be in tag list, but may still be in the registry manifest_existence = manifests_checked.get(f"sha256:{match.group(1)}") if manifest_existence is None: manifest_existence = manifest_exists(quay_token, namespace, name, f"sha256:{match.group(1)}") manifests_checked[f"sha256:{match.group(1)}"] = manifest_existence if not manifest_existence: if dry_run: LOGGER.info("Tag %s from %s/%s should be removed", tag_name, namespace, name) else: LOGGER.info("Removing tag %s from %s/%s", tag_name, namespace, name) delete_image_tag(quay_token, namespace, name, tag_name) elif tag_name.endswith(".src"): to_delete = False binary_tag = tag_name.removesuffix(".src") if binary_tag not in tags_map: to_delete = True else: manifest_digest = tags_map[binary_tag] new_src_tag = f"{manifest_digest.replace(':', '-')}.src" to_delete = new_src_tag in tags_map if to_delete: LOGGER.info("Removing deprecated tag %s", tag_name) delete_image_tag(quay_token, namespace, name, tag_name) else: LOGGER.debug("%s is not in a known type to be deleted.", tag_name) def process_repositories(repos: List[ImageRepo], quay_token: str, dry_run: bool = False) -> None: for repo in repos: namespace = repo["namespace"] name = repo["name"] # skip huge repository for which we can't get all tags if name == "ocp-art-tenant/art-images": continue LOGGER.info("Processing repository %s: %s/%s", next(processed_repos_counter), namespace, name) all_tags = get_quay_tags(quay_token, namespace, name) if not all_tags: continue remove_tags(all_tags, quay_token, namespace, name, dry_run=dry_run) def fetch_image_repos(access_token: str, namespace: str) -> Iterator[List[ImageRepo]]: next_page = None resp: HTTPResponse auth_errors_counter = 0 while True: query_args = {"namespace": namespace} if next_page is not None: query_args["next_page"] = next_page api_url = f"{QUAY_API_URL}/repository?{urlencode(query_args)}" request = Request(api_url, headers={ "Authorization": f"Bearer {access_token}", }) try: with urlopen(request) as resp: if resp.status != 200: raise RuntimeError(resp.reason) json_data = json.loads(resp.read()) auth_errors_counter = 0 except HTTPError as ex: if ex.status == 401: if auth_errors_counter > auth_errors_limit: raise(ex) auth_errors_counter += 1 LOGGER.info("Auth error, will retry") time.sleep(1) continue auth_errors_counter = 0 if ex.status == 502 or ex.status == 504: LOGGER.info("Gateway error, will retry") time.sleep(1) continue LOGGER.info("Http %s error, will retry", ex.status) time.sleep(1) continue except json.JSONDecodeError: auth_errors_counter = 0 LOGGER.info("Json decoder error, will retry") continue repos = json_data.get("repositories", []) if not repos: LOGGER.debug("No image repository is found.") break yield repos if (next_page := json_data.get("next_page", None)) is None: break def main(): token = os.getenv("QUAY_TOKEN") if not token: raise ValueError("The token required for access to Quay API is missing!") args = parse_args() for image_repos in fetch_image_repos(token, args.namespace): process_repositories(image_repos, token, dry_run=args.dry_run) def parse_args(): parser = argparse.ArgumentParser() parser.add_argument("--namespace", required=True) parser.add_argument("--dry-run", action="store_true") args = parser.parse_args() return args if __name__ == "__main__": main() kind: ConfigMap metadata: name: image-controller-image-pruner-configmap-hgm7kmgb6k namespace: image-controller --- apiVersion: v1 data: reset_notifications.py: | import argparse import itertools import json import logging import os from collections.abc import Iterator from http.client import HTTPResponse from typing import Any, Dict, List from urllib.error import HTTPError from urllib.parse import urlencode from urllib.request import Request, urlopen logging.basicConfig( format="%(asctime)s - %(levelname)s - %(message)s", level=logging.INFO ) LOGGER = logging.getLogger(__name__) QUAY_API_URL = "https://quay.io/api/v1" processed_repos_counter = itertools.count() ImageRepo = Dict[str, Any] RepoNotification = Dict[str, Any] def get_quay_notifications( quay_token: str, namespace: str, name: str ) -> List[RepoNotification]: """ Get all notifications for a repository Quay API response format: { "notifications": [ { "uuid": "string", "number_of_failures": int, "title": "string", ... }, ... ] } """ resp: HTTPResponse api_url = f"{QUAY_API_URL}/repository/{namespace}/{name}/notification/" request = Request( api_url, headers={ "Authorization": f"Bearer {quay_token}", }, ) try: with urlopen(request) as resp: if resp.status != 200: # do not fail the job if we can't fetch notifications # for single repository LOGGER.warning("Failed to fetch notifications for %s/%s", namespace, name) json_data = {} else: json_data = json.loads(resp.read()) except HTTPError: LOGGER.warning("Failed to fetch notifications for %s/%s", namespace, name) json_data = {} return json_data.get("notifications", []) def reset_notification(uuid: str, quay_token: str, namespace: str, name: str) -> None: """Reset notification by notification uuid""" api_url = f"{QUAY_API_URL}/repository/{namespace}/{name}/notification/{uuid}" request = Request( api_url, method="POST", headers={ "Authorization": f"Bearer {quay_token}", }, ) resp: HTTPResponse try: with urlopen(request) as resp: # The actual API response is 204 for notification reset # There is bug in Quay Swagger docs generator # claiming all POST request return 201 if resp.status not in (201, 204): # do not fail the job if we can't reset notification LOGGER.warning( "Failed to reset notification %s from %s/%s", uuid, namespace, name, ) except HTTPError as ex: # Quay API returns 400 if notification is not found # filter out when this is the case rsp_message = json.loads(ex.read()).get("detail", "") if ex.status == 400 and rsp_message.startswith( "No repository notification found" ): LOGGER.info( "Notification %s from %s/%s was not found", uuid, namespace, name ) else: LOGGER.warning( "Failed to reset notification %s from %s/%s with error: %s", uuid, namespace, name, rsp_message, ) def process_repositories( repos: List[ImageRepo], quay_token: str, dry_run: bool = False ) -> None: """Process all repositories and reset notifications if needed""" for repo in repos: namespace = repo["namespace"] name = repo["name"] LOGGER.info( "Processing repository %s: %s/%s", next(processed_repos_counter), namespace, name, ) all_notifications = get_quay_notifications(quay_token, namespace, name) if not all_notifications: continue for notification in all_notifications: notification_title = notification.get("title", "") uuid = notification["uuid"] if notification.get("number_of_failures", 0) > 0: if dry_run: LOGGER.info( "Notification %s with title %s from %s/%s should be reset", uuid, notification_title, namespace, name, ) else: reset_notification(uuid, quay_token, namespace, name) LOGGER.info( "Notification %s with title %s from %s/%s was reset", uuid, notification_title, namespace, name, ) else: LOGGER.info( "Notification %s with title %s from %s/%s has no failures", uuid, notification_title, namespace, name, ) def fetch_image_repos(access_token: str, namespace: str) -> Iterator[List[ImageRepo]]: """Fetch all image repositories for a given namespace""" next_page = None resp: HTTPResponse retry = 0 while True: query_args = {"namespace": namespace} if next_page is not None: query_args["next_page"] = next_page api_url = f"{QUAY_API_URL}/repository?{urlencode(query_args)}" request = Request( api_url, headers={ "Authorization": f"Bearer {access_token}", }, ) try: with urlopen(request) as resp: if resp.status == 200: json_data = json.loads(resp.read()) else: # this will raise error for 2xx other than 200 # urlopen raises HTTPError for all non 2xx responses raise HTTPError(resp.reason) except HTTPError as ex: # retry 5 times before giving up if retry < 5: retry += 1 continue else: LOGGER.error( "Unable to fetch repositories for namespace %s", namespace, ) raise RuntimeError(ex) repos = json_data.get("repositories", []) if not repos: LOGGER.debug("No image repository is found.") break yield repos if (next_page := json_data.get("next_page", None)) is None: break def main(): token = os.getenv("QUAY_TOKEN") if not token: raise ValueError("The token required for access to Quay API is missing!") args = parse_args() for image_repos in fetch_image_repos(token, args.namespace): process_repositories(image_repos, token, dry_run=args.dry_run) def parse_args(): parser = argparse.ArgumentParser() parser.add_argument("--namespace", required=True) parser.add_argument("--dry-run", action="store_true") args = parser.parse_args() return args if __name__ == "__main__": main() kind: ConfigMap metadata: name: image-controller-notification-resetter-configmap-tfm9h79698 namespace: image-controller --- apiVersion: v1 kind: Service metadata: labels: app.kubernetes.io/managed-by: kustomize app.kubernetes.io/name: image-controller control-plane: controller-manager name: image-controller-controller-manager-metrics-service namespace: image-controller spec: ports: - name: https port: 8443 protocol: TCP targetPort: 8443 selector: control-plane: controller-manager --- apiVersion: apps/v1 kind: Deployment metadata: labels: app.kubernetes.io/managed-by: kustomize app.kubernetes.io/name: image-controller control-plane: controller-manager name: image-controller-controller-manager namespace: image-controller spec: progressDeadlineSeconds: 2147483647 replicas: 1 selector: matchLabels: control-plane: controller-manager template: metadata: annotations: kubectl.kubernetes.io/default-container: manager labels: control-plane: controller-manager spec: containers: - args: - --metrics-bind-address=:8443 - --leader-elect=false - --health-probe-bind-address=:8081 command: - /manager image: quay.io/konflux-ci/image-controller:2b1c320886f9b18b3e882634a8d1e64c8812fc93 livenessProbe: httpGet: path: /healthz port: 8081 initialDelaySeconds: 15 periodSeconds: 20 name: manager ports: - containerPort: 8081 name: probes protocol: TCP readinessProbe: httpGet: path: /readyz port: 8081 initialDelaySeconds: 5 periodSeconds: 10 resources: limits: cpu: 500m memory: 128Mi requests: cpu: 10m memory: 64Mi securityContext: allowPrivilegeEscalation: false capabilities: drop: - ALL readOnlyRootFilesystem: true volumeMounts: - mountPath: /etc/ssl/certs/quay-ca name: quay-ca-bundle readOnly: true - mountPath: /workspace name: quaytoken readOnly: true securityContext: runAsNonRoot: true seccompProfile: type: RuntimeDefault serviceAccountName: image-controller-controller-manager terminationGracePeriodSeconds: 10 volumes: - configMap: name: quay-ca-bundle optional: true name: quay-ca-bundle - name: quaytoken secret: secretName: quaytoken --- apiVersion: batch/v1 kind: CronJob metadata: name: image-controller-image-pruner-cronjob namespace: image-controller spec: concurrencyPolicy: Forbid jobTemplate: spec: template: spec: containers: - command: - /bin/bash - -c - python /image-pruner/prune_images.py --namespace $(NAMESPACE) env: - name: QUAY_TOKEN valueFrom: secretKeyRef: key: quaytoken name: image-pruner-token - name: NAMESPACE valueFrom: secretKeyRef: key: organization name: image-pruner-token image: registry.access.redhat.com/ubi8/python-39:1-201.1729679484 imagePullPolicy: IfNotPresent name: image-pruner resources: limits: cpu: 500m memory: 512Mi requests: cpu: 150m memory: 128Mi securityContext: readOnlyRootFilesystem: true volumeMounts: - mountPath: /image-pruner name: image-pruner-volume restartPolicy: OnFailure securityContext: runAsNonRoot: true volumes: - configMap: name: image-controller-image-pruner-configmap-hgm7kmgb6k name: image-pruner-volume - name: image-pruner-token secret: secretName: image-pruner-token schedule: 0 0 * * * --- apiVersion: batch/v1 kind: CronJob metadata: name: image-controller-notification-resetter-cronjob namespace: image-controller spec: concurrencyPolicy: Forbid jobTemplate: spec: template: spec: containers: - command: - /bin/bash - -c - python /notification-resetter/reset_notifications.py --namespace $(QUAY_NAMESPACE) env: - name: QUAY_TOKEN valueFrom: secretKeyRef: key: quaytoken name: quaytoken - name: QUAY_NAMESPACE valueFrom: secretKeyRef: key: organization name: quaytoken image: registry.redhat.io/rhel9/python-312:9.5-1739797362 imagePullPolicy: IfNotPresent name: notification-resetter resources: limits: cpu: 500m memory: 512Mi requests: cpu: 150m memory: 128Mi securityContext: readOnlyRootFilesystem: true volumeMounts: - mountPath: /notification-resetter name: notification-resetter-volume restartPolicy: OnFailure securityContext: runAsNonRoot: true volumes: - configMap: name: image-controller-notification-resetter-configmap-tfm9h79698 name: notification-resetter-volume - name: quaytoken secret: secretName: quaytoken schedule: 0 3 * * *