489 lines
13 KiB
Go
489 lines
13 KiB
Go
// Copyright 2024 The Devstar Authors. All rights reserved.
|
||
// SPDX-License-Identifier: MIT
|
||
|
||
package appstore
|
||
|
||
import (
|
||
"encoding/json"
|
||
"fmt"
|
||
"regexp"
|
||
"strings"
|
||
|
||
"code.gitea.io/gitea/modules/log"
|
||
)
|
||
|
||
// Parser handles JSON parsing and validation for app store
|
||
type Parser struct {
|
||
}
|
||
|
||
// NewParser creates a new parser instance
|
||
func NewParser() *Parser {
|
||
return &Parser{}
|
||
}
|
||
|
||
// MergeUserConfig merges user configuration with app's default configuration
|
||
// Returns a complete App structure with merged configuration
|
||
func (p *Parser) MergeUserConfig(app *App, userConfig UserConfig) (*App, error) {
|
||
if userConfig.AppID != app.ID {
|
||
return nil, &AppStoreError{
|
||
Code: "CONFIG_ERROR",
|
||
Message: "User config app ID does not match app ID",
|
||
}
|
||
}
|
||
|
||
if userConfig.Version != app.Version {
|
||
return nil, &AppStoreError{
|
||
Code: "CONFIG_ERROR",
|
||
Message: "User config version does not match app version",
|
||
}
|
||
}
|
||
|
||
// 创建 App 的副本,避免修改原始数据
|
||
mergedApp := *app
|
||
|
||
// 只用AppConfig.Default作为默认值来源
|
||
mergedConfig := make(map[string]interface{})
|
||
for fieldName, defaultValue := range app.Config.Default {
|
||
mergedConfig[fieldName] = defaultValue
|
||
}
|
||
|
||
// 用户输入覆盖默认值
|
||
for fieldName, value := range userConfig.Values {
|
||
if field, exists := app.Config.Schema[fieldName]; exists {
|
||
if err := p.validateConfigValue(fieldName, field, value); err != nil {
|
||
return nil, err
|
||
}
|
||
mergedConfig[fieldName] = value
|
||
} else {
|
||
log.Warn("Unknown configuration field: %s", fieldName)
|
||
}
|
||
}
|
||
|
||
// 检查必填字段
|
||
for fieldName, field := range app.Config.Schema {
|
||
if field.Required {
|
||
if _, exists := mergedConfig[fieldName]; !exists {
|
||
return nil, &AppStoreError{
|
||
Code: "VALIDATION_ERROR",
|
||
Message: fmt.Sprintf("Required field missing: %s", fieldName),
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 更新 App 的 Config.Default 字段为合并后的配置
|
||
mergedApp.Config.Default = mergedConfig
|
||
|
||
return &mergedApp, nil
|
||
}
|
||
|
||
// ParseApp 从原始 JSON 字节流解析 App
|
||
func (p *Parser) ParseApp(jsonBytes []byte) (*App, error) {
|
||
var app App
|
||
if err := json.Unmarshal(jsonBytes, &app); err != nil {
|
||
return nil, &AppStoreError{
|
||
Code: "INVALID_JSON",
|
||
Message: "应用JSON格式错误",
|
||
Details: err.Error(),
|
||
}
|
||
}
|
||
if err := p.validateApp(&app); err != nil {
|
||
return nil, err
|
||
}
|
||
return &app, nil
|
||
}
|
||
|
||
// ParseAndMergeApp 解析 JSON 并将结果按规则覆盖到 baseApp 上(DB 字段保持优先,仅覆盖功能性域)
|
||
func (p *Parser) ParseAndMergeApp(jsonBytes []byte, baseApp *App) (*App, error) {
|
||
if baseApp == nil {
|
||
return nil, &AppStoreError{Code: "INTERNAL", Message: "baseApp is nil"}
|
||
}
|
||
parsed, err := p.ParseApp(jsonBytes)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
out := *baseApp
|
||
// 仅覆盖功能域,ID/Name/Category/Version/DeploymentType 等以 DB 为准
|
||
out.Config = parsed.Config
|
||
out.Deploy = parsed.Deploy
|
||
out.Requirements = parsed.Requirements
|
||
return &out, nil
|
||
}
|
||
|
||
// ParseAndMergeUserConfig 解析用户配置JSON字符串并与应用配置合并
|
||
func (p *Parser) ParseAndMergeUserConfig(app *App, configJSON string) (*App, error) {
|
||
if app == nil {
|
||
return nil, &AppStoreError{
|
||
Code: "INTERNAL_ERROR",
|
||
Message: "应用对象不能为空",
|
||
}
|
||
}
|
||
|
||
// 如果用户没有提供配置,直接返回应用副本
|
||
if configJSON == "" {
|
||
mergedApp := *app
|
||
return &mergedApp, nil
|
||
}
|
||
|
||
// 解析用户配置JSON
|
||
var userConfigValues map[string]interface{}
|
||
if err := json.Unmarshal([]byte(configJSON), &userConfigValues); err != nil {
|
||
return nil, &AppStoreError{
|
||
Code: "CONFIG_PARSE_ERROR",
|
||
Message: "配置JSON格式错误",
|
||
Details: err.Error(),
|
||
}
|
||
}
|
||
|
||
// 创建应用副本
|
||
mergedApp := *app
|
||
|
||
// 处理 deploy 字段
|
||
if deployValue, exists := userConfigValues["deploy"]; exists {
|
||
log.Info("ParseAndMergeUserConfig: found deploy field: %v", deployValue)
|
||
if deployMap, ok := deployValue.(map[string]interface{}); ok {
|
||
if deployType, ok := deployMap["type"].(string); ok {
|
||
log.Info("ParseAndMergeUserConfig: setting Deploy.Type to: %s", deployType)
|
||
mergedApp.Deploy.Type = deployType
|
||
|
||
// 如果选择 Kubernetes 部署,确保 Deploy.Kubernetes 字段存在
|
||
if deployType == "kubernetes" && mergedApp.Deploy.Kubernetes == nil {
|
||
log.Info("ParseAndMergeUserConfig: creating default Kubernetes deployment config")
|
||
|
||
// 尝试从 Docker 配置中复制基本信息
|
||
var image, tag string
|
||
if mergedApp.Deploy.Docker != nil {
|
||
image = mergedApp.Deploy.Docker.Image
|
||
tag = mergedApp.Deploy.Docker.Tag
|
||
}
|
||
|
||
// 如果 Docker 配置也没有,使用应用名称作为默认镜像
|
||
if image == "" {
|
||
image = strings.ToLower(mergedApp.Name)
|
||
}
|
||
if tag == "" {
|
||
tag = mergedApp.Version
|
||
}
|
||
|
||
mergedApp.Deploy.Kubernetes = &K8sDeploy{
|
||
Namespace: "default",
|
||
Replicas: 1,
|
||
Image: image,
|
||
Tag: tag,
|
||
}
|
||
}
|
||
}
|
||
}
|
||
// 从 userConfigValues 中移除 deploy 字段,避免被当作普通配置值处理
|
||
delete(userConfigValues, "deploy")
|
||
} else {
|
||
log.Info("ParseAndMergeUserConfig: no deploy field found in user config")
|
||
}
|
||
|
||
// 创建用户配置对象(只包含普通配置值)
|
||
userConfig := UserConfig{
|
||
AppID: app.ID,
|
||
Version: app.Version,
|
||
Values: userConfigValues,
|
||
}
|
||
|
||
// 使用现有的合并逻辑处理普通配置值
|
||
mergedAppWithConfig, err := p.MergeUserConfig(&mergedApp, userConfig)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
return mergedAppWithConfig, nil
|
||
}
|
||
|
||
// validateApp validates an application configuration
|
||
func (p *Parser) validateApp(app *App) error {
|
||
if app.ID == "" {
|
||
return &AppStoreError{
|
||
Code: "VALIDATION_ERROR",
|
||
Message: "App ID is required",
|
||
}
|
||
}
|
||
|
||
if app.Name == "" {
|
||
return &AppStoreError{
|
||
Code: "VALIDATION_ERROR",
|
||
Message: fmt.Sprintf("App name is required for app: %s", app.ID),
|
||
}
|
||
}
|
||
|
||
if app.Category == "" {
|
||
return &AppStoreError{
|
||
Code: "VALIDATION_ERROR",
|
||
Message: fmt.Sprintf("App category is required for app: %s", app.ID),
|
||
}
|
||
}
|
||
|
||
if app.Version == "" {
|
||
return &AppStoreError{
|
||
Code: "VALIDATION_ERROR",
|
||
Message: fmt.Sprintf("App version is required for app: %s", app.ID),
|
||
}
|
||
}
|
||
|
||
if err := p.validateVersionFormat(app.Version); err != nil {
|
||
return err
|
||
}
|
||
|
||
if err := p.validateAppConfig(&app.Config); err != nil {
|
||
return err
|
||
}
|
||
|
||
if err := p.validateAppDeploy(&app.Deploy); err != nil {
|
||
return err
|
||
}
|
||
|
||
// Ensure deployment_type supports the selected deploy.type
|
||
deployType := strings.ToLower(strings.TrimSpace(app.Deploy.Type))
|
||
deploymentType := strings.ToLower(strings.TrimSpace(app.DeploymentType))
|
||
|
||
// Validate deployment_type value when provided
|
||
if deploymentType != "" {
|
||
switch deploymentType {
|
||
case "docker", "kubernetes", "both":
|
||
// allowed
|
||
default:
|
||
return &AppStoreError{
|
||
Code: "VALIDATION_ERROR",
|
||
Message: fmt.Sprintf("Invalid deployment_type: %s (must be docker|kubernetes|both)", app.DeploymentType),
|
||
}
|
||
}
|
||
}
|
||
|
||
// Default deployment_type to deploy.type if empty (backward compatibility)
|
||
if deploymentType == "" {
|
||
if deployType == "docker" || deployType == "kubernetes" {
|
||
app.DeploymentType = deployType
|
||
deploymentType = deployType
|
||
}
|
||
}
|
||
|
||
// Cross-check: deploy.type must be included by deployment_type
|
||
switch deployType {
|
||
case "kubernetes":
|
||
if deploymentType != "kubernetes" && deploymentType != "both" {
|
||
return &AppStoreError{
|
||
Code: "VALIDATION_ERROR",
|
||
Message: "deployment_type must include 'kubernetes' when deploy.type is 'kubernetes' (use 'kubernetes' or 'both')",
|
||
}
|
||
}
|
||
case "docker":
|
||
if deploymentType != "docker" && deploymentType != "both" {
|
||
return &AppStoreError{
|
||
Code: "VALIDATION_ERROR",
|
||
Message: "deployment_type must include 'docker' when deploy.type is 'docker' (use 'docker' or 'both')",
|
||
}
|
||
}
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// validateVersionFormat validates version string format
|
||
func (p *Parser) validateVersionFormat(version string) error {
|
||
// Simple version format validation (semantic versioning)
|
||
versionRegex := regexp.MustCompile(`^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$`)
|
||
if !versionRegex.MatchString(version) {
|
||
return &AppStoreError{
|
||
Code: "VALIDATION_ERROR",
|
||
Message: fmt.Sprintf("Invalid version format: %s", version),
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// validateAppConfig validates application configuration schema
|
||
func (p *Parser) validateAppConfig(config *AppConfig) error {
|
||
if config.Schema == nil {
|
||
return &AppStoreError{
|
||
Code: "VALIDATION_ERROR",
|
||
Message: "Configuration schema is required",
|
||
}
|
||
}
|
||
|
||
for fieldName, field := range config.Schema {
|
||
if err := p.validateConfigField(fieldName, field); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// validateConfigField validates a configuration field
|
||
func (p *Parser) validateConfigField(fieldName string, field ConfigField) error {
|
||
if field.Type == "" {
|
||
return &AppStoreError{
|
||
Code: "VALIDATION_ERROR",
|
||
Message: fmt.Sprintf("Field type is required for field: %s", fieldName),
|
||
}
|
||
}
|
||
|
||
validTypes := []string{"string", "int", "bool", "select"}
|
||
validType := false
|
||
for _, t := range validTypes {
|
||
if field.Type == t {
|
||
validType = true
|
||
break
|
||
}
|
||
}
|
||
|
||
if !validType {
|
||
return &AppStoreError{
|
||
Code: "VALIDATION_ERROR",
|
||
Message: fmt.Sprintf("Invalid field type '%s' for field: %s", field.Type, fieldName),
|
||
}
|
||
}
|
||
|
||
if field.Type == "select" && len(field.Options) == 0 {
|
||
return &AppStoreError{
|
||
Code: "VALIDATION_ERROR",
|
||
Message: fmt.Sprintf("Options are required for select field: %s", fieldName),
|
||
}
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// validateAppDeploy validates application deployment configuration
|
||
func (p *Parser) validateAppDeploy(deploy *AppDeploy) error {
|
||
if deploy.Type == "" {
|
||
return &AppStoreError{
|
||
Code: "VALIDATION_ERROR",
|
||
Message: "Deployment type is required",
|
||
}
|
||
}
|
||
|
||
validTypes := []string{"docker", "kubernetes"}
|
||
validType := false
|
||
for _, t := range validTypes {
|
||
if deploy.Type == t {
|
||
validType = true
|
||
break
|
||
}
|
||
}
|
||
|
||
if !validType {
|
||
return &AppStoreError{
|
||
Code: "VALIDATION_ERROR",
|
||
Message: fmt.Sprintf("Invalid deployment type: %s", deploy.Type),
|
||
}
|
||
}
|
||
|
||
switch deploy.Type {
|
||
case "docker":
|
||
if deploy.Docker == nil {
|
||
return &AppStoreError{
|
||
Code: "VALIDATION_ERROR",
|
||
Message: "Docker configuration is required for docker deployment type",
|
||
}
|
||
}
|
||
case "kubernetes":
|
||
if deploy.Kubernetes == nil {
|
||
return &AppStoreError{
|
||
Code: "VALIDATION_ERROR",
|
||
Message: "Kubernetes configuration is required for kubernetes deployment type",
|
||
}
|
||
}
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// validateConfigValue validates a single configuration value
|
||
func (p *Parser) validateConfigValue(fieldName string, field ConfigField, value interface{}) error {
|
||
switch field.Type {
|
||
case "string":
|
||
if str, ok := value.(string); ok {
|
||
if field.Pattern != "" {
|
||
matched, err := regexp.MatchString(field.Pattern, str)
|
||
if err != nil {
|
||
return &AppStoreError{
|
||
Code: "VALIDATION_ERROR",
|
||
Message: fmt.Sprintf("Invalid regex pattern for field %s: %s", fieldName, field.Pattern),
|
||
Details: err.Error(),
|
||
}
|
||
}
|
||
if !matched {
|
||
return &AppStoreError{
|
||
Code: "VALIDATION_ERROR",
|
||
Message: fmt.Sprintf("Value does not match pattern for field %s", fieldName),
|
||
}
|
||
}
|
||
}
|
||
} else {
|
||
return &AppStoreError{
|
||
Code: "VALIDATION_ERROR",
|
||
Message: fmt.Sprintf("Field %s must be a string", fieldName),
|
||
}
|
||
}
|
||
case "int":
|
||
var intVal int
|
||
switch v := value.(type) {
|
||
case float64:
|
||
intVal = int(v)
|
||
case int:
|
||
intVal = v
|
||
case int64:
|
||
intVal = int(v)
|
||
default:
|
||
return &AppStoreError{
|
||
Code: "VALIDATION_ERROR",
|
||
Message: fmt.Sprintf("Field %s must be an integer", fieldName),
|
||
}
|
||
}
|
||
|
||
if field.Min != nil {
|
||
if min, ok := field.Min.(float64); ok && float64(intVal) < min {
|
||
return &AppStoreError{
|
||
Code: "VALIDATION_ERROR",
|
||
Message: fmt.Sprintf("Field %s must be at least %v", fieldName, field.Min),
|
||
}
|
||
}
|
||
}
|
||
if field.Max != nil {
|
||
if max, ok := field.Max.(float64); ok && float64(intVal) > max {
|
||
return &AppStoreError{
|
||
Code: "VALIDATION_ERROR",
|
||
Message: fmt.Sprintf("Field %s must be at most %v", fieldName, field.Max),
|
||
}
|
||
}
|
||
}
|
||
case "bool":
|
||
if _, ok := value.(bool); !ok {
|
||
return &AppStoreError{
|
||
Code: "VALIDATION_ERROR",
|
||
Message: fmt.Sprintf("Field %s must be a boolean", fieldName),
|
||
}
|
||
}
|
||
case "select":
|
||
if str, ok := value.(string); ok {
|
||
validOption := false
|
||
for _, option := range field.Options {
|
||
if option == str {
|
||
validOption = true
|
||
break
|
||
}
|
||
}
|
||
if !validOption {
|
||
return &AppStoreError{
|
||
Code: "VALIDATION_ERROR",
|
||
Message: fmt.Sprintf("Field %s must be one of: %s", fieldName, strings.Join(field.Options, ", ")),
|
||
}
|
||
}
|
||
} else {
|
||
return &AppStoreError{
|
||
Code: "VALIDATION_ERROR",
|
||
Message: fmt.Sprintf("Field %s must be a string", fieldName),
|
||
}
|
||
}
|
||
}
|
||
|
||
return nil
|
||
}
|