完成应用商店应用对证书的支持

This commit is contained in:
panshuxiao
2025-11-18 19:25:54 +08:00
parent c0fbbfb618
commit a37025f3bf
10 changed files with 1154 additions and 78 deletions

View File

@@ -157,6 +157,18 @@ type GatewayTLS struct {
// +kubebuilder:default="TLSv1_2"
// +kubebuilder:validation:Enum=TLSv1_0;TLSv1_1;TLSv1_2;TLSv1_3
MinProtocolVersion string `json:"minProtocolVersion,omitempty"`
// 目标 Secret 所在命名空间,默认 istio-system若不指定控制器将自动探测 IngressGateway 所在命名空间
// +optional
SecretNamespace string `json:"secretNamespace,omitempty"`
// 用户直接提供的证书PEM等价于 tls.crt。若提供需同时提供 privateKey并必须指定 secretName
// +optional
Certificate string `json:"certificate,omitempty"`
// 用户直接提供的私钥PEM等价于 tls.key。若提供需同时提供 certificate并必须指定 secretName
// +optional
PrivateKey string `json:"privateKey,omitempty"`
}
// MeshConfig 定义服务网格相关配置

View File

@@ -1,6 +1,7 @@
package application
import (
"bytes"
"context"
"fmt"
"strings"
@@ -38,6 +39,8 @@ type ApplicationReconciler struct {
// +kubebuilder:rbac:groups=apps,resources=statefulsets,verbs=create;delete;get;list;watch;update;patch
// +kubebuilder:rbac:groups="",resources=services,verbs=create;delete;get;list;watch;update;patch
// +kubebuilder:rbac:groups=networking.k8s.io,resources=ingresses,verbs=create;delete;get;list;watch;update;patch
// +kubebuilder:rbac:groups="",resources=secrets,verbs=get;list;watch;create;update;patch
// +kubebuilder:rbac:groups="",resources=namespaces,verbs=get;list;watch;create
// Reconcile is part of the main kubernetes reconciliation loop
func (r *ApplicationReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
@@ -57,6 +60,12 @@ func (r *ApplicationReconciler) Reconcile(ctx context.Context, req ctrl.Request)
logger.Info("Processing Application", "name", app.Name, "namespace", app.Namespace, "type", app.Spec.Template.Type)
// 确保命名空间存在
if err := r.ensureNamespace(ctx, app.Namespace); err != nil {
logger.Error(err, "Failed to ensure namespace exists", "namespace", app.Namespace)
return ctrl.Result{}, err
}
// 添加 finalizer 处理逻辑
finalizerName := "application.devstar.cn/finalizer"
@@ -67,6 +76,18 @@ func (r *ApplicationReconciler) Reconcile(ctx context.Context, req ctrl.Request)
// 执行清理操作
logger.Info("Cleaning up resources before deletion", "name", app.Name)
// 清理 Gateway 资源(包括 Secret
if err := r.cleanupGateway(ctx, app); err != nil {
logger.Error(err, "Failed to cleanup gateway resources")
return ctrl.Result{}, err
}
// 清理 Mesh 资源
if err := r.cleanupMesh(ctx, app); err != nil {
logger.Error(err, "Failed to cleanup mesh resources")
return ctrl.Result{}, err
}
// 清理完成后移除 finalizer
k8s_sigs_controller_runtime_utils.RemoveFinalizer(app, finalizerName)
if err := r.Update(ctx, app); err != nil {
@@ -739,21 +760,29 @@ func (r *ApplicationReconciler) cleanupGateway(ctx context.Context, app *applica
gatewayName := app.Name + "-gateway"
vsName := app.Name + "-gateway-vs"
// 清理Gateway
gateway := &istionetworkingv1.Gateway{}
err := r.Get(ctx, types.NamespacedName{Name: gatewayName, Namespace: app.Namespace}, gateway)
if err == nil {
logger.Info("Cleaning up Gateway that is no longer needed", "name", gatewayName)
if err := r.Delete(ctx, gateway); err != nil && !errors.IsNotFound(err) {
return fmt.Errorf("failed to delete Gateway: %w", err)
targetNamespaces := map[string]struct{}{app.Namespace: {}}
if ns, err := r.determineGatewayNamespace(ctx, app); err == nil {
targetNamespaces[ns] = struct{}{}
} else {
logger.Error(err, "Failed to determine gateway namespace during cleanup, fallback to application namespace")
}
for ns := range targetNamespaces {
gateway := &istionetworkingv1.Gateway{}
err := r.Get(ctx, types.NamespacedName{Name: gatewayName, Namespace: ns}, gateway)
if err == nil {
logger.Info("Cleaning up Gateway that is no longer needed", "name", gatewayName, "namespace", ns)
if err := r.Delete(ctx, gateway); err != nil && !errors.IsNotFound(err) {
return fmt.Errorf("failed to delete Gateway %s/%s: %w", ns, gatewayName, err)
}
} else if !errors.IsNotFound(err) {
return fmt.Errorf("failed to get Gateway %s/%s: %w", ns, gatewayName, err)
}
} else if !errors.IsNotFound(err) {
return fmt.Errorf("failed to get Gateway: %w", err)
}
// 清理VirtualService
vs := &istionetworkingv1.VirtualService{}
err = r.Get(ctx, types.NamespacedName{Name: vsName, Namespace: app.Namespace}, vs)
err := r.Get(ctx, types.NamespacedName{Name: vsName, Namespace: app.Namespace}, vs)
if err == nil {
logger.Info("Cleaning up Gateway VirtualService that is no longer needed", "name", vsName)
if err := r.Delete(ctx, vs); err != nil && !errors.IsNotFound(err) {
@@ -763,6 +792,43 @@ func (r *ApplicationReconciler) cleanupGateway(ctx context.Context, app *applica
return fmt.Errorf("failed to get Gateway VirtualService: %w", err)
}
// 清理 TLS Secret如果是由控制器创建的
if app.Spec.NetworkPolicy != nil && app.Spec.NetworkPolicy.Gateway != nil {
for _, tls := range app.Spec.NetworkPolicy.Gateway.TLS {
if strings.EqualFold(tls.Mode, "PASSTHROUGH") {
continue
}
if tls.SecretName == "" {
continue
}
// 确定 Secret 的命名空间
secretNS, err := r.detectIngressNamespace(ctx, tls.SecretNamespace)
if err != nil {
logger.Error(err, "Failed to determine secret namespace for cleanup, skipping", "secretName", tls.SecretName)
continue
}
// 检查 Secret 是否存在,并且有我们的标签(说明是我们创建的)
secret := &core_v1.Secret{}
err = r.Get(ctx, types.NamespacedName{Name: tls.SecretName, Namespace: secretNS}, secret)
if err == nil {
// 检查标签,确认是我们创建的
if secret.Labels != nil &&
secret.Labels["app.k8s.devstar/name"] == app.Name &&
secret.Labels["app.k8s.devstar/type"] == "gateway-tls" {
logger.Info("Cleaning up Gateway TLS Secret", "name", tls.SecretName, "namespace", secretNS)
if err := r.Delete(ctx, secret); err != nil && !errors.IsNotFound(err) {
logger.Error(err, "Failed to delete Gateway TLS Secret", "name", tls.SecretName, "namespace", secretNS)
// 不返回错误,继续清理其他资源
}
}
} else if !errors.IsNotFound(err) {
logger.Error(err, "Failed to get Gateway TLS Secret for cleanup", "name", tls.SecretName, "namespace", secretNS)
}
}
}
return nil
}
@@ -799,6 +865,44 @@ func (r *ApplicationReconciler) cleanupMesh(ctx context.Context, app *applicatio
return nil
}
// ensureNamespace 确保命名空间存在,如果不存在则创建
func (r *ApplicationReconciler) ensureNamespace(ctx context.Context, namespace string) error {
logger := log.FromContext(ctx)
// 跳过默认命名空间(这些命名空间通常由系统管理)
if namespace == "default" || namespace == "kube-system" || namespace == "kube-public" {
return nil
}
// 检查命名空间是否存在
ns := &core_v1.Namespace{}
err := r.Get(ctx, types.NamespacedName{Name: namespace}, ns)
if err == nil {
// 命名空间已存在
return nil
}
if !errors.IsNotFound(err) {
// 获取命名空间时发生其他错误
return fmt.Errorf("failed to get namespace %s: %w", namespace, err)
}
// 命名空间不存在,创建它
logger.Info("Creating namespace", "namespace", namespace)
newNS := &core_v1.Namespace{
ObjectMeta: metav1.ObjectMeta{
Name: namespace,
},
}
if err := r.Create(ctx, newNS); err != nil {
return fmt.Errorf("failed to create namespace %s: %w", namespace, err)
}
logger.Info("Successfully created namespace", "namespace", namespace)
return nil
}
// 为了在SetupWithManager中注册Istio资源监控
func (r *ApplicationReconciler) SetupWithManager(mgr ctrl.Manager) error {
return ctrl.NewControllerManagedBy(mgr).
@@ -1028,36 +1132,68 @@ func (r *ApplicationReconciler) reconcileGateway(ctx context.Context, app *appli
logger := log.FromContext(ctx)
gatewayName := app.Name + "-gateway"
// 在创建/更新 Gateway 前,确保 TLS Secret
if err := r.reconcileGatewayTLSSecret(ctx, app); err != nil {
return fmt.Errorf("failed to reconcile gateway TLS secret: %w", err)
}
gatewayNamespace, err := r.determineGatewayNamespace(ctx, app)
if err != nil {
return fmt.Errorf("failed to determine gateway namespace: %w", err)
}
// 删除遗留在应用命名空间的 Gateway兼容旧版本
if gatewayNamespace != app.Namespace {
legacyGateway := &istionetworkingv1.Gateway{}
legacyKey := types.NamespacedName{Name: gatewayName, Namespace: app.Namespace}
if legacyErr := r.Get(ctx, legacyKey, legacyGateway); legacyErr == nil {
logger.Info("Deleting legacy Gateway from application namespace", "name", gatewayName)
if err := r.Delete(ctx, legacyGateway); err != nil && !errors.IsNotFound(err) {
return fmt.Errorf("failed to delete legacy gateway: %w", err)
}
}
}
// Get + Create/Update 以规避 CreateOrUpdate 对 protobuf 类型的反射补丁
existingGateway := &istionetworkingv1.Gateway{}
err := r.Get(ctx, types.NamespacedName{Name: gatewayName, Namespace: app.Namespace}, existingGateway)
gatewayKey := types.NamespacedName{Name: gatewayName, Namespace: gatewayNamespace}
err = r.Get(ctx, gatewayKey, existingGateway)
if errors.IsNotFound(err) {
logger.Info("Creating new Gateway", "name", gatewayName)
newGateway := &istionetworkingv1.Gateway{ObjectMeta: metav1.ObjectMeta{Name: gatewayName, Namespace: app.Namespace}}
if err := r.configureGateway(newGateway, app); err != nil {
newGateway := &istionetworkingv1.Gateway{
ObjectMeta: metav1.ObjectMeta{
Name: gatewayName,
Namespace: gatewayNamespace,
},
}
ensureGatewayLabels(newGateway, app)
if err := r.configureGateway(ctx, newGateway, app); err != nil {
return fmt.Errorf("failed to configure Gateway: %w", err)
}
if err := k8s_sigs_controller_runtime_utils.SetControllerReference(app, newGateway, r.Scheme); err != nil {
return fmt.Errorf("failed to set controller reference: %w", err)
if gatewayNamespace == app.Namespace {
if err := k8s_sigs_controller_runtime_utils.SetControllerReference(app, newGateway, r.Scheme); err != nil {
return fmt.Errorf("failed to set controller reference: %w", err)
}
}
if err := r.Create(ctx, newGateway); err != nil {
return fmt.Errorf("failed to create Gateway: %w", err)
}
logger.Info("Gateway created", "name", gatewayName)
logger.Info("Gateway created", "name", gatewayName, "namespace", gatewayNamespace)
} else if err != nil {
return fmt.Errorf("failed to get Gateway: %w", err)
} else {
if err := r.configureGateway(existingGateway, app); err != nil {
ensureGatewayLabels(existingGateway, app)
if err := r.configureGateway(ctx, existingGateway, app); err != nil {
return fmt.Errorf("failed to configure Gateway: %w", err)
}
if err := r.Update(ctx, existingGateway); err != nil {
return fmt.Errorf("failed to update Gateway: %w", err)
}
logger.Info("Gateway updated", "name", gatewayName)
logger.Info("Gateway updated", "name", gatewayName, "namespace", gatewayNamespace)
}
// 协调与Gateway关联的VirtualService
if err := r.reconcileGatewayVirtualService(ctx, app); err != nil {
if err := r.reconcileGatewayVirtualService(ctx, app, gatewayNamespace); err != nil {
return fmt.Errorf("failed to reconcile gateway VirtualService: %w", err)
}
@@ -1065,7 +1201,7 @@ func (r *ApplicationReconciler) reconcileGateway(ctx context.Context, app *appli
}
// configureGateway 配置Gateway资源
func (r *ApplicationReconciler) configureGateway(gateway *istionetworkingv1.Gateway, app *applicationv1.Application) error {
func (r *ApplicationReconciler) configureGateway(ctx context.Context, gateway *istionetworkingv1.Gateway, app *applicationv1.Application) error {
// 设置Gateway选择器
gateway.Spec.Selector = map[string]string{
"istio": "ingressgateway",
@@ -1126,9 +1262,22 @@ func (r *ApplicationReconciler) configureGateway(gateway *istionetworkingv1.Gate
// 配置TLS设置
server := gateway.Spec.Servers[serverIndex]
// 确定 Secret 的命名空间
secretNS, err := r.detectIngressNamespace(ctx, tls.SecretNamespace)
if err != nil {
return fmt.Errorf("failed to determine secret namespace for TLS: %w", err)
}
// 如果 Secret 不在 Gateway 所在命名空间,使用 namespace/secretName 格式
credentialName := tls.SecretName
if secretNS != gateway.Namespace {
credentialName = secretNS + "/" + tls.SecretName
}
server.Tls = &istioapinetworkingv1.ServerTLSSettings{
Mode: getIstioTLSMode(tls.Mode),
CredentialName: tls.SecretName,
CredentialName: credentialName,
}
// 设置最小TLS版本
@@ -1183,10 +1332,391 @@ func getIstioTLSVersion(version string) istioapinetworkingv1.ServerTLSSettings_T
}
}
// detectIngressNamespace 自动探测 IngressGateway 所在命名空间
// 优先级CRD 指定 -> 根据 selector {istio=ingressgateway} 查找 Service
func (r *ApplicationReconciler) detectIngressNamespace(ctx context.Context, explicit string) (string, error) {
if explicit != "" {
return explicit, nil
}
// 优先找名为 istio-ingressgateway 的 Service
svcList := &core_v1.ServiceList{}
if err := r.List(ctx, svcList, client.MatchingLabels{"istio": "ingressgateway"}); err != nil {
return "", fmt.Errorf("list ingressgateway services failed: %w", err)
}
var fallback string
for _, svc := range svcList.Items {
if svc.Name == "istio-ingressgateway" {
return svc.Namespace, nil
}
if fallback == "" {
fallback = svc.Namespace
}
}
if fallback != "" {
return fallback, nil
}
return "", fmt.Errorf("cannot detect ingress gateway namespace; set tls.secretNamespace or ISTIO_INGRESS_NAMESPACE")
}
// determineGatewayNamespace 决定 Gateway 应创建到的命名空间
func (r *ApplicationReconciler) determineGatewayNamespace(ctx context.Context, app *applicationv1.Application) (string, error) {
var explicit string
if app.Spec.NetworkPolicy != nil && app.Spec.NetworkPolicy.Gateway != nil {
for _, tls := range app.Spec.NetworkPolicy.Gateway.TLS {
if tls.SecretNamespace != "" {
explicit = tls.SecretNamespace
break
}
}
}
return r.detectIngressNamespace(ctx, explicit)
}
func ensureGatewayLabels(gateway *istionetworkingv1.Gateway, app *applicationv1.Application) {
if gateway.Labels == nil {
gateway.Labels = make(map[string]string)
}
gateway.Labels["app.k8s.devstar/name"] = app.Name
gateway.Labels["app.k8s.devstar/namespace"] = app.Namespace
}
// reconcileGatewayTLSSecret 确保 Gateway TLS 所需的 Secret 存在/最新
func (r *ApplicationReconciler) reconcileGatewayTLSSecret(ctx context.Context, app *applicationv1.Application) error {
if app.Spec.NetworkPolicy == nil || app.Spec.NetworkPolicy.Gateway == nil {
return nil
}
tlsList := app.Spec.NetworkPolicy.Gateway.TLS
if len(tlsList) == 0 {
return nil
}
for _, tls := range tlsList {
if strings.EqualFold(tls.Mode, "PASSTHROUGH") {
continue
}
secretName := tls.SecretName
if secretName == "" {
return fmt.Errorf("gateway.tls.secretName is required when TLS mode is %s", tls.Mode)
}
// 自动确定目标命名空间
targetNS, err := r.detectIngressNamespace(ctx, tls.SecretNamespace)
if err != nil {
return err
}
hasInline := tls.Certificate != "" && tls.PrivateKey != ""
existing := &core_v1.Secret{}
getErr := r.Get(ctx, types.NamespacedName{Namespace: targetNS, Name: secretName}, existing)
if errors.IsNotFound(getErr) {
if !hasInline {
return fmt.Errorf("secret %s/%s not found; provide certificate/privateKey or create it manually", targetNS, secretName)
}
// 规范化证书和私钥格式(确保是有效的 PEM 格式)
certPEM := normalizePEM(tls.Certificate, "CERTIFICATE")
keyPEM := normalizePEM(tls.PrivateKey, "PRIVATE KEY")
newSec := &core_v1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: secretName,
Namespace: targetNS,
Labels: map[string]string{
"app.k8s.devstar/name": app.Name,
"app.k8s.devstar/type": "gateway-tls",
},
},
Type: core_v1.SecretTypeTLS,
Data: map[string][]byte{
core_v1.TLSCertKey: []byte(certPEM),
core_v1.TLSPrivateKeyKey: []byte(keyPEM),
},
}
if err := r.Create(ctx, newSec); err != nil {
return fmt.Errorf("create tls secret %s/%s failed: %w", targetNS, secretName, err)
}
continue
} else if getErr != nil {
return fmt.Errorf("get secret %s/%s failed: %w", targetNS, secretName, getErr)
}
if hasInline {
// 规范化证书和私钥格式
certPEM := normalizePEM(tls.Certificate, "CERTIFICATE")
keyPEM := normalizePEM(tls.PrivateKey, "PRIVATE KEY")
if existing.Type != core_v1.SecretTypeTLS ||
!bytes.Equal(existing.Data[core_v1.TLSCertKey], []byte(certPEM)) ||
!bytes.Equal(existing.Data[core_v1.TLSPrivateKeyKey], []byte(keyPEM)) {
if existing.Data == nil {
existing.Data = map[string][]byte{}
}
existing.Type = core_v1.SecretTypeTLS
existing.Data[core_v1.TLSCertKey] = []byte(certPEM)
existing.Data[core_v1.TLSPrivateKeyKey] = []byte(keyPEM)
if existing.Labels == nil {
existing.Labels = map[string]string{}
}
existing.Labels["app.k8s.devstar/name"] = app.Name
existing.Labels["app.k8s.devstar/type"] = "gateway-tls"
if err := r.Update(ctx, existing); err != nil {
return fmt.Errorf("update tls secret %s/%s failed: %w", targetNS, secretName, err)
}
}
}
}
return nil
}
// detectPrivateKeyType 检测私钥的原始格式类型
// 返回 "RSA PRIVATE KEY" 或 "PRIVATE KEY"
func detectPrivateKeyType(content string) string {
contentUpper := strings.ToUpper(content)
if strings.Contains(contentUpper, "-----BEGIN RSA PRIVATE KEY-----") {
return "RSA PRIVATE KEY"
}
if strings.Contains(contentUpper, "-----BEGIN PRIVATE KEY-----") {
return "PRIVATE KEY"
}
if strings.Contains(contentUpper, "-----BEGIN EC PRIVATE KEY-----") {
return "EC PRIVATE KEY"
}
// 默认返回 PRIVATE KEY
return "PRIVATE KEY"
}
// normalizePEM 规范化 PEM 格式的证书或私钥
// 支持两种输入格式:
// 1. 只包含 base64 内容(没有 BEGIN/END自动添加标记并格式化
// 2. 完整 PEM 格式(包含 BEGIN/END提取内容并重新格式化为标准格式
// 对于私钥,如果 pemType 是 "PRIVATE KEY"会自动检测原始格式RSA PRIVATE KEY 或 PRIVATE KEY并保持
func normalizePEM(content, pemType string) string {
content = strings.TrimSpace(content)
if content == "" {
return ""
}
// 对于私钥,如果指定为 "PRIVATE KEY",自动检测原始格式
if pemType == "PRIVATE KEY" && strings.Contains(strings.ToUpper(content), "-----BEGIN") {
detectedType := detectPrivateKeyType(content)
if detectedType != "PRIVATE KEY" {
// 使用检测到的原始格式
pemType = detectedType
}
}
// 如果已经包含 BEGIN 标记,需要提取 BEGIN 和 END 之间的内容
if strings.Contains(content, "-----BEGIN") {
// 特别处理证书链:保留所有 CERTIFICATE 分段
if pemType == "CERTIFICATE" {
var blocks []string
search := content
for {
beginIdx := strings.Index(search, "-----BEGIN CERTIFICATE-----")
if beginIdx == -1 {
break
}
searchFrom := search[beginIdx+len("-----BEGIN CERTIFICATE-----"):]
endMarker := "-----END CERTIFICATE-----"
endIdx := strings.Index(searchFrom, endMarker)
if endIdx == -1 {
// 没有匹配的 END使用 BEGIN 之后的所有内容作为最后一段
body := searchFrom
blocks = append(blocks, body)
break
}
body := searchFrom[:endIdx]
blocks = append(blocks, body)
// 继续在 END 之后搜索下一段
nextStart := endIdx + len(endMarker)
if nextStart >= len(searchFrom) {
break
}
search = searchFrom[nextStart:]
}
if len(blocks) > 0 {
return buildPEMChainFromContent(blocks, "CERTIFICATE")
}
// 如果没有成功解析到分段,退化为单段处理
}
// 非证书链,按单段处理
// 对于私钥,需要匹配对应的 BEGIN/END 标记
if pemType == "RSA PRIVATE KEY" || pemType == "EC PRIVATE KEY" || pemType == "PRIVATE KEY" {
// 查找对应的 BEGIN 标记
beginMarker := fmt.Sprintf("-----BEGIN %s-----", pemType)
beginIdx := strings.Index(strings.ToUpper(content), strings.ToUpper(beginMarker))
if beginIdx == -1 {
// 如果找不到精确匹配,尝试查找任何 BEGIN 标记
beginIdx = strings.Index(strings.ToUpper(content), "-----BEGIN")
}
if beginIdx == -1 {
return buildPEMSingleFromContent(content, pemType)
}
// 计算 BEGIN 标记行的结束位置
// 先尝试查找换行符
beginLineEnd := strings.Index(content[beginIdx:], "\n")
if beginLineEnd == -1 {
beginLineEnd = strings.Index(content[beginIdx:], "\r")
}
if beginLineEnd == -1 {
// 如果没有换行符,直接使用 BEGIN 标记的长度
// 这样可以避免在单行格式中错误地匹配到 END 标记
beginLineEnd = len(beginMarker)
} else {
// 找到了换行符beginLineEnd 是相对于 beginIdx 的偏移
beginLineEnd += 1 // 包含换行符
}
// 查找对应的 END 标记
endMarker := fmt.Sprintf("-----END %s-----", pemType)
searchStart := beginIdx + beginLineEnd
if searchStart > len(content) {
searchStart = len(content)
}
endIdx := strings.Index(strings.ToUpper(content[searchStart:]), strings.ToUpper(endMarker))
if endIdx == -1 {
// 如果找不到精确匹配,尝试查找任何 END 标记
endIdx = strings.Index(strings.ToUpper(content[searchStart:]), "-----END")
}
if endIdx == -1 {
// 如果找不到 END提取 BEGIN 之后的所有内容
bodyContent := content[beginIdx+beginLineEnd:]
return buildPEMSingleFromContent(bodyContent, pemType)
}
// 提取 BEGIN 行结束和 END 标记开始之间的内容
bodyContent := content[beginIdx+beginLineEnd : searchStart+endIdx]
// 移除可能包含的 END 标记前缀(防止单行格式时误包含)
bodyContent = strings.TrimSpace(bodyContent)
if strings.HasPrefix(strings.ToUpper(bodyContent), "-----END") {
// 如果 bodyContent 以 END 标记开头,说明提取错误,需要重新提取
endMarkerStart := strings.Index(strings.ToUpper(bodyContent), strings.ToUpper(endMarker))
if endMarkerStart != -1 {
bodyContent = bodyContent[:endMarkerStart]
} else {
// 如果找不到完整的 END 标记,尝试查找 "-----END" 的位置
endDashIdx := strings.Index(strings.ToUpper(bodyContent), "-----END")
if endDashIdx != -1 {
bodyContent = bodyContent[:endDashIdx]
}
}
}
return buildPEMSingleFromContent(bodyContent, pemType)
}
// 其他类型(证书等)的原有逻辑
// 查找 BEGIN 标记的结束位置(包含完整标记行)
beginIdx := strings.Index(content, "-----BEGIN")
if beginIdx == -1 {
return buildPEMSingleFromContent(content, pemType)
}
// 找到 BEGIN 行结束(下一个换行符,或字符串结束)
beginLineEnd := strings.Index(content[beginIdx:], "\n")
if beginLineEnd == -1 {
beginLineEnd = strings.Index(content[beginIdx:], "\r")
}
if beginLineEnd == -1 {
// 如果没有换行,查找下一个 "-----" 作为结束
nextDash := strings.Index(content[beginIdx+10:], "-----")
if nextDash != -1 {
beginLineEnd = beginIdx + 10 + nextDash + 5
} else {
beginLineEnd = len(content)
}
} else {
beginLineEnd += beginIdx + 1
}
// 查找 END 标记
endPattern := "-----END"
searchStart := beginLineEnd
if searchStart > len(content) {
searchStart = len(content)
}
endIdx := strings.Index(content[searchStart:], endPattern)
if endIdx == -1 {
// 如果找不到 END提取 BEGIN 之后的所有内容
bodyContent := content[beginLineEnd:]
return buildPEMSingleFromContent(bodyContent, pemType)
}
// 提取 BEGIN 行结束和 END 标记开始之间的内容
bodyContent := content[beginLineEnd : searchStart+endIdx]
// 清理并重新格式化
return buildPEMSingleFromContent(bodyContent, pemType)
}
// 如果没有 BEGIN 标记,直接格式化
return buildPEMSingleFromContent(content, pemType)
}
// buildPEMSingleFromContent 从清理后的内容构建单段标准 PEM 格式
// 只移除空白字符,保留所有实际的 base64 内容
func buildPEMSingleFromContent(content, pemType string) string {
// 移除所有空格、换行符、制表符等空白字符
cleaned := strings.ReplaceAll(content, " ", "")
cleaned = strings.ReplaceAll(cleaned, "\n", "")
cleaned = strings.ReplaceAll(cleaned, "\r", "")
cleaned = strings.ReplaceAll(cleaned, "\t", "")
cleaned = strings.TrimSpace(cleaned)
if cleaned == "" {
return ""
}
// 构建标准 PEM 格式
var pem strings.Builder
pem.WriteString("-----BEGIN ")
pem.WriteString(pemType)
pem.WriteString("-----\n")
// 每 64 字符一行
for i := 0; i < len(cleaned); i += 64 {
end := i + 64
if end > len(cleaned) {
end = len(cleaned)
}
pem.WriteString(cleaned[i:end])
pem.WriteString("\n")
}
pem.WriteString("-----END ")
pem.WriteString(pemType)
pem.WriteString("-----\n")
return pem.String()
}
// buildPEMChainFromContent 根据多段内容构建包含多段的 PEM 证书链
func buildPEMChainFromContent(blockBodies []string, pemType string) string {
var b strings.Builder
for _, body := range blockBodies {
seg := buildPEMSingleFromContent(body, pemType)
if seg == "" {
continue
}
b.WriteString(seg)
}
return b.String()
}
// reconcileGatewayVirtualService 处理与Gateway关联的VirtualService
func (r *ApplicationReconciler) reconcileGatewayVirtualService(ctx context.Context, app *applicationv1.Application) error {
func (r *ApplicationReconciler) reconcileGatewayVirtualService(ctx context.Context, app *applicationv1.Application, gatewayNamespace string) error {
logger := log.FromContext(ctx)
vsName := app.Name + "-gateway-vs"
gatewayName := app.Name + "-gateway"
gatewayRef := gatewayName
if gatewayNamespace != "" && gatewayNamespace != app.Namespace {
gatewayRef = fmt.Sprintf("%s/%s", gatewayNamespace, gatewayName)
}
// 检查服务是否存在
serviceName := app.Name + "-svc"
@@ -1205,7 +1735,7 @@ func (r *ApplicationReconciler) reconcileGatewayVirtualService(ctx context.Conte
if errors.IsNotFound(err) {
logger.Info("Creating new Gateway VirtualService", "name", vsName)
newVS := &istionetworkingv1.VirtualService{ObjectMeta: metav1.ObjectMeta{Name: vsName, Namespace: app.Namespace}}
if err := r.configureGatewayVirtualService(newVS, app, service); err != nil {
if err := r.configureGatewayVirtualService(newVS, app, service, gatewayRef); err != nil {
return fmt.Errorf("failed to configure VirtualService: %w", err)
}
if err := k8s_sigs_controller_runtime_utils.SetControllerReference(app, newVS, r.Scheme); err != nil {
@@ -1218,7 +1748,7 @@ func (r *ApplicationReconciler) reconcileGatewayVirtualService(ctx context.Conte
} else if err != nil {
return fmt.Errorf("failed to get VirtualService: %w", err)
} else {
if err := r.configureGatewayVirtualService(existingVS, app, service); err != nil {
if err := r.configureGatewayVirtualService(existingVS, app, service, gatewayRef); err != nil {
return fmt.Errorf("failed to configure VirtualService: %w", err)
}
if err := r.Update(ctx, existingVS); err != nil {
@@ -1231,10 +1761,10 @@ func (r *ApplicationReconciler) reconcileGatewayVirtualService(ctx context.Conte
}
// configureGatewayVirtualService 配置与Gateway关联的VirtualService
func (r *ApplicationReconciler) configureGatewayVirtualService(vs *istionetworkingv1.VirtualService, app *applicationv1.Application, service *core_v1.Service) error {
func (r *ApplicationReconciler) configureGatewayVirtualService(vs *istionetworkingv1.VirtualService, app *applicationv1.Application, service *core_v1.Service, gatewayRef string) error {
// 设置基本字段
vs.Spec.Hosts = getHosts(app)
vs.Spec.Gateways = []string{app.Name + "-gateway"}
vs.Spec.Gateways = []string{gatewayRef}
// 创建HTTP路由
httpRoute := &istioapinetworkingv1.HTTPRoute{

View File

@@ -55,6 +55,26 @@ func NewDeployment(app *applicationv1.Application) (*apps_v1.Deployment, error)
deployment.Name = app.Name
deployment.Namespace = app.Namespace
// 默认启用 Istio sidecar 注入(除非明确禁用)
shouldInjectSidecar := true
if app.Spec.NetworkPolicy != nil && app.Spec.NetworkPolicy.Mesh != nil && app.Spec.NetworkPolicy.Mesh.Sidecar != nil {
// 如果明确配置了 sidecar使用配置的值
shouldInjectSidecar = app.Spec.NetworkPolicy.Mesh.Sidecar.Inject
}
if shouldInjectSidecar {
// 添加注解触发 sidecar 注入
if deployment.Spec.Template.Annotations == nil {
deployment.Spec.Template.Annotations = make(map[string]string)
}
deployment.Spec.Template.Annotations["sidecar.istio.io/inject"] = "true"
// 添加标签确保 webhook 能够匹配Istio 1.27+ 需要)
if deployment.Spec.Template.Labels == nil {
deployment.Spec.Template.Labels = make(map[string]string)
}
deployment.Spec.Template.Labels["istio.io/rev"] = "default"
}
return deployment, nil
}
@@ -105,6 +125,26 @@ func NewStatefulSet(app *applicationv1.Application) (*apps_v1.StatefulSet, error
statefulSet.Name = app.Name
statefulSet.Namespace = app.Namespace
// 默认启用 Istio sidecar 注入(除非明确禁用)
shouldInjectSidecar := true
if app.Spec.NetworkPolicy != nil && app.Spec.NetworkPolicy.Mesh != nil && app.Spec.NetworkPolicy.Mesh.Sidecar != nil {
// 如果明确配置了 sidecar使用配置的值
shouldInjectSidecar = app.Spec.NetworkPolicy.Mesh.Sidecar.Inject
}
if shouldInjectSidecar {
// 添加注解触发 sidecar 注入
if statefulSet.Spec.Template.Annotations == nil {
statefulSet.Spec.Template.Annotations = make(map[string]string)
}
statefulSet.Spec.Template.Annotations["sidecar.istio.io/inject"] = "true"
// 添加标签确保 webhook 能够匹配Istio 1.27+ 需要)
if statefulSet.Spec.Template.Labels == nil {
statefulSet.Spec.Template.Labels = make(map[string]string)
}
statefulSet.Spec.Template.Labels["istio.io/rev"] = "default"
}
return statefulSet, nil
}

View File

@@ -382,9 +382,6 @@ func AppStoreUninstall(ctx *context.Context) {
// 解析表单数据
appID := ctx.FormString("app_id")
installTarget := ctx.FormString("install_target")
kubeconfig := ctx.FormString("kubeconfig")
kubeconfigContext := ctx.FormString("kubeconfig_context")
if appID == "" {
ctx.JSON(400, map[string]string{"error": "应用ID不能为空"})
@@ -392,8 +389,9 @@ func AppStoreUninstall(ctx *context.Context) {
}
// 创建 manager 并执行卸载
// UninstallApp 会自动从数据库读取 kubeconfig 判断是外部集群还是本地集群
manager := appstore.NewManager(ctx, ctx.Doer.ID)
if err := manager.UninstallApp(appID, installTarget, kubeconfig, kubeconfigContext); err != nil {
if err := manager.UninstallApp(appID); err != nil {
// 根据错误类型返回相应的状态码和消息
if appErr, ok := err.(*appstore.AppStoreError); ok {
switch appErr.Code {
@@ -413,11 +411,7 @@ func AppStoreUninstall(ctx *context.Context) {
}
// 卸载成功
if installTarget == "kubeconfig" && kubeconfig != "" {
ctx.Flash.Success("应用已成功从指定位置卸载")
} else {
ctx.Flash.Success("应用已成功从默认位置卸载")
}
ctx.Flash.Success("应用已成功卸载")
// 根据来源页面决定重定向位置
referer := ctx.Req.Header.Get("Referer")
@@ -615,8 +609,8 @@ func AppStoreDelete(ctx *context.Context) {
return
}
// 解析表单数据
appID := ctx.FormString("app_id")
// 从路径参数获取应用ID
appID := ctx.PathParam("app_id")
if appID == "" {
ctx.JSON(400, map[string]string{"error": "应用ID不能为空"})

View File

@@ -713,6 +713,7 @@ func registerWebRoutes(m *web.Router) {
m.Post("/stop/{app_id}", user_setting.AppStoreStop)
m.Post("/resume/{app_id}", user_setting.AppStoreResume)
m.Post("/uninstall/{app_id}", user_setting.AppStoreUninstall)
m.Post("/delete/{app_id}", user_setting.AppStoreDelete)
m.Get("/configure/{app_id}", user_setting.AppStoreConfigure)
m.Post("/configure/{app_id}", user_setting.AppStoreConfigurePost)
})

View File

@@ -0,0 +1,139 @@
{
"id": "mengningsoftware-2",
"name": "mengningsoftware-2",
"description": "High-performance HTTP server and reverse proxy",
"category": "web-server",
"tags": ["web", "proxy", "http", "server"],
"icon": "https://nginx.org/favicon.ico",
"author": "Nginx Inc.",
"website": "https://nginx.org",
"repository": "https://github.com/nginx/nginx",
"license": "BSD-2-Clause",
"version": "1.24.0",
"deployment_type": "both",
"config": {
"schema": {
"ssl_enabled": {
"type": "bool",
"description": "Enable SSL/TLS support",
"required": false,
"help": "Enable HTTPS support"
},
"domains": {
"type": "string",
"description": "域名(逗号分隔,如 a.com,b.com)",
"required": false
},
"enable_https": {
"type": "bool",
"description": "启用 HTTPS",
"required": false
},
"tls_certificate": {
"type": "string",
"description": "证书(PEM-----BEGIN CERTIFICATE-----...)",
"required": false
},
"tls_private_key": {
"type": "string",
"description": "私钥(PEM-----BEGIN PRIVATE KEY-----...)",
"required": false
},
"https_min_tls": {
"type": "select",
"description": "最低 TLS 版本",
"options": ["TLSv1_2", "TLSv1_3"],
"required": false
}
},
"default": {
"ssl_enabled": false,
"domains": "",
"enable_https": false,
"https_min_tls": "TLSv1_2"
}
},
"requirements": {
"min_memory": "128MB",
"min_cpu": "1 core",
"min_storage": "100MB",
"os": "linux",
"arch": "x86_64"
},
"deploy": {
"docker": {
"image": "devstar.cn/devstar/devstar-studio-docs",
"tag": "latest",
"ports": [
{
"host_port": 80,
"container_port": 80,
"protocol": "tcp",
"name": "http"
},
{
"host_port": 443,
"container_port": 443,
"protocol": "tcp",
"name": "https"
}
],
"volumes": [
{
"host_path": "/var/log/nginx",
"container_path": "/var/log/nginx",
"type": "bind"
},
{
"host_path": "/etc/nginx/conf.d",
"container_path": "/etc/nginx/conf.d",
"type": "bind"
}
],
"environment": {
"NGINX_VERSION": "1.24.0"
},
"resources": {
"cpu": "500m",
"memory": "512Mi"
}
},
"kubernetes": {
"namespace": "web-servers",
"replicas": 2,
"image": "devstar.cn/devstar/devstar-studio-docs",
"tag": "latest",
"ports": [
{
"container_port": 80,
"protocol": "tcp",
"name": "http"
}
],
"volumes": [
{
"container_path": "/var/log/nginx",
"type": "emptyDir"
}
],
"environment": {
"NGINX_VERSION": "1.24.0"
},
"resources": {
"cpu": "500m",
"memory": "512Mi"
},
"service": {
"type": "ClusterIP",
"ports": [
{
"container_port": 80,
"protocol": "tcp",
"name": "http"
}
]
}
}
}
}

View File

@@ -1,6 +1,6 @@
{
"id": "nginx-9",
"name": "Nginx Web Server-9",
"id": "nginx-12",
"name": "Nginx Web Server-12",
"description": "High-performance HTTP server and reverse proxy",
"category": "web-server",
"tags": ["web", "proxy", "http", "server"],
@@ -12,40 +12,46 @@
"version": "1.24.0",
"deployment_type": "both",
"config": {
"schema": {
"port": {
"type": "int",
"description": "Port number for the web server",
"required": true,
"min": 1,
"max": 65535,
"placeholder": "Enter port number"
},
"server_name": {
"type": "string",
"description": "Server name for virtual host",
"required": false,
"pattern": "^[a-zA-Z0-9.-]+$",
"placeholder": "Enter server name"
},
"schema": {
"ssl_enabled": {
"type": "bool",
"description": "Enable SSL/TLS support",
"required": false,
"help": "Enable HTTPS support"
},
"log_level": {
"domains": {
"type": "string",
"description": "域名(逗号分隔,如 a.com,b.com)",
"required": false
},
"enable_https": {
"type": "bool",
"description": "启用 HTTPS",
"required": false
},
"tls_certificate": {
"type": "string",
"description": "证书(PEM-----BEGIN CERTIFICATE-----...)",
"required": false
},
"tls_private_key": {
"type": "string",
"description": "私钥(PEM-----BEGIN PRIVATE KEY-----...)",
"required": false
},
"https_min_tls": {
"type": "select",
"description": "Logging level",
"required": false,
"options": ["debug", "info", "warn", "error"]
"description": "最低 TLS 版本",
"options": ["TLSv1_2", "TLSv1_3"],
"required": false
}
},
"default": {
"port": 80,
"server_name": "localhost",
"default": {
"ssl_enabled": false,
"log_level": "info"
"domains": "",
"enable_https": false,
"https_min_tls": "TLSv1_2"
}
},
"requirements": {

View File

@@ -1,6 +1,7 @@
package appstore
import (
"encoding/json"
"fmt"
"strings"
@@ -33,19 +34,115 @@ func BuildK8sCreateOptions(app *App) (*application.CreateApplicationOptions, err
namespace = "default"
}
// Image with optional tag
// 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
// 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"
@@ -124,6 +221,63 @@ func BuildK8sCreateOptions(app *App) (*application.CreateApplicationOptions, err
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
}
@@ -204,17 +358,108 @@ func BuildK8sUpdateOptions(app *App, namespaceOverride string, existing *applica
// 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"
@@ -273,12 +518,65 @@ func BuildK8sUpdateOptions(app *App, namespaceOverride string, existing *applica
Ports: tplPorts,
},
Replicas: replicasPtr,
Environment: k.Environment,
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,

View File

@@ -379,18 +379,28 @@ func (m *Manager) UpdateApp(app *App) error {
return nil
}
// RemoveApp removes a user's application instance from the database
// RemoveApp removes an application template from the database
func (m *Manager) RemoveApp(appID string) error {
// 删除用户的应用实例
if err := user_app_instance.DeleteUserAppInstanceByAppID(m.ctx, m.userID, appID); err != nil {
// 获取应用模板记录
appStore, err := appstore_model.GetAppStoreByAppID(m.ctx, appID)
if err != nil {
return &AppStoreError{
Code: "DATABASE_ERROR",
Message: "删除应用实例失败",
Code: "APP_NOT_FOUND",
Message: "应用模板不存在",
Details: err.Error(),
}
}
log.Info("Successfully removed app instance %s for user %d", appID, m.userID)
// 删除应用模板
if err := appstore_model.DeleteAppStore(m.ctx, appStore.ID); err != nil {
return &AppStoreError{
Code: "DATABASE_ERROR",
Message: "删除应用模板失败",
Details: err.Error(),
}
}
log.Info("Successfully removed app template %s", appID)
return nil
}
@@ -629,8 +639,10 @@ func (m *Manager) UpdateInstalledApp(appID string, configJSON string, installTar
return nil
}
// UninstallApp uninstalls an application based on the provided parameters
func (m *Manager) UninstallApp(appID string, installTarget string, kubeconfig string, kubeconfigContext string) error {
// UninstallApp uninstalls an application
// It automatically determines whether to uninstall from external cluster or local cluster
// based on the kubeconfig stored in the database instance
func (m *Manager) UninstallApp(appID string) error {
// 获取用户的应用实例
instance, err := user_app_instance.GetUserAppInstanceByAppID(m.ctx, m.userID, appID)
if err != nil {
@@ -657,11 +669,14 @@ func (m *Manager) UninstallApp(appID string, installTarget string, kubeconfig st
}
}
// 根据安装目标和应用实际部署类型决定卸载方式
if installTarget == "kubeconfig" && kubeconfig != "" {
// 根据数据库实例中保存的 kubeconfig 判断卸载方式
// 如果数据库中有 kubeconfig说明是安装在外部集群的使用外部集群卸载
// 如果数据库中没有 kubeconfig说明是安装在本地集群的使用本地卸载
if instance.Kubeconfig != "" {
// 从外部 Kubernetes 集群卸载
if instance.DeployType == "kubernetes" {
if err := m.UninstallAppFromKubernetes(&app, []byte(kubeconfig), kubeconfigContext); err != nil {
log.Info("UninstallApp: Uninstalling from external cluster, appID=%s, kubeconfig length=%d", appID, len(instance.Kubeconfig))
if err := m.UninstallAppFromKubernetes(&app, []byte(instance.Kubeconfig), instance.KubeconfigContext); err != nil {
return &AppStoreError{
Code: "KUBERNETES_UNINSTALL_ERROR",
Message: "Kubernetes 卸载失败",
@@ -677,6 +692,7 @@ func (m *Manager) UninstallApp(appID string, installTarget string, kubeconfig st
} else {
// 本地卸载(依据实例中保存的部署类型)
if instance.DeployType == "kubernetes" {
log.Info("UninstallApp: Uninstalling from local cluster, appID=%s", appID)
if err := m.UninstallAppFromKubernetes(&app, nil, ""); err != nil {
return &AppStoreError{
Code: "KUBERNETES_UNINSTALL_ERROR",

View File

@@ -352,6 +352,7 @@
<div class="ui primary button" onclick="updateInstalledAppFromDetails()">更新应用</div>
<div class="ui orange button" id="pause-resume-btn" onclick="togglePauseResumeFromDetails()">暂停应用</div>
<div class="ui red button" onclick="uninstallCurrentAppFromDetails()">卸载应用</div>
<div class="ui red button" id="delete-app-btn" onclick="deleteAppFromDetails()">删除应用</div>
</div>
</div>
@@ -1106,6 +1107,37 @@ function uninstallCurrentAppFromDetails() {
uninstallForm.submit();
}
// 删除应用(从详情弹窗触发)
function deleteAppFromDetails() {
if (!currentApp) return;
const appId = currentApp.id || currentApp.app_id;
const appName = currentApp.name || appId;
// 确认删除
if (!confirm(`确定要删除应用 "${appName}" 吗?\n\n注意这将从应用商店中删除该应用模板但不会影响已安装的应用实例。`)) {
return;
}
const deleteForm = document.createElement('form');
deleteForm.method = 'POST';
deleteForm.action = `/user/settings/appstore/delete/${appId}`;
const csrfInput = document.createElement('input');
csrfInput.type = 'hidden';
csrfInput.name = '_csrf';
csrfInput.value = document.querySelector('meta[name="_csrf"]')?.content || '';
deleteForm.appendChild(csrfInput);
const redirectInput = document.createElement('input');
redirectInput.type = 'hidden';
redirectInput.name = 'redirect_to';
redirectInput.value = window.location.pathname;
deleteForm.appendChild(redirectInput);
document.body.appendChild(deleteForm);
deleteForm.submit();
}
// 暂停/恢复应用(从详情弹窗触发)
function togglePauseResumeFromDetails() {
if (!currentApp) return;
@@ -1203,12 +1235,15 @@ function updateActionButtonsVisibility() {
const updateBtn = document.querySelector('#app-details-modal .ui.primary.button[onclick="updateInstalledAppFromDetails()"]');
const pauseResumeBtn = document.getElementById('pause-resume-btn');
const uninstallBtn = document.querySelector('#app-details-modal .ui.red.button[onclick="uninstallCurrentAppFromDetails()"]');
const deleteBtn = document.getElementById('delete-app-btn');
if (!currentAppStatus) {
// 状态未知,隐藏所有操作按钮
// 状态未知,隐藏所有操作按钮(除了删除按钮,因为未安装时应该显示)
if (updateBtn) updateBtn.style.display = 'none';
if (pauseResumeBtn) pauseResumeBtn.style.display = 'none';
if (uninstallBtn) uninstallBtn.style.display = 'none';
// 状态未知时,默认显示删除按钮(假设未安装)
if (deleteBtn) deleteBtn.style.display = 'inline-block';
return;
}
@@ -1242,6 +1277,11 @@ function updateActionButtonsVisibility() {
if (uninstallBtn) {
uninstallBtn.style.display = isInstalled ? 'inline-block' : 'none';
}
// 删除按钮:只有未安装的应用才能删除(删除的是应用模板)
if (deleteBtn) {
deleteBtn.style.display = isInstalled ? 'none' : 'inline-block';
}
}
async function installApp() {