package deployer import ( "context" "strings" appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" networkingv1 "k8s.io/api/networking/v1" "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/api/resource" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/util/intstr" "github.com/orchard9/rdev/internal/domain" ) // ensureNamespace verifies the deployment namespace exists, creating it only if needed. func (d *Deployer) ensureNamespace(ctx context.Context) error { _, err := d.client.CoreV1().Namespaces().Get(ctx, d.config.Namespace, metav1.GetOptions{}) if err == nil { return nil // namespace exists } if !errors.IsNotFound(err) { return err } // Namespace doesn't exist, try to create it ns := &corev1.Namespace{ ObjectMeta: metav1.ObjectMeta{ Name: d.config.Namespace, }, } _, err = d.client.CoreV1().Namespaces().Create(ctx, ns, metav1.CreateOptions{}) if err != nil && !errors.IsAlreadyExists(err) { return err } return nil } // createOrUpdateSecret manages the secret for environment variables. func (d *Deployer) createOrUpdateSecret(ctx context.Context, spec domain.DeploySpec) error { secretName := spec.ProjectName + "-env" ns := d.config.Namespace secret := &corev1.Secret{ ObjectMeta: metav1.ObjectMeta{ Name: secretName, Namespace: ns, Labels: map[string]string{ "app": spec.ProjectName, "project": spec.ProjectName, }, }, StringData: spec.Secrets, } _, err := d.client.CoreV1().Secrets(ns).Get(ctx, secretName, metav1.GetOptions{}) if errors.IsNotFound(err) { _, err = d.client.CoreV1().Secrets(ns).Create(ctx, secret, metav1.CreateOptions{}) } else if err == nil { _, err = d.client.CoreV1().Secrets(ns).Update(ctx, secret, metav1.UpdateOptions{}) } return err } // createOrUpdateDeployment manages the Kubernetes Deployment resource. func (d *Deployer) createOrUpdateDeployment(ctx context.Context, spec domain.DeploySpec) error { ns := d.config.Namespace replicas := int32(spec.Replicas) // Build env vars var envVars []corev1.EnvVar for k, v := range spec.EnvVars { envVars = append(envVars, corev1.EnvVar{Name: k, Value: v}) } // Add secret env vars var envFrom []corev1.EnvFromSource if len(spec.Secrets) > 0 { envFrom = append(envFrom, corev1.EnvFromSource{ SecretRef: &corev1.SecretEnvSource{ LocalObjectReference: corev1.LocalObjectReference{ Name: spec.ProjectName + "-env", }, }, }) } deployment := d.buildDeployment(spec, ns, replicas, envVars, envFrom) _, err := d.client.AppsV1().Deployments(ns).Get(ctx, spec.ProjectName, metav1.GetOptions{}) if errors.IsNotFound(err) { _, err = d.client.AppsV1().Deployments(ns).Create(ctx, deployment, metav1.CreateOptions{}) } else if err == nil { _, err = d.client.AppsV1().Deployments(ns).Update(ctx, deployment, metav1.UpdateOptions{}) } return err } func (d *Deployer) buildDeployment(spec domain.DeploySpec, ns string, replicas int32, envVars []corev1.EnvVar, envFrom []corev1.EnvFromSource) *appsv1.Deployment { return &appsv1.Deployment{ ObjectMeta: metav1.ObjectMeta{ Name: spec.ProjectName, Namespace: ns, Labels: map[string]string{ "app": spec.ProjectName, "project": spec.ProjectName, }, }, Spec: appsv1.DeploymentSpec{ Replicas: &replicas, Selector: &metav1.LabelSelector{ MatchLabels: map[string]string{ "app": spec.ProjectName, }, }, Template: corev1.PodTemplateSpec{ ObjectMeta: metav1.ObjectMeta{ Labels: map[string]string{ "app": spec.ProjectName, "project": spec.ProjectName, }, }, Spec: corev1.PodSpec{ Containers: []corev1.Container{ { Name: spec.ProjectName, Image: spec.Image, Env: envVars, EnvFrom: envFrom, Ports: []corev1.ContainerPort{ { ContainerPort: int32(spec.Port), Protocol: corev1.ProtocolTCP, }, }, Resources: corev1.ResourceRequirements{ Requests: corev1.ResourceList{ corev1.ResourceCPU: resourceQuantity("100m"), corev1.ResourceMemory: resourceQuantity("128Mi"), }, Limits: corev1.ResourceList{ corev1.ResourceCPU: resourceQuantity("1000m"), corev1.ResourceMemory: resourceQuantity("512Mi"), }, }, }, }, }, }, }, } } // createOrUpdateService manages the Kubernetes Service resource. func (d *Deployer) createOrUpdateService(ctx context.Context, spec domain.DeploySpec) error { ns := d.config.Namespace service := &corev1.Service{ ObjectMeta: metav1.ObjectMeta{ Name: spec.ProjectName, Namespace: ns, Labels: map[string]string{ "app": spec.ProjectName, "project": spec.ProjectName, }, }, Spec: corev1.ServiceSpec{ Selector: map[string]string{ "app": spec.ProjectName, }, Ports: []corev1.ServicePort{ { Port: int32(spec.Port), TargetPort: intstr.FromInt(spec.Port), Protocol: corev1.ProtocolTCP, }, }, }, } _, err := d.client.CoreV1().Services(ns).Get(ctx, spec.ProjectName, metav1.GetOptions{}) if errors.IsNotFound(err) { _, err = d.client.CoreV1().Services(ns).Create(ctx, service, metav1.CreateOptions{}) } else if err == nil { _, err = d.client.CoreV1().Services(ns).Update(ctx, service, metav1.UpdateOptions{}) } return err } // createOrUpdateIngress manages the Kubernetes Ingress resource. func (d *Deployer) createOrUpdateIngress(ctx context.Context, spec domain.DeploySpec) error { ns := d.config.Namespace pathType := networkingv1.PathTypePrefix ingressClass := d.config.IngressClass // Build TLS secret name from domain tlsSecretName := strings.ReplaceAll(spec.Domain, ".", "-") + "-tls" annotations := map[string]string{} if d.config.TLSIssuer != "" { annotations["cert-manager.io/cluster-issuer"] = d.config.TLSIssuer } ingress := d.buildIngress(spec, ns, pathType, ingressClass, tlsSecretName, annotations) _, err := d.client.NetworkingV1().Ingresses(ns).Get(ctx, spec.ProjectName, metav1.GetOptions{}) if errors.IsNotFound(err) { _, err = d.client.NetworkingV1().Ingresses(ns).Create(ctx, ingress, metav1.CreateOptions{}) } else if err == nil { _, err = d.client.NetworkingV1().Ingresses(ns).Update(ctx, ingress, metav1.UpdateOptions{}) } return err } func (d *Deployer) buildIngress(spec domain.DeploySpec, ns string, pathType networkingv1.PathType, ingressClass, tlsSecretName string, annotations map[string]string) *networkingv1.Ingress { return &networkingv1.Ingress{ ObjectMeta: metav1.ObjectMeta{ Name: spec.ProjectName, Namespace: ns, Labels: map[string]string{ "app": spec.ProjectName, "project": spec.ProjectName, }, Annotations: annotations, }, Spec: networkingv1.IngressSpec{ IngressClassName: &ingressClass, TLS: []networkingv1.IngressTLS{ { Hosts: []string{spec.Domain}, SecretName: tlsSecretName, }, }, Rules: []networkingv1.IngressRule{ { Host: spec.Domain, IngressRuleValue: networkingv1.IngressRuleValue{ HTTP: &networkingv1.HTTPIngressRuleValue{ Paths: []networkingv1.HTTPIngressPath{ { Path: "/", PathType: &pathType, Backend: networkingv1.IngressBackend{ Service: &networkingv1.IngressServiceBackend{ Name: spec.ProjectName, Port: networkingv1.ServiceBackendPort{ Number: int32(spec.Port), }, }, }, }, }, }, }, }, }, }, } } // resourceQuantity parses a resource quantity string. // Returns the parsed quantity or a zero quantity on error. func resourceQuantity(s string) resource.Quantity { q, _ := resource.ParseQuantity(s) return q }