oncall-engine/tools/migrators/lib/grafana/service_model_client.py
Bob Cotton 0e1dcd2e71
Service to service model migration (#5485)
# What this PR does

Adds Service and Business Service migration to the Pager Duty Migrator.

To test, in addition to the OnCall configs, you need to crate a Grafana
Service Account with `Admin` permission and generate a token. You will
set `GRAFANA_SERVICE_ACCOUNT_URL`, per the README, to
`https://<namespace>:<token>@<server>` The namespace is the stack id, in
the format of `stacks-<stack id>`

Service migration is configurable, filterable, and idempotent.

## Which issue(s) this PR closes

Related to [issue link here]

<!--
*Note*: If you want the issue to be auto-closed once the PR is merged,
change "Related to" to "Closes" in the line above.
If you have more than one GitHub issue that this PR closes, be sure to
preface
each issue link with a [closing
keyword](https://docs.github.com/en/get-started/writing-on-github/working-with-advanced-formatting/using-keywords-in-issues-and-pull-requests#linking-a-pull-request-to-an-issue).
This ensures that the issue(s) are auto-closed once the PR has been
merged.
-->

## Checklist

- [x] Unit, integration, and e2e (if applicable) tests updated
- [x] Documentation added (or `pr:no public docs` PR label added if not
required)
- [ ] Added the relevant release notes label (see labels prefixed w/
`release:`). These labels dictate how your PR will
    show up in the autogenerated release notes.

---------

Co-authored-by: Joey Orlando <joey.orlando@grafana.com>
Co-authored-by: GitHub Actions <actions@github.com>
Co-authored-by: grafana-irm-app[bot] <165293418+grafana-irm-app[bot]@users.noreply.github.com>
Co-authored-by: Joey Orlando <joseph.t.orlando@gmail.com>
2025-03-15 21:07:59 -04:00

194 lines
6.5 KiB
Python

from urllib.parse import urlparse
import kubernetes
from kubernetes import client
from lib.base_config import GRAFANA_SERVICE_ACCOUNT_URL
SERVICE_MODEL_API_GROUP = "servicemodel.ext.grafana.com"
SERVICE_MODEL_API_VERSION = "v1alpha1"
class ServiceModelClient:
"""
Client for interacting with Grafana's Service Model API using the Kubernetes client.
This uses the k8s API to interact with the service model which is implemented
as a Kubernetes ApiServer embedded within Grafana.
"""
@staticmethod
def parse_k8s_url(url: str) -> tuple:
"""
Parse a kubernetes URL of the format https://<namespace>:<token>@<server>
Returns tuple of (server_url, namespace, token)
"""
parsed = urlparse(url)
if not all([parsed.scheme, parsed.netloc]):
raise ValueError(
"Invalid URL format. Expected: https://<namespace>:<token>@<server>"
)
# Split username (namespace) and password (token)
if "@" not in parsed.netloc:
raise ValueError(
"URL must contain credentials in the format namespace:token@server"
)
auth, server = parsed.netloc.rsplit("@", 1)
if ":" not in auth:
raise ValueError("Credentials must be in the format namespace:token")
namespace, token = auth.split(":", 1)
# Reconstruct server URL with scheme
server_url = f"{parsed.scheme}://{server}{parsed.path}"
return server_url, namespace, token
def __init__(self):
"""
Initialize the ServiceModelClient.
Configures the client using a URL-based format or falls back to legacy configuration.
"""
if GRAFANA_SERVICE_ACCOUNT_URL:
try:
server_url, namespace, token = self.parse_k8s_url(
GRAFANA_SERVICE_ACCOUNT_URL
)
# Configure client using parsed parameters
configuration = client.Configuration()
configuration.host = server_url
configuration.api_key = {"authorization": f"Bearer {token}"}
# configuration.verify_ssl = False # Note: In production, you should handle SSL verification properly
# Set the default namespace
self.default_namespace = namespace
# Create API client with custom configuration
client.Configuration.set_default(configuration)
self.api_client = client.ApiClient(configuration)
except ValueError as e:
raise ValueError(
f"Failed to parse GRAFANA_SERVICE_ACCOUNT_URL: {str(e)}"
)
else:
raise ValueError(
"Unable to configure Kubernetes client. Please set: "
"GRAFANA_SERVICE_ACCOUNT_URL (format: https://<namespace>:<token>@<server>) "
)
# Base API group and version for service model resources
self.api_group = SERVICE_MODEL_API_GROUP
self.api_version = SERVICE_MODEL_API_VERSION
# Initialize the CustomObjectsApi for interacting with custom resources
self.custom_api = client.CustomObjectsApi(self.api_client)
def get_components(self, namespace=None):
"""
Get all Component resources from the service model.
Args:
namespace: The namespace to list components from. Defaults to the namespace from the URL.
Returns:
List of Component resources.
"""
namespace = namespace or self.default_namespace
return self.custom_api.list_namespaced_custom_object(
group=self.api_group,
version=self.api_version,
namespace=namespace,
plural="components",
)
def get_component(self, name, namespace=None):
"""
Get a specific Component resource by name.
Args:
name: The name of the component.
namespace: The namespace of the component.
Returns:
The Component resource if found, None otherwise.
"""
namespace = namespace or self.default_namespace
try:
return self.custom_api.get_namespaced_custom_object(
group=self.api_group,
version=self.api_version,
namespace=namespace,
plural="components",
name=name,
)
except kubernetes.client.rest.ApiException as e:
if e.status == 404:
return None
raise
def create_component(self, component_data, namespace=None):
"""
Create a new Component resource.
Args:
component_data: The Component resource data.
namespace: The namespace to create the component in.
Returns:
The created Component resource.
"""
namespace = namespace or self.default_namespace
return self.custom_api.create_namespaced_custom_object(
group=self.api_group,
version=self.api_version,
namespace=namespace,
plural="components",
body=component_data,
)
def update_component(self, name, component_data, namespace=None):
"""
Update an existing Component resource.
Args:
name: The name of the component to update.
component_data: The updated Component resource data.
namespace: The namespace of the component.
Returns:
The updated Component resource.
"""
namespace = namespace or self.default_namespace
return self.custom_api.replace_namespaced_custom_object(
group=self.api_group,
version=self.api_version,
namespace=namespace,
plural="components",
name=name,
body=component_data,
)
def patch_component(self, name, patch_data, namespace=None):
"""
Patch an existing Component resource.
Args:
name: The name of the component to patch.
patch_data: The patch data to apply.
namespace: The namespace of the component.
Returns:
The patched Component resource.
"""
namespace = namespace or self.default_namespace
return self.custom_api.patch_namespaced_custom_object(
group=self.api_group,
version=self.api_version,
namespace=namespace,
plural="components",
name=name,
body=patch_data,
)