603 lines
17 KiB
Go
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 ""
|
|
}
|