完成应用商店应用对证书的支持
This commit is contained in:
@@ -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 定义服务网格相关配置
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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不能为空"})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
139
services/appstore/examples/mengning.json
Normal file
139
services/appstore/examples/mengning.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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": {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user