The deployer was using cert-manager.io/issuer (namespace-scoped) referencing letsencrypt-threesix which only exists in the threesix namespace. Projects deploy to the projects namespace, so changed to cert-manager.io/cluster-issuer with letsencrypt-prod. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
269 lines
7.7 KiB
Go
269 lines
7.7 KiB
Go
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
|
|
}
|