Files
devstar/services/appstore/k8s_application_mapper.go
2025-11-19 21:40:10 +08:00

603 lines
17 KiB
Go

/*
* Copyright (c) Mengning Software. 2025. All rights reserved.
* Authors: DevStar Team, panshuxiao
* Create: 2025-11-19
* Description: Maps AppStore specs to Kubernetes manifests.
*/
package appstore
import (
"encoding/json"
"fmt"
"strings"
applicationv1 "code.gitea.io/gitea/modules/k8s/api/application/v1"
application "code.gitea.io/gitea/modules/k8s/application"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
// BuildK8sCreateOptions converts an AppStore App into CreateApplicationOptions for the Application CRD.
// Returns error if the app doesn't contain a valid Kubernetes deployment definition.
func BuildK8sCreateOptions(app *App) (*application.CreateApplicationOptions, error) {
if app == nil {
return nil, fmt.Errorf("nil app")
}
// Require actual Deploy.Type to be kubernetes, parameters are in Deploy.Kubernetes
if strings.ToLower(strings.TrimSpace(app.Deploy.Type)) != "kubernetes" {
return nil, fmt.Errorf("deploy.type must be 'kubernetes'")
}
if app.Deploy.Kubernetes == nil {
return nil, fmt.Errorf("deploy.kubernetes is required for kubernetes deployment")
}
k := app.Deploy.Kubernetes
// Name & Namespace
name := sanitizeName(app.ID)
namespace := k.Namespace
if namespace == "" {
namespace = "default"
}
// Overlay basic runtime params from config.default if present
cfg := app.Config.Default
if cfg != nil {
if v, ok := cfg["image"].(string); ok && strings.TrimSpace(v) != "" {
k.Image = v
}
if v, ok := cfg["tag"].(string); ok && strings.TrimSpace(v) != "" {
k.Tag = v
}
if v, ok := cfg["replicas"].(float64); ok {
k.Replicas = int(v)
}
if v, ok := cfg["env"].(map[string]interface{}); ok {
if k.Environment == nil {
k.Environment = map[string]string{}
}
for ek, ev := range v {
if sv, ok := ev.(string); ok {
k.Environment[ek] = sv
}
}
} else if v, ok := cfg["env"].(string); ok && strings.TrimSpace(v) != "" {
// allow JSON string
var m map[string]string
if json.Unmarshal([]byte(v), &m) == nil {
if k.Environment == nil {
k.Environment = map[string]string{}
}
for ek, ev := range m {
k.Environment[ek] = ev
}
}
}
if v, ok := cfg["port"].(float64); ok {
// single container port
cp := int(v)
if cp > 0 {
// replace or append port if not exists
found := false
for i := range k.Ports {
if k.Ports[i].ContainerPort == cp {
found = true
break
}
}
if !found {
// generate port name based on port number instead of hardcoding "http"
portName := fmt.Sprintf("port-%d", cp)
k.Ports = append(k.Ports, PortMapping{ContainerPort: cp, Protocol: "tcp", Name: portName})
}
}
} else if v, ok := cfg["ports"].(string); ok && strings.TrimSpace(v) != "" {
var arr []struct {
ContainerPort int `json:"container_port"`
Protocol string `json:"protocol"`
Name string `json:"name"`
}
if json.Unmarshal([]byte(v), &arr) == nil {
k.Ports = nil
for _, it := range arr {
proto := strings.ToLower(strings.TrimSpace(it.Protocol))
if proto == "" {
proto = "tcp"
}
name := it.Name
if name == "" {
name = fmt.Sprintf("port-%d", it.ContainerPort)
}
k.Ports = append(k.Ports, PortMapping{ContainerPort: it.ContainerPort, Protocol: proto, Name: name})
}
}
}
if v, ok := cfg["service_type"].(string); ok && strings.TrimSpace(v) != "" {
if k.Service == nil {
k.Service = &K8sService{}
}
k.Service.Type = v
}
}
// Image with optional tag (after overlay)
image := k.Image
if k.Tag != "" {
image = fmt.Sprintf("%s:%s", k.Image, k.Tag)
}
// Template Ports (ensure unique names)
var tplPorts []applicationv1.Port
usedNames := map[string]bool{}
for _, p := range k.Ports {
portName := p.Name
if portName == "" {
portName = fmt.Sprintf("port-%d", p.ContainerPort)
}
// de-duplicate name within the container scope
if usedNames[portName] {
// try suffix with container port
candidate := fmt.Sprintf("%s-%d", portName, p.ContainerPort)
if usedNames[candidate] {
// fallback to generic unique suffix
i := 2
for usedNames[fmt.Sprintf("%s-%d", portName, i)] {
i++
}
candidate = fmt.Sprintf("%s-%d", portName, i)
}
portName = candidate
}
usedNames[portName] = true
proto := strings.ToUpper(p.Protocol)
if proto == "" {
proto = "TCP"
}
tplPorts = append(tplPorts, applicationv1.Port{
Name: portName,
Port: int32(p.ContainerPort),
Protocol: proto,
})
}
// Resources
res := applicationv1.ResourceRequirements{}
if k.Resources != nil {
res.CPU = k.Resources.CPU
res.Memory = k.Resources.Memory
}
// Service config (optional)
var svc *applicationv1.ServiceConfig
if k.Service != nil {
svc = &applicationv1.ServiceConfig{
Enabled: true,
Type: k.Service.Type, // ClusterIP/NodePort/LoadBalancer/ExternalName
}
if len(k.Service.Ports) > 0 {
for _, sp := range k.Service.Ports {
portName := sp.Name
if portName == "" {
portName = fmt.Sprintf("svc-%d", sp.ContainerPort)
}
proto := strings.ToUpper(sp.Protocol)
if proto == "" {
proto = "TCP"
}
svc.Ports = append(svc.Ports, applicationv1.ServicePort{
Name: portName,
Port: int32(sp.ContainerPort),
TargetPort: portName, // name-based targetPort for stability
Protocol: proto,
})
}
} else if len(tplPorts) > 0 {
// Fallback: expose template ports
for _, p := range tplPorts {
svc.Ports = append(svc.Ports, applicationv1.ServicePort{
Name: p.Name,
Port: p.Port,
TargetPort: p.Name,
Protocol: p.Protocol,
})
}
}
}
// Replicas
var replicasPtr *int32
if k.Replicas >= 0 {
r := int32(k.Replicas)
replicasPtr = &r
}
opts := &application.CreateApplicationOptions{
Name: name,
Namespace: namespace,
Component: app.Category,
Template: applicationv1.ApplicationTemplate{
Image: image,
Type: "stateless",
Ports: tplPorts,
},
Replicas: replicasPtr,
Environment: k.Environment,
Resources: &res,
Expose: svc != nil, // deprecated in CRD but kept for compatibility
Service: svc,
}
// Map SSL/Gateway from config without frontend change
hosts := []string{}
enableHTTPS := false
tlsSecret := ""
tlsCert := ""
tlsKey := ""
minTLS := ""
if cfg != nil {
if v, ok := cfg["domains"].(string); ok && strings.TrimSpace(v) != "" {
parts := strings.Split(v, ",")
for _, p := range parts {
h := strings.TrimSpace(p)
if h != "" {
hosts = append(hosts, h)
}
}
}
if v, ok := cfg["enable_https"].(bool); ok {
enableHTTPS = v
}
if v, ok := cfg["tls_certificate"].(string); ok {
tlsCert = v
}
if v, ok := cfg["tls_private_key"].(string); ok {
tlsKey = v
}
if v, ok := cfg["https_min_tls"].(string); ok {
minTLS = v
}
}
if len(hosts) > 0 && (enableHTTPS || tlsSecret != "" || (tlsCert != "" && tlsKey != "")) {
// build gateway
gw := &applicationv1.GatewayConfig{Enabled: true, Hosts: hosts}
// ports
gw.Ports = append(gw.Ports, applicationv1.GatewayPort{Name: "http", Number: 80, Protocol: "HTTP"})
if enableHTTPS {
gw.Ports = append(gw.Ports, applicationv1.GatewayPort{Name: "https", Number: 443, Protocol: "HTTPS"})
}
// tls
if enableHTTPS {
if tlsSecret == "" && tlsCert != "" && tlsKey != "" {
tlsSecret = fmt.Sprintf("%s-tls", name)
}
tls := applicationv1.GatewayTLS{Hosts: hosts, Mode: "SIMPLE", SecretName: tlsSecret}
if minTLS != "" {
tls.MinProtocolVersion = minTLS
}
// inline cert/key: 使用我们新增的字段
if tlsCert != "" && tlsKey != "" {
tls.Certificate = tlsCert
tls.PrivateKey = tlsKey
}
gw.TLS = append(gw.TLS, tls)
}
opts.NetworkPolicy = &applicationv1.NetworkPolicy{Gateway: gw}
}
return opts, nil
}
func sanitizeName(s string) string {
s = strings.ToLower(strings.TrimSpace(s))
s = strings.ReplaceAll(s, "_", "-")
s = strings.ReplaceAll(s, " ", "-")
return s
}
// BuildK8sGetOptions builds GetApplicationOptions from an App for querying Application CRD
func BuildK8sGetOptions(app *App, namespaceOverride string, wait bool) (*application.GetApplicationOptions, error) {
if app == nil {
return nil, fmt.Errorf("nil app")
}
if app.Deploy.Kubernetes == nil {
return nil, fmt.Errorf("deploy.kubernetes is required")
}
name := sanitizeName(app.ID)
ns := firstNonEmpty(namespaceOverride, app.Deploy.Kubernetes.Namespace, "default")
return &application.GetApplicationOptions{
Name: name,
Namespace: ns,
Wait: wait,
GetOptions: metav1.GetOptions{},
}, nil
}
// BuildK8sDeleteOptions builds DeleteApplicationOptions to remove an Application CRD
func BuildK8sDeleteOptions(app *App, namespaceOverride string) (*application.DeleteApplicationOptions, error) {
if app == nil {
return nil, fmt.Errorf("nil app")
}
if app.Deploy.Kubernetes == nil {
return nil, fmt.Errorf("deploy.kubernetes is required")
}
name := sanitizeName(app.ID)
ns := firstNonEmpty(namespaceOverride, app.Deploy.Kubernetes.Namespace, "default")
return &application.DeleteApplicationOptions{
Name: name,
Namespace: ns,
DeleteOptions: metav1.DeleteOptions{},
}, nil
}
// BuildK8sListOptions builds ListApplicationsOptions to list Applications in a namespace
func BuildK8sListOptions(namespace string, listOpts metav1.ListOptions) (*application.ListApplicationsOptions, error) {
ns := namespace
if strings.TrimSpace(ns) == "" {
ns = "default"
}
return &application.ListApplicationsOptions{
Namespace: ns,
ListOptions: listOpts,
}, nil
}
// BuildK8sUpdateOptions builds UpdateApplicationOptions from an App and optional existing Application object
func BuildK8sUpdateOptions(app *App, namespaceOverride string, existing *applicationv1.Application) (*application.UpdateApplicationOptions, error) {
if app == nil {
return nil, fmt.Errorf("nil app")
}
if app.Deploy.Kubernetes == nil {
return nil, fmt.Errorf("deploy.kubernetes is required")
}
name := sanitizeName(app.ID)
ns := firstNonEmpty(namespaceOverride, app.Deploy.Kubernetes.Namespace, "default")
// Start from existing object when provided to preserve metadata/status
var obj applicationv1.Application
if existing != nil {
obj = *existing.DeepCopy()
} else {
obj.TypeMeta = metav1.TypeMeta{Kind: "Application", APIVersion: "application.devstar.cn/v1"}
obj.ObjectMeta = metav1.ObjectMeta{Name: name, Namespace: ns}
}
// Map spec similar to BuildK8sCreateOptions
k := app.Deploy.Kubernetes
// Re-apply overlay for update path as well
cfg := app.Config.Default
if cfg != nil {
if v, ok := cfg["image"].(string); ok && strings.TrimSpace(v) != "" {
k.Image = v
}
if v, ok := cfg["tag"].(string); ok && strings.TrimSpace(v) != "" {
k.Tag = v
}
if v, ok := cfg["replicas"].(float64); ok {
k.Replicas = int(v)
}
if v, ok := cfg["env"].(map[string]interface{}); ok {
if obj.Spec.Environment == nil {
obj.Spec.Environment = map[string]string{}
}
for ek, ev := range v {
if sv, ok := ev.(string); ok {
obj.Spec.Environment[ek] = sv
}
}
} else if v, ok := cfg["env"].(string); ok && strings.TrimSpace(v) != "" {
var m map[string]string
if json.Unmarshal([]byte(v), &m) == nil {
if obj.Spec.Environment == nil {
obj.Spec.Environment = map[string]string{}
}
for ek, ev := range m {
obj.Spec.Environment[ek] = ev
}
}
}
if v, ok := cfg["port"].(float64); ok {
cp := int(v)
if cp > 0 {
// overwrite tplPorts below since we rebuild Obj.Template.Ports from k.Ports
found := false
for i := range k.Ports {
if k.Ports[i].ContainerPort == cp {
found = true
break
}
}
if !found {
// generate port name based on port number instead of hardcoding "http"
portName := fmt.Sprintf("port-%d", cp)
k.Ports = append(k.Ports, PortMapping{ContainerPort: cp, Protocol: "tcp", Name: portName})
}
}
} else if v, ok := cfg["ports"].(string); ok && strings.TrimSpace(v) != "" {
var arr []struct {
ContainerPort int `json:"container_port"`
Protocol string `json:"protocol"`
Name string `json:"name"`
}
if json.Unmarshal([]byte(v), &arr) == nil {
k.Ports = nil
for _, it := range arr {
proto := strings.ToLower(strings.TrimSpace(it.Protocol))
if proto == "" {
proto = "tcp"
}
name := it.Name
if name == "" {
name = fmt.Sprintf("port-%d", it.ContainerPort)
}
k.Ports = append(k.Ports, PortMapping{ContainerPort: it.ContainerPort, Protocol: proto, Name: name})
}
}
}
if v, ok := cfg["service_type"].(string); ok && strings.TrimSpace(v) != "" {
if obj.Spec.Service == nil {
obj.Spec.Service = &applicationv1.ServiceConfig{Enabled: true, Type: v}
} else {
obj.Spec.Service.Type = v
}
}
}
image := k.Image
if k.Tag != "" {
image = fmt.Sprintf("%s:%s", k.Image, k.Tag)
}
var tplPorts []applicationv1.Port
usedNames := map[string]bool{}
for _, p := range k.Ports {
portName := p.Name
if portName == "" {
portName = fmt.Sprintf("port-%d", p.ContainerPort)
}
if usedNames[portName] {
candidate := fmt.Sprintf("%s-%d", portName, p.ContainerPort)
if usedNames[candidate] {
i := 2
for usedNames[fmt.Sprintf("%s-%d", portName, i)] {
i++
}
candidate = fmt.Sprintf("%s-%d", portName, i)
}
portName = candidate
}
usedNames[portName] = true
proto := strings.ToUpper(p.Protocol)
if proto == "" {
proto = "TCP"
}
tplPorts = append(tplPorts, applicationv1.Port{Name: portName, Port: int32(p.ContainerPort), Protocol: proto})
}
res := applicationv1.ResourceRequirements{}
if k.Resources != nil {
res.CPU = k.Resources.CPU
res.Memory = k.Resources.Memory
}
var svc *applicationv1.ServiceConfig
if k.Service != nil {
svc = &applicationv1.ServiceConfig{Enabled: true, Type: k.Service.Type}
if len(k.Service.Ports) > 0 {
for _, sp := range k.Service.Ports {
portName := sp.Name
if portName == "" {
portName = fmt.Sprintf("svc-%d", sp.ContainerPort)
}
proto := strings.ToUpper(sp.Protocol)
if proto == "" {
proto = "TCP"
}
svc.Ports = append(svc.Ports, applicationv1.ServicePort{
Name: portName,
Port: int32(sp.ContainerPort),
TargetPort: portName,
Protocol: proto,
})
}
} else if len(tplPorts) > 0 {
for _, p := range tplPorts {
svc.Ports = append(svc.Ports, applicationv1.ServicePort{
Name: p.Name,
Port: p.Port,
TargetPort: p.Name,
Protocol: p.Protocol,
})
}
}
}
var replicasPtr *int32
if k.Replicas >= 0 {
r := int32(k.Replicas)
replicasPtr = &r
}
obj.Spec = applicationv1.ApplicationSpec{
Template: applicationv1.ApplicationTemplate{
Image: image,
Type: "stateless",
Ports: tplPorts,
},
Replicas: replicasPtr,
Environment: obj.Spec.Environment,
Resources: res,
Expose: svc != nil,
Service: svc,
}
// Map SSL/Gateway from config for update
hosts := []string{}
enableHTTPS := false
tlsSecret := ""
tlsCert := ""
tlsKey := ""
minTLS := ""
if cfg != nil {
if v, ok := cfg["domains"].(string); ok && strings.TrimSpace(v) != "" {
parts := strings.Split(v, ",")
for _, p := range parts {
h := strings.TrimSpace(p)
if h != "" {
hosts = append(hosts, h)
}
}
}
if v, ok := cfg["enable_https"].(bool); ok {
enableHTTPS = v
}
if v, ok := cfg["tls_certificate"].(string); ok {
tlsCert = v
}
if v, ok := cfg["tls_private_key"].(string); ok {
tlsKey = v
}
if v, ok := cfg["https_min_tls"].(string); ok {
minTLS = v
}
}
if len(hosts) > 0 && (enableHTTPS || tlsSecret != "" || (tlsCert != "" && tlsKey != "")) {
gw := &applicationv1.GatewayConfig{Enabled: true, Hosts: hosts}
gw.Ports = append(gw.Ports, applicationv1.GatewayPort{Name: "http", Number: 80, Protocol: "HTTP"})
if enableHTTPS {
gw.Ports = append(gw.Ports, applicationv1.GatewayPort{Name: "https", Number: 443, Protocol: "HTTPS"})
}
if enableHTTPS {
if tlsSecret == "" && tlsCert != "" && tlsKey != "" {
tlsSecret = fmt.Sprintf("%s-tls", name)
}
tls := applicationv1.GatewayTLS{Hosts: hosts, Mode: "SIMPLE", SecretName: tlsSecret}
if minTLS != "" {
tls.MinProtocolVersion = minTLS
}
if tlsCert != "" && tlsKey != "" {
tls.Certificate = tlsCert
tls.PrivateKey = tlsKey
}
gw.TLS = append(gw.TLS, tls)
}
obj.Spec.NetworkPolicy = &applicationv1.NetworkPolicy{Gateway: gw}
}
return &application.UpdateApplicationOptions{
Name: name,
Namespace: ns,
Application: &obj,
UpdateOptions: metav1.UpdateOptions{},
}, nil
}
func firstNonEmpty(vals ...string) string {
for _, v := range vals {
if strings.TrimSpace(v) != "" {
return v
}
}
return ""
}