package client import ( "context" "fmt" appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" k8sresource "k8s.io/apimachinery/pkg/api/resource" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/client-go/dynamic" "k8s.io/client-go/kubernetes" "k8s.io/client-go/rest" "k8s.io/client-go/tools/clientcmd" ) var environmentGVR = schema.GroupVersionResource{ Group: "fission.io", Version: "v1", Resource: "environments", } var packageGVR = schema.GroupVersionResource{ Group: "fission.io", Version: "v1", Resource: "packages", } var functionGVR = schema.GroupVersionResource{ Group: "fission.io", Version: "v1", Resource: "functions", } var httpTriggerGVR = schema.GroupVersionResource{ Group: "fission.io", Version: "v1", Resource: "httptriggers", } var timeTriggerGVR = schema.GroupVersionResource{ Group: "fission.io", Version: "v1", Resource: "timetriggers", } var iotDeviceGVR = schema.GroupVersionResource{ Group: "iot.kube5s.ru", Version: "v1alpha1", Resource: "iotdevices", } const ( sqsConsumerImage = "naeel/sqs-consumer:v1.0" sqsDefaultEndpoint = "http://shared-sqs.shared-sqs.svc.cluster.local:4100" fissionRouterBase = "http://router.fission.svc.cluster.local" mqManagedByLabel = "app.kubernetes.io/managed-by" mqManagedByVal = "fission-terraform" mqComponentLabel = "component" mqComponentVal = "mq-trigger" ) // Client хранит клиентов Kubernetes API для работы с CRD Fission. type Client struct { DynClient dynamic.Interface K8sClient kubernetes.Interface Namespace string } // New создает Kubernetes clients из kubeconfig/context. func New(kubeconfigPath, kubeContext, namespace string) (*Client, error) { cfg, err := buildConfig(kubeconfigPath, kubeContext) if err != nil { return nil, err } dynClient, err := dynamic.NewForConfig(cfg) if err != nil { return nil, fmt.Errorf("create dynamic client: %w", err) } k8sClient, err := kubernetes.NewForConfig(cfg) if err != nil { return nil, fmt.Errorf("create kubernetes client: %w", err) } return &Client{ DynClient: dynClient, K8sClient: k8sClient, Namespace: namespace, }, nil } func buildConfig(kubeconfigPath, kubeContext string) (*rest.Config, error) { loadingRules := &clientcmd.ClientConfigLoadingRules{} if kubeconfigPath != "" { loadingRules.ExplicitPath = kubeconfigPath } overrides := &clientcmd.ConfigOverrides{} if kubeContext != "" { overrides.CurrentContext = kubeContext } clientCfg := clientcmd.NewNonInteractiveDeferredLoadingClientConfig(loadingRules, overrides) cfg, err := clientCfg.ClientConfig() if err != nil { return nil, fmt.Errorf("build kube config: %w", err) } return cfg, nil } // CreateEnvironment создает объект Environment в заданном namespace. func (c *Client) CreateEnvironment(ctx context.Context, environment *unstructured.Unstructured) (*unstructured.Unstructured, error) { createdEnvironment, err := c.DynClient.Resource(environmentGVR).Namespace(environment.GetNamespace()).Create(ctx, environment, metav1.CreateOptions{}) if err != nil { return nil, fmt.Errorf("create environment %q: %w", environment.GetName(), err) } return createdEnvironment, nil } // GetEnvironment получает объект Environment по имени. func (c *Client) GetEnvironment(ctx context.Context, namespace, name string) (*unstructured.Unstructured, error) { environment, err := c.DynClient.Resource(environmentGVR).Namespace(namespace).Get(ctx, name, metav1.GetOptions{}) if err != nil { return nil, fmt.Errorf("get environment %q: %w", name, err) } return environment, nil } // UpdateEnvironment обновляет существующий объект Environment. func (c *Client) UpdateEnvironment(ctx context.Context, environment *unstructured.Unstructured) (*unstructured.Unstructured, error) { updatedEnvironment, err := c.DynClient.Resource(environmentGVR).Namespace(environment.GetNamespace()).Update(ctx, environment, metav1.UpdateOptions{}) if err != nil { return nil, fmt.Errorf("update environment %q: %w", environment.GetName(), err) } return updatedEnvironment, nil } // DeleteEnvironment удаляет объект Environment по имени. func (c *Client) DeleteEnvironment(ctx context.Context, namespace, name string) error { err := c.DynClient.Resource(environmentGVR).Namespace(namespace).Delete(ctx, name, metav1.DeleteOptions{}) if err != nil { return fmt.Errorf("delete environment %q: %w", name, err) } return nil } // IsNotFound возвращает true для ошибок Kubernetes типа NotFound. func IsNotFound(err error) bool { return apierrors.IsNotFound(err) } // CreatePackage создает объект Package в заданном namespace. func (c *Client) CreatePackage(ctx context.Context, pkg *unstructured.Unstructured) (*unstructured.Unstructured, error) { createdPackage, err := c.DynClient.Resource(packageGVR).Namespace(pkg.GetNamespace()).Create(ctx, pkg, metav1.CreateOptions{}) if err != nil { return nil, fmt.Errorf("create package %q: %w", pkg.GetName(), err) } return createdPackage, nil } // GetPackage получает объект Package по имени. func (c *Client) GetPackage(ctx context.Context, namespace, name string) (*unstructured.Unstructured, error) { pkg, err := c.DynClient.Resource(packageGVR).Namespace(namespace).Get(ctx, name, metav1.GetOptions{}) if err != nil { return nil, fmt.Errorf("get package %q: %w", name, err) } return pkg, nil } // UpdatePackage обновляет существующий объект Package. func (c *Client) UpdatePackage(ctx context.Context, pkg *unstructured.Unstructured) (*unstructured.Unstructured, error) { updatedPackage, err := c.DynClient.Resource(packageGVR).Namespace(pkg.GetNamespace()).Update(ctx, pkg, metav1.UpdateOptions{}) if err != nil { return nil, fmt.Errorf("update package %q: %w", pkg.GetName(), err) } return updatedPackage, nil } // DeletePackage удаляет объект Package по имени. func (c *Client) DeletePackage(ctx context.Context, namespace, name string) error { err := c.DynClient.Resource(packageGVR).Namespace(namespace).Delete(ctx, name, metav1.DeleteOptions{}) if err != nil { return fmt.Errorf("delete package %q: %w", name, err) } return nil } // CreateFunction создает объект Function в заданном namespace. func (c *Client) CreateFunction(ctx context.Context, fn *unstructured.Unstructured) (*unstructured.Unstructured, error) { createdFunction, err := c.DynClient.Resource(functionGVR).Namespace(fn.GetNamespace()).Create(ctx, fn, metav1.CreateOptions{}) if err != nil { return nil, fmt.Errorf("create function %q: %w", fn.GetName(), err) } return createdFunction, nil } // GetFunction получает объект Function по имени. func (c *Client) GetFunction(ctx context.Context, namespace, name string) (*unstructured.Unstructured, error) { fn, err := c.DynClient.Resource(functionGVR).Namespace(namespace).Get(ctx, name, metav1.GetOptions{}) if err != nil { return nil, fmt.Errorf("get function %q: %w", name, err) } return fn, nil } // UpdateFunction обновляет существующий объект Function. func (c *Client) UpdateFunction(ctx context.Context, fn *unstructured.Unstructured) (*unstructured.Unstructured, error) { updatedFunction, err := c.DynClient.Resource(functionGVR).Namespace(fn.GetNamespace()).Update(ctx, fn, metav1.UpdateOptions{}) if err != nil { return nil, fmt.Errorf("update function %q: %w", fn.GetName(), err) } return updatedFunction, nil } // DeleteFunction удаляет объект Function по имени. func (c *Client) DeleteFunction(ctx context.Context, namespace, name string) error { err := c.DynClient.Resource(functionGVR).Namespace(namespace).Delete(ctx, name, metav1.DeleteOptions{}) if err != nil { return fmt.Errorf("delete function %q: %w", name, err) } return nil } // CreateHTTPTrigger создает объект HTTPTrigger в заданном namespace. func (c *Client) CreateHTTPTrigger(ctx context.Context, trigger *unstructured.Unstructured) (*unstructured.Unstructured, error) { createdTrigger, err := c.DynClient.Resource(httpTriggerGVR).Namespace(trigger.GetNamespace()).Create(ctx, trigger, metav1.CreateOptions{}) if err != nil { return nil, fmt.Errorf("create http trigger %q: %w", trigger.GetName(), err) } return createdTrigger, nil } // GetHTTPTrigger получает объект HTTPTrigger по имени. func (c *Client) GetHTTPTrigger(ctx context.Context, namespace, name string) (*unstructured.Unstructured, error) { trigger, err := c.DynClient.Resource(httpTriggerGVR).Namespace(namespace).Get(ctx, name, metav1.GetOptions{}) if err != nil { return nil, fmt.Errorf("get http trigger %q: %w", name, err) } return trigger, nil } // UpdateHTTPTrigger обновляет существующий объект HTTPTrigger. func (c *Client) UpdateHTTPTrigger(ctx context.Context, trigger *unstructured.Unstructured) (*unstructured.Unstructured, error) { updatedTrigger, err := c.DynClient.Resource(httpTriggerGVR).Namespace(trigger.GetNamespace()).Update(ctx, trigger, metav1.UpdateOptions{}) if err != nil { return nil, fmt.Errorf("update http trigger %q: %w", trigger.GetName(), err) } return updatedTrigger, nil } // DeleteHTTPTrigger удаляет объект HTTPTrigger по имени. func (c *Client) DeleteHTTPTrigger(ctx context.Context, namespace, name string) error { err := c.DynClient.Resource(httpTriggerGVR).Namespace(namespace).Delete(ctx, name, metav1.DeleteOptions{}) if err != nil { return fmt.Errorf("delete http trigger %q: %w", name, err) } return nil } // ── MQ Trigger (K8s Deployment + Secret) ──────────────────────────── func mqSecretName(name string) string { return "sqs-mq-" + name } func mqDeployName(name string) string { return "mq-" + name } // MQTriggerInfo — данные MQ trigger, считанные из K8s. type MQTriggerInfo struct { Function string Queue string SQSEndpoint string DeployUID string } // CreateMQTrigger создаёт Secret + Deployment для SQS consumer. func (c *Client) CreateMQTrigger(ctx context.Context, name, ns, functionName, queue, sqsEndpoint, accessKey, secretKey string) (*MQTriggerInfo, error) { if sqsEndpoint == "" { sqsEndpoint = sqsDefaultEndpoint } functionURL := fissionRouterBase + "/" + functionName secretName := mqSecretName(name) deployName := mqDeployName(name) labels := map[string]string{ mqManagedByLabel: mqManagedByVal, mqComponentLabel: mqComponentVal, "mq-trigger-name": name, } secret := &corev1.Secret{ ObjectMeta: metav1.ObjectMeta{Name: secretName, Namespace: ns, Labels: labels}, StringData: map[string]string{ "SQS_ACCESS_KEY": accessKey, "SQS_SECRET_KEY": secretKey, "SQS_ENDPOINT": sqsEndpoint, }, } if _, err := c.K8sClient.CoreV1().Secrets(ns).Create(ctx, secret, metav1.CreateOptions{}); err != nil { return nil, fmt.Errorf("create mq secret %q: %w", secretName, err) } replicas := int32(1) deploy := &appsv1.Deployment{ ObjectMeta: metav1.ObjectMeta{ Name: deployName, Namespace: ns, Labels: labels, Annotations: map[string]string{ "fission-terraform/mq-trigger-name": name, "fission-terraform/function": functionName, "fission-terraform/queue": queue, "fission-terraform/sqs-endpoint": sqsEndpoint, }, }, Spec: appsv1.DeploymentSpec{ Replicas: &replicas, Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"mq-trigger-name": name}}, Template: corev1.PodTemplateSpec{ ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{ "mq-trigger-name": name, mqComponentLabel: mqComponentVal, }}, Spec: corev1.PodSpec{ Containers: []corev1.Container{{ Name: "sqs-consumer", Image: sqsConsumerImage, ImagePullPolicy: corev1.PullAlways, Env: []corev1.EnvVar{ {Name: "SQS_QUEUE_NAME", Value: queue}, {Name: "SQS_REGION", Value: "us-east-1"}, {Name: "FUNCTION_URL", Value: functionURL}, {Name: "POLL_INTERVAL", Value: "5"}, {Name: "MAX_MESSAGES", Value: "1"}, {Name: "MAX_RETRIES", Value: "3"}, }, EnvFrom: []corev1.EnvFromSource{{ SecretRef: &corev1.SecretEnvSource{ LocalObjectReference: corev1.LocalObjectReference{Name: secretName}, }, }}, Resources: corev1.ResourceRequirements{ Limits: corev1.ResourceList{ corev1.ResourceCPU: k8sresource.MustParse("50m"), corev1.ResourceMemory: k8sresource.MustParse("32Mi"), }, Requests: corev1.ResourceList{ corev1.ResourceCPU: k8sresource.MustParse("10m"), corev1.ResourceMemory: k8sresource.MustParse("16Mi"), }, }, }}, }, }, }, } created, err := c.K8sClient.AppsV1().Deployments(ns).Create(ctx, deploy, metav1.CreateOptions{}) if err != nil { _ = c.K8sClient.CoreV1().Secrets(ns).Delete(ctx, secretName, metav1.DeleteOptions{}) return nil, fmt.Errorf("create mq deployment %q: %w", deployName, err) } return &MQTriggerInfo{ Function: functionName, Queue: queue, SQSEndpoint: sqsEndpoint, DeployUID: string(created.UID), }, nil } // GetMQTrigger возвращает информацию о MQ trigger по аннотациям Deployment. func (c *Client) GetMQTrigger(ctx context.Context, ns, name string) (*MQTriggerInfo, error) { deploy, err := c.K8sClient.AppsV1().Deployments(ns).Get(ctx, mqDeployName(name), metav1.GetOptions{}) if err != nil { return nil, fmt.Errorf("get mq deployment %q: %w", name, err) } ann := deploy.Annotations return &MQTriggerInfo{ Function: ann["fission-terraform/function"], Queue: ann["fission-terraform/queue"], SQSEndpoint: ann["fission-terraform/sqs-endpoint"], DeployUID: string(deploy.UID), }, nil } // DeleteMQTrigger удаляет Deployment + Secret MQ trigger. func (c *Client) DeleteMQTrigger(ctx context.Context, ns, name string) error { dErr := c.K8sClient.AppsV1().Deployments(ns).Delete(ctx, mqDeployName(name), metav1.DeleteOptions{}) sErr := c.K8sClient.CoreV1().Secrets(ns).Delete(ctx, mqSecretName(name), metav1.DeleteOptions{}) if dErr != nil && !apierrors.IsNotFound(dErr) { return fmt.Errorf("delete mq deployment %q: %w", name, dErr) } if sErr != nil && !apierrors.IsNotFound(sErr) { return fmt.Errorf("delete mq secret %q: %w", name, sErr) } return nil } // ── TimeTrigger (Fission CRD) ──────────────────────────────────────── // CreateTimeTrigger создаёт объект TimeTrigger (CRON) в заданном namespace. func (c *Client) CreateTimeTrigger(ctx context.Context, tt *unstructured.Unstructured) (*unstructured.Unstructured, error) { created, err := c.DynClient.Resource(timeTriggerGVR).Namespace(tt.GetNamespace()).Create(ctx, tt, metav1.CreateOptions{}) if err != nil { return nil, fmt.Errorf("create time trigger %q: %w", tt.GetName(), err) } return created, nil } // GetTimeTrigger возвращает объект TimeTrigger по имени. func (c *Client) GetTimeTrigger(ctx context.Context, namespace, name string) (*unstructured.Unstructured, error) { tt, err := c.DynClient.Resource(timeTriggerGVR).Namespace(namespace).Get(ctx, name, metav1.GetOptions{}) if err != nil { return nil, fmt.Errorf("get time trigger %q: %w", name, err) } return tt, nil } // UpdateTimeTrigger обновляет объект TimeTrigger. func (c *Client) UpdateTimeTrigger(ctx context.Context, tt *unstructured.Unstructured) (*unstructured.Unstructured, error) { updated, err := c.DynClient.Resource(timeTriggerGVR).Namespace(tt.GetNamespace()).Update(ctx, tt, metav1.UpdateOptions{}) if err != nil { return nil, fmt.Errorf("update time trigger %q: %w", tt.GetName(), err) } return updated, nil } // DeleteTimeTrigger удаляет объект TimeTrigger по имени. func (c *Client) DeleteTimeTrigger(ctx context.Context, namespace, name string) error { if err := c.DynClient.Resource(timeTriggerGVR).Namespace(namespace).Delete(ctx, name, metav1.DeleteOptions{}); err != nil { return fmt.Errorf("delete time trigger %q: %w", name, err) } return nil } // ── IoTDevice (CRD iot.kube5s.ru/v1alpha1) ────────────────────────── // IoTDeviceInfo — данные IoTDevice из статуса CRD. type IoTDeviceInfo struct { Phase string MQTTUsername string SecretName string TopicPrefix string } // CreateIoTDevice создаёт объект IoTDevice. func (c *Client) CreateIoTDevice(ctx context.Context, name, namespace, deviceID string, metadata map[string]string) (*IoTDeviceInfo, error) { obj := &unstructured.Unstructured{ Object: map[string]any{ "apiVersion": "iot.kube5s.ru/v1alpha1", "kind": "IoTDevice", "metadata": map[string]any{ "name": name, "namespace": namespace, }, "spec": map[string]any{ "deviceId": deviceID, "enabled": true, "metadata": metadata, }, }, } created, err := c.DynClient.Resource(iotDeviceGVR).Namespace(namespace).Create(ctx, obj, metav1.CreateOptions{}) if err != nil { return nil, fmt.Errorf("create iot device %q: %w", name, err) } return extractIoTDeviceInfo(created), nil } // GetIoTDevice возвращает IoTDevice по имени. func (c *Client) GetIoTDevice(ctx context.Context, namespace, name string) (*IoTDeviceInfo, error) { obj, err := c.DynClient.Resource(iotDeviceGVR).Namespace(namespace).Get(ctx, name, metav1.GetOptions{}) if err != nil { return nil, fmt.Errorf("get iot device %q: %w", name, err) } return extractIoTDeviceInfo(obj), nil } // DeleteIoTDevice удаляет IoTDevice по имени. func (c *Client) DeleteIoTDevice(ctx context.Context, namespace, name string) error { if err := c.DynClient.Resource(iotDeviceGVR).Namespace(namespace).Delete(ctx, name, metav1.DeleteOptions{}); err != nil { return fmt.Errorf("delete iot device %q: %w", name, err) } return nil } func extractIoTDeviceInfo(obj *unstructured.Unstructured) *IoTDeviceInfo { status, _, _ := unstructured.NestedMap(obj.Object, "status") phase, _ := status["phase"].(string) mqttUsername, _ := status["mqttUsername"].(string) secretName, _ := status["secretName"].(string) topicPrefix, _ := status["topicPrefix"].(string) return &IoTDeviceInfo{ Phase: phase, MQTTUsername: mqttUsername, SecretName: secretName, TopicPrefix: topicPrefix, } }