Merge remote-tracking branch 'upstream/main' into feat/base-service-delivery

# Conflicts:
#	server/.env.example
#	server/internal/config/config.go
#	server/internal/service/delivery.go
#	server/internal/service/delivery_test.go
This commit is contained in:
mac
2026-07-28 09:34:37 +08:00
11 changed files with 786 additions and 602 deletions
+4
View File
@@ -22,6 +22,10 @@ DELIVERY_CALLBACK_BASE_URL=http://authserver-backend.authserver.svc.cluster.loca
DELIVERY_RESERVATION_TTL_MINUTES=120
DELIVERY_GLOBAL_LIMIT=2
DELIVERY_TARGET_LIMIT=2
# 单机 MySQL 实例数上限(同机多实例,容量由配额 + playbook 实机守卫兜底)
DELIVERY_HOST_INSTANCE_LIMIT=4
# 数据盘挂载点白名单(逗号分隔,第一项为默认值)
DELIVERY_DATA_DISKS=/data
AWX_BASE_URL=
AWX_TOKEN=
AWX_USERNAME=
+61 -118
View File
@@ -152,7 +152,7 @@ const docTemplate = `{
"BearerAuth": []
}
],
"description": "返回所有已启用的部署目标(如 k8s 集群、主机池)",
"description": "从 AWX 动态返回可用 Job Template 及其 Inventory hosts",
"produces": [
"application/json"
],
@@ -160,6 +160,14 @@ const docTemplate = `{
"delivery"
],
"summary": "获取可用部署目标",
"parameters": [
{
"type": "string",
"description": "组件过滤,例如 mysql",
"name": "component",
"in": "query"
}
],
"responses": {
"200": {
"description": "items: 部署目标数组",
@@ -176,64 +184,6 @@ const docTemplate = `{
}
}
}
},
"post": {
"security": [
{
"BearerAuth": []
}
],
"description": "管理员创建新的部署目标(目前仅支持 k8s 类型)",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"delivery"
],
"summary": "创建部署目标",
"parameters": [
{
"description": "目标配置",
"name": "body",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/handler.targetPayload"
}
}
],
"responses": {
"201": {
"description": "目标已创建",
"schema": {
"$ref": "#/definitions/model.DeploymentTarget"
}
},
"400": {
"description": "参数错误",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"401": {
"description": "未授权",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"409": {
"description": "名称冲突",
"schema": {
"type": "object",
"additionalProperties": true
}
}
}
}
},
"/auth/api/v1/delivery/tasks": {
@@ -254,6 +204,7 @@ const docTemplate = `{
"parameters": [
{
"type": "integer",
"format": "int64",
"description": "业务线 ID 过滤",
"name": "business_line_id",
"in": "query"
@@ -689,65 +640,6 @@ const docTemplate = `{
}
}
},
"handler.targetPayload": {
"type": "object",
"required": [
"awx_inventory_id",
"awx_template_id",
"name",
"target_type"
],
"properties": {
"awx_inventory_id": {
"type": "integer"
},
"awx_template_id": {
"type": "integer"
},
"metadata": {
"type": "object",
"additionalProperties": {}
},
"name": {
"type": "string"
},
"target_type": {
"type": "string"
}
}
},
"model.DeploymentTarget": {
"type": "object",
"properties": {
"awx_inventory_id": {
"type": "integer"
},
"awx_template_id": {
"type": "integer"
},
"created_at": {
"type": "string"
},
"enabled": {
"type": "boolean"
},
"id": {
"type": "integer"
},
"metadata": {
"type": "string"
},
"name": {
"type": "string"
},
"target_type": {
"type": "string"
},
"updated_at": {
"type": "string"
}
}
},
"model.ResourceQuota": {
"type": "object",
"properties": {
@@ -816,18 +708,55 @@ const docTemplate = `{
"target_id"
],
"properties": {
"binlog_expire_logs_seconds": {
"type": "integer"
},
"business_line_id": {
"type": "integer"
},
"character_set": {
"type": "string"
},
"collation": {
"type": "string"
},
"cpu_milli": {
"type": "integer"
},
"data_disk": {
"type": "string"
},
"innodb_flush_log_at_trx_commit": {
"type": "integer"
},
"innodb_io_capacity": {
"type": "integer"
},
"innodb_redo_log_capacity": {
"type": "string"
},
"instance_name": {
"type": "string"
},
"long_query_time": {
"type": "number"
},
"lower_case_table_names": {
"type": "integer"
},
"max_binlog_size": {
"type": "string"
},
"max_connections": {
"description": "高级参数(选填,零值视为未设置)",
"type": "string"
},
"memory_mi": {
"type": "integer"
},
"mysql_port": {
"type": "integer"
},
"mysql_version": {
"type": "string"
},
@@ -837,8 +766,22 @@ const docTemplate = `{
"storage_gi": {
"type": "integer"
},
"sync_binlog": {
"type": "integer"
},
"target_host": {
"description": "调度控制(选填):点名候选池内主机跳过自动选机,端口/配额/实机守卫照常执行",
"type": "string"
},
"target_id": {
"type": "integer"
},
"timezone": {
"description": "数据库配置(选填,缺省由 playbook 基线兜底)",
"type": "string"
},
"topology": {
"type": "string"
}
}
}
+61 -118
View File
@@ -145,7 +145,7 @@
"BearerAuth": []
}
],
"description": "返回所有已启用的部署目标(如 k8s 集群、主机池)",
"description": "从 AWX 动态返回可用 Job Template 及其 Inventory hosts",
"produces": [
"application/json"
],
@@ -153,6 +153,14 @@
"delivery"
],
"summary": "获取可用部署目标",
"parameters": [
{
"type": "string",
"description": "组件过滤,例如 mysql",
"name": "component",
"in": "query"
}
],
"responses": {
"200": {
"description": "items: 部署目标数组",
@@ -169,64 +177,6 @@
}
}
}
},
"post": {
"security": [
{
"BearerAuth": []
}
],
"description": "管理员创建新的部署目标(目前仅支持 k8s 类型)",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"delivery"
],
"summary": "创建部署目标",
"parameters": [
{
"description": "目标配置",
"name": "body",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/handler.targetPayload"
}
}
],
"responses": {
"201": {
"description": "目标已创建",
"schema": {
"$ref": "#/definitions/model.DeploymentTarget"
}
},
"400": {
"description": "参数错误",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"401": {
"description": "未授权",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"409": {
"description": "名称冲突",
"schema": {
"type": "object",
"additionalProperties": true
}
}
}
}
},
"/auth/api/v1/delivery/tasks": {
@@ -247,6 +197,7 @@
"parameters": [
{
"type": "integer",
"format": "int64",
"description": "业务线 ID 过滤",
"name": "business_line_id",
"in": "query"
@@ -682,65 +633,6 @@
}
}
},
"handler.targetPayload": {
"type": "object",
"required": [
"awx_inventory_id",
"awx_template_id",
"name",
"target_type"
],
"properties": {
"awx_inventory_id": {
"type": "integer"
},
"awx_template_id": {
"type": "integer"
},
"metadata": {
"type": "object",
"additionalProperties": {}
},
"name": {
"type": "string"
},
"target_type": {
"type": "string"
}
}
},
"model.DeploymentTarget": {
"type": "object",
"properties": {
"awx_inventory_id": {
"type": "integer"
},
"awx_template_id": {
"type": "integer"
},
"created_at": {
"type": "string"
},
"enabled": {
"type": "boolean"
},
"id": {
"type": "integer"
},
"metadata": {
"type": "string"
},
"name": {
"type": "string"
},
"target_type": {
"type": "string"
},
"updated_at": {
"type": "string"
}
}
},
"model.ResourceQuota": {
"type": "object",
"properties": {
@@ -809,18 +701,55 @@
"target_id"
],
"properties": {
"binlog_expire_logs_seconds": {
"type": "integer"
},
"business_line_id": {
"type": "integer"
},
"character_set": {
"type": "string"
},
"collation": {
"type": "string"
},
"cpu_milli": {
"type": "integer"
},
"data_disk": {
"type": "string"
},
"innodb_flush_log_at_trx_commit": {
"type": "integer"
},
"innodb_io_capacity": {
"type": "integer"
},
"innodb_redo_log_capacity": {
"type": "string"
},
"instance_name": {
"type": "string"
},
"long_query_time": {
"type": "number"
},
"lower_case_table_names": {
"type": "integer"
},
"max_binlog_size": {
"type": "string"
},
"max_connections": {
"description": "高级参数(选填,零值视为未设置)",
"type": "string"
},
"memory_mi": {
"type": "integer"
},
"mysql_port": {
"type": "integer"
},
"mysql_version": {
"type": "string"
},
@@ -830,8 +759,22 @@
"storage_gi": {
"type": "integer"
},
"sync_binlog": {
"type": "integer"
},
"target_host": {
"description": "调度控制(选填):点名候选池内主机跳过自动选机,端口/配额/实机守卫照常执行",
"type": "string"
},
"target_id": {
"type": "integer"
},
"timezone": {
"description": "数据库配置(选填,缺省由 playbook 基线兜底)",
"type": "string"
},
"topology": {
"type": "string"
}
}
}
+42 -79
View File
@@ -22,46 +22,6 @@ definitions:
- storage_gi
- target_id
type: object
handler.targetPayload:
properties:
awx_inventory_id:
type: integer
awx_template_id:
type: integer
metadata:
additionalProperties: {}
type: object
name:
type: string
target_type:
type: string
required:
- awx_inventory_id
- awx_template_id
- name
- target_type
type: object
model.DeploymentTarget:
properties:
awx_inventory_id:
type: integer
awx_template_id:
type: integer
created_at:
type: string
enabled:
type: boolean
id:
type: integer
metadata:
type: string
name:
type: string
target_type:
type: string
updated_at:
type: string
type: object
model.ResourceQuota:
properties:
business_line_id:
@@ -101,22 +61,57 @@ definitions:
type: object
service.MySQLDeliveryInput:
properties:
binlog_expire_logs_seconds:
type: integer
business_line_id:
type: integer
character_set:
type: string
collation:
type: string
cpu_milli:
type: integer
data_disk:
type: string
innodb_flush_log_at_trx_commit:
type: integer
innodb_io_capacity:
type: integer
innodb_redo_log_capacity:
type: string
instance_name:
type: string
long_query_time:
type: number
lower_case_table_names:
type: integer
max_binlog_size:
type: string
max_connections:
description: 高级参数(选填,零值视为未设置)
type: string
memory_mi:
type: integer
mysql_port:
type: integer
mysql_version:
type: string
namespace:
type: string
storage_gi:
type: integer
sync_binlog:
type: integer
target_host:
description: 调度控制(选填):点名候选池内主机跳过自动选机,端口/配额/实机守卫照常执行
type: string
target_id:
type: integer
timezone:
description: 数据库配置(选填,缺省由 playbook 基线兜底)
type: string
topology:
type: string
required:
- business_line_id
- cpu_milli
@@ -220,7 +215,12 @@ paths:
- delivery
/auth/api/v1/delivery/targets:
get:
description: 返回所有已启用的部署目标(如 k8s 集群、主机池)
description: 从 AWX 动态返回可用 Job Template 及其 Inventory hosts
parameters:
- description: 组件过滤,例如 mysql
in: query
name: component
type: string
produces:
- application/json
responses:
@@ -239,49 +239,12 @@ paths:
summary: 获取可用部署目标
tags:
- delivery
post:
consumes:
- application/json
description: 管理员创建新的部署目标(目前仅支持 k8s 类型)
parameters:
- description: 目标配置
in: body
name: body
required: true
schema:
$ref: '#/definitions/handler.targetPayload'
produces:
- application/json
responses:
"201":
description: 目标已创建
schema:
$ref: '#/definitions/model.DeploymentTarget'
"400":
description: 参数错误
schema:
additionalProperties: true
type: object
"401":
description: 未授权
schema:
additionalProperties: true
type: object
"409":
description: 名称冲突
schema:
additionalProperties: true
type: object
security:
- BearerAuth: []
summary: 创建部署目标
tags:
- delivery
/auth/api/v1/delivery/tasks:
get:
description: 返回当前用户可见的交付任务列表(管理员可见全部)
parameters:
- description: 业务线 ID 过滤
format: int64
in: query
name: business_line_id
type: integer
+116 -112
View File
@@ -14,62 +14,64 @@ type OAuthClient struct {
}
type Config struct {
AppEnv string
HTTPAddr string
PublicBaseURL string
MySQLDSN string
AutoMigrate bool
SSOEnabled bool
JWTSecret string
JWTIssuer string
JWTTTLMinutes int
SAMLEntityID string
SAMLACSURL string
SAMLSPCert string
SAMLSPKey string
SAMLIDPMetaURL string
SAMLLogoutURL string
WayenLoginURL string
WayenTargetURL string
WayenUsernameKey string
WayenPasswordKey string
WayenLoginFormat string
WayenLoginValue string
WayenOAuthRef string
WayenOAuthLoginURL string
WayneAPIBaseURL string
WayneAdminUsername string
WayneAdminPassword string
WayneTokenTTLMinutes int
WayneInternalAPIBaseURL string
WayneServiceName string
WayneServiceAPISecretKey string
OAuthClientID string
OAuthClientSecret string
OAuthRedirectURI string
OAuthCodeTTLSeconds int
OIDCIssuer string
OIDCAuthorizeURL string
OIDCTokenURL string
OIDCUserInfoURL string
OIDCJWKSURL string
CloudDMClientID string
CloudDMClientSecret string
CloudDMRedirectURI string
CloudDMTargetURL string
CloudDMRegisterURL string
CloudDMAPIToken string
AWXBaseURL string
AWXToken string
AWXUsername string
AWXPassword string
AWXWebhookToken string
DeliverySchedulerEnabled bool
DeliveryDispatchSeconds int
DeliveryCallbackBaseURL string
ReservationTTLMinutes int
DeliveryGlobalLimit int
DeliveryTargetLimit int
AppEnv string
HTTPAddr string
PublicBaseURL string
MySQLDSN string
AutoMigrate bool
SSOEnabled bool
JWTSecret string
JWTIssuer string
JWTTTLMinutes int
SAMLEntityID string
SAMLACSURL string
SAMLSPCert string
SAMLSPKey string
SAMLIDPMetaURL string
SAMLLogoutURL string
WayenLoginURL string
WayenTargetURL string
WayenUsernameKey string
WayenPasswordKey string
WayenLoginFormat string
WayenLoginValue string
WayenOAuthRef string
WayenOAuthLoginURL string
WayneAPIBaseURL string
WayneAdminUsername string
WayneAdminPassword string
WayneTokenTTLMinutes int
WayneInternalAPIBaseURL string
WayneServiceName string
WayneServiceAPISecretKey string
OAuthClientID string
OAuthClientSecret string
OAuthRedirectURI string
OAuthCodeTTLSeconds int
OIDCIssuer string
OIDCAuthorizeURL string
OIDCTokenURL string
OIDCUserInfoURL string
OIDCJWKSURL string
CloudDMClientID string
CloudDMClientSecret string
CloudDMRedirectURI string
CloudDMTargetURL string
CloudDMRegisterURL string
CloudDMAPIToken string
AWXBaseURL string
AWXToken string
AWXUsername string
AWXPassword string
AWXWebhookToken string
DeliverySchedulerEnabled bool
DeliveryDispatchSeconds int
DeliveryCallbackBaseURL string
ReservationTTLMinutes int
DeliveryGlobalLimit int
DeliveryTargetLimit int
DeliveryHostInstanceLimit int
DeliveryDataDisks []string
}
func Load() Config {
@@ -82,62 +84,64 @@ func Load() Config {
oidcIssuer = strings.TrimRight(oidcIssuer, "/")
return Config{
AppEnv: env("APP_ENV", "dev"),
HTTPAddr: httpAddr,
PublicBaseURL: publicBaseURL,
MySQLDSN: env("MYSQL_DSN", "auth:auth@tcp(127.0.0.1:3306)/authserver?charset=utf8mb4&parseTime=True&loc=Local"),
AutoMigrate: envBool("AUTO_MIGRATE", true),
SSOEnabled: envBool("SSO_ENABLED", true),
JWTSecret: env("JWT_SECRET", "change-this-secret"),
JWTIssuer: env("JWT_ISSUER", "authserver"),
JWTTTLMinutes: envInt("JWT_TTL_MINUTES", 120),
SAMLEntityID: samlEntityID,
SAMLACSURL: samlACSURL,
SAMLSPCert: env("SAML_SP_CERT_FILE", "certs/sp.crt"),
SAMLSPKey: env("SAML_SP_KEY_FILE", "certs/sp.key"),
SAMLIDPMetaURL: env("SAML_IDP_METADATA_URL", "http://sso-internal.dev.qiniu.io/saml2/meta"),
SAMLLogoutURL: trimURL(env("SAML_LOGOUT_URL", "")),
WayenLoginURL: env("WAYEN_LOGIN_URL", ""),
WayenTargetURL: env("WAYEN_TARGET_URL", ""),
WayenUsernameKey: env("WAYEN_USERNAME_KEY", "email"),
WayenPasswordKey: env("WAYEN_PASSWORD_KEY", "password"),
WayenLoginFormat: env("WAYEN_LOGIN_FORMAT", "form"),
WayenLoginValue: env("WAYEN_LOGIN_VALUE", "email"),
WayenOAuthRef: env("WAYEN_OAUTH_REF", "/portal/namespace/1/app"),
WayenOAuthLoginURL: trimURL(env("WAYEN_OAUTH_LOGIN_URL", "")),
WayneAPIBaseURL: trimURL(env("WAYNE_API_BASE_URL", env("WAYNE_INTERNAL_API_BASE_URL", ""))),
WayneAdminUsername: env("WAYNE_ADMIN_USERNAME", ""),
WayneAdminPassword: env("WAYNE_ADMIN_PASSWORD", ""),
WayneTokenTTLMinutes: envInt("WAYNE_TOKEN_TTL_MINUTES", 1440),
WayneInternalAPIBaseURL: trimURL(env("WAYNE_INTERNAL_API_BASE_URL", "")),
WayneServiceName: env("WAYNE_SERVICE_NAME", "xinfra"),
WayneServiceAPISecretKey: env("WAYNE_SERVICE_API_SECRET_KEY", ""),
OAuthClientID: env("OAUTH_WAYNE_CLIENT_ID", "wayne"),
OAuthClientSecret: env("OAUTH_WAYNE_CLIENT_SECRET", "wayne-secret"),
OAuthRedirectURI: env("OAUTH_WAYNE_REDIRECT_URI", ""),
OAuthCodeTTLSeconds: envInt("OAUTH_CODE_TTL_SECONDS", 120),
OIDCIssuer: oidcIssuer,
OIDCAuthorizeURL: trimURL(env("OIDC_AUTHORIZATION_ENDPOINT", oidcIssuer+"/oauth/authorize")),
OIDCTokenURL: trimURL(env("OIDC_TOKEN_ENDPOINT", oidcIssuer+"/oauth/token")),
OIDCUserInfoURL: trimURL(env("OIDC_USERINFO_ENDPOINT", oidcIssuer+"/oauth/userinfo")),
OIDCJWKSURL: trimURL(env("OIDC_JWKS_URI", oidcIssuer+"/oauth/jwks")),
CloudDMClientID: env("OIDC_CLOUDDM_CLIENT_ID", "clouddm"),
CloudDMClientSecret: env("OIDC_CLOUDDM_CLIENT_SECRET", ""),
CloudDMRedirectURI: env("OIDC_CLOUDDM_REDIRECT_URI", ""),
CloudDMTargetURL: env("CLOUDDM_TARGET_URL", ""),
CloudDMRegisterURL: trimURL(env("CLOUDDM_REGISTER_URL", "")),
CloudDMAPIToken: env("CLOUDDM_API_TOKEN", ""),
AWXBaseURL: trimURL(env("AWX_BASE_URL", "")),
AWXToken: env("AWX_TOKEN", ""),
AWXUsername: env("AWX_USERNAME", ""),
AWXPassword: env("AWX_PASSWORD", ""),
AWXWebhookToken: env("AWX_WEBHOOK_TOKEN", ""),
DeliverySchedulerEnabled: envBool("DELIVERY_SCHEDULER_ENABLED", false),
DeliveryDispatchSeconds: envInt("DELIVERY_DISPATCH_SECONDS", 5),
DeliveryCallbackBaseURL: trimURL(env("DELIVERY_CALLBACK_BASE_URL", publicBaseURL)),
ReservationTTLMinutes: envInt("DELIVERY_RESERVATION_TTL_MINUTES", 120),
DeliveryGlobalLimit: envInt("DELIVERY_GLOBAL_LIMIT", 2),
DeliveryTargetLimit: envInt("DELIVERY_TARGET_LIMIT", 2),
AppEnv: env("APP_ENV", "dev"),
HTTPAddr: httpAddr,
PublicBaseURL: publicBaseURL,
MySQLDSN: env("MYSQL_DSN", "auth:auth@tcp(127.0.0.1:3306)/authserver?charset=utf8mb4&parseTime=True&loc=Local"),
AutoMigrate: envBool("AUTO_MIGRATE", true),
SSOEnabled: envBool("SSO_ENABLED", true),
JWTSecret: env("JWT_SECRET", "change-this-secret"),
JWTIssuer: env("JWT_ISSUER", "authserver"),
JWTTTLMinutes: envInt("JWT_TTL_MINUTES", 120),
SAMLEntityID: samlEntityID,
SAMLACSURL: samlACSURL,
SAMLSPCert: env("SAML_SP_CERT_FILE", "certs/sp.crt"),
SAMLSPKey: env("SAML_SP_KEY_FILE", "certs/sp.key"),
SAMLIDPMetaURL: env("SAML_IDP_METADATA_URL", "http://sso-internal.dev.qiniu.io/saml2/meta"),
SAMLLogoutURL: trimURL(env("SAML_LOGOUT_URL", "")),
WayenLoginURL: env("WAYEN_LOGIN_URL", ""),
WayenTargetURL: env("WAYEN_TARGET_URL", ""),
WayenUsernameKey: env("WAYEN_USERNAME_KEY", "email"),
WayenPasswordKey: env("WAYEN_PASSWORD_KEY", "password"),
WayenLoginFormat: env("WAYEN_LOGIN_FORMAT", "form"),
WayenLoginValue: env("WAYEN_LOGIN_VALUE", "email"),
WayenOAuthRef: env("WAYEN_OAUTH_REF", "/portal/namespace/1/app"),
WayenOAuthLoginURL: trimURL(env("WAYEN_OAUTH_LOGIN_URL", "")),
WayneAPIBaseURL: trimURL(env("WAYNE_API_BASE_URL", env("WAYNE_INTERNAL_API_BASE_URL", ""))),
WayneAdminUsername: env("WAYNE_ADMIN_USERNAME", ""),
WayneAdminPassword: env("WAYNE_ADMIN_PASSWORD", ""),
WayneTokenTTLMinutes: envInt("WAYNE_TOKEN_TTL_MINUTES", 1440),
WayneInternalAPIBaseURL: trimURL(env("WAYNE_INTERNAL_API_BASE_URL", "")),
WayneServiceName: env("WAYNE_SERVICE_NAME", "xinfra"),
WayneServiceAPISecretKey: env("WAYNE_SERVICE_API_SECRET_KEY", ""),
OAuthClientID: env("OAUTH_WAYNE_CLIENT_ID", "wayne"),
OAuthClientSecret: env("OAUTH_WAYNE_CLIENT_SECRET", "wayne-secret"),
OAuthRedirectURI: env("OAUTH_WAYNE_REDIRECT_URI", ""),
OAuthCodeTTLSeconds: envInt("OAUTH_CODE_TTL_SECONDS", 120),
OIDCIssuer: oidcIssuer,
OIDCAuthorizeURL: trimURL(env("OIDC_AUTHORIZATION_ENDPOINT", oidcIssuer+"/oauth/authorize")),
OIDCTokenURL: trimURL(env("OIDC_TOKEN_ENDPOINT", oidcIssuer+"/oauth/token")),
OIDCUserInfoURL: trimURL(env("OIDC_USERINFO_ENDPOINT", oidcIssuer+"/oauth/userinfo")),
OIDCJWKSURL: trimURL(env("OIDC_JWKS_URI", oidcIssuer+"/oauth/jwks")),
CloudDMClientID: env("OIDC_CLOUDDM_CLIENT_ID", "clouddm"),
CloudDMClientSecret: env("OIDC_CLOUDDM_CLIENT_SECRET", ""),
CloudDMRedirectURI: env("OIDC_CLOUDDM_REDIRECT_URI", ""),
CloudDMTargetURL: env("CLOUDDM_TARGET_URL", ""),
CloudDMRegisterURL: trimURL(env("CLOUDDM_REGISTER_URL", "")),
CloudDMAPIToken: env("CLOUDDM_API_TOKEN", ""),
AWXBaseURL: trimURL(env("AWX_BASE_URL", "")),
AWXToken: env("AWX_TOKEN", ""),
AWXUsername: env("AWX_USERNAME", ""),
AWXPassword: env("AWX_PASSWORD", ""),
AWXWebhookToken: env("AWX_WEBHOOK_TOKEN", ""),
DeliverySchedulerEnabled: envBool("DELIVERY_SCHEDULER_ENABLED", false),
DeliveryDispatchSeconds: envInt("DELIVERY_DISPATCH_SECONDS", 5),
DeliveryCallbackBaseURL: trimURL(env("DELIVERY_CALLBACK_BASE_URL", publicBaseURL)),
ReservationTTLMinutes: envInt("DELIVERY_RESERVATION_TTL_MINUTES", 120),
DeliveryGlobalLimit: envInt("DELIVERY_GLOBAL_LIMIT", 2),
DeliveryTargetLimit: envInt("DELIVERY_TARGET_LIMIT", 2),
DeliveryHostInstanceLimit: envInt("DELIVERY_HOST_INSTANCE_LIMIT", 4),
DeliveryDataDisks: splitCSV(env("DELIVERY_DATA_DISKS", "/data")),
}
}
+119 -54
View File
@@ -24,6 +24,7 @@ import (
)
var dnsLabelPattern = regexp.MustCompile(`^[a-z0-9](?:[-a-z0-9]*[a-z0-9])?$`)
var hostNamePattern = regexp.MustCompile(`^[a-zA-Z0-9](?:[-a-zA-Z0-9.]*[a-zA-Z0-9])?$`)
type MySQLDeliveryInput struct {
BusinessLineID uint64 `json:"business_line_id" binding:"required"`
@@ -34,9 +35,10 @@ type MySQLDeliveryInput struct {
Topology string `json:"topology"`
MySQLPort int `json:"mysql_port"`
DataDisk string `json:"data_disk"`
CPUCores int64 `json:"cpu_cores" binding:"required"`
MemoryGB int64 `json:"memory_gb" binding:"required"`
StorageGB int64 `json:"storage_gb" binding:"required"`
TargetHost string `json:"target_host"`
CPUCores int64 `json:"cpu_cores"`
MemoryGB int64 `json:"memory_gb"`
StorageGB int64 `json:"storage_gb"`
ParamTemplate string `json:"param_template"`
TimeZone string `json:"timezone"`
LowerCaseTableNames int `json:"lower_case_table_names"`
@@ -50,9 +52,9 @@ type MySQLDeliveryInput struct {
LongQueryTime float64 `json:"long_query_time"`
BinlogExpireLogsSeconds int64 `json:"binlog_expire_logs_seconds"`
MaxBinlogSize string `json:"max_binlog_size"`
CPUMilli int64 `json:"-"`
MemoryMi int64 `json:"-"`
StorageGi int64 `json:"-"`
CPUMilli int64 `json:"cpu_milli"`
MemoryMi int64 `json:"memory_mi"`
StorageGi int64 `json:"storage_gi"`
}
type deliveryPayload struct {
@@ -118,31 +120,51 @@ func parseTargetMetadata(raw string) targetMetadata {
return meta
}
// firstFreeHost 返回候选池中第一个未被占用的节点。
func firstFreeHost(hosts []targetHost, occupied []string) *targetHost {
taken := make(map[string]bool, len(occupied))
for _, h := range occupied {
taken[h] = true
// firstFreeHost 返回候选池中非失败任务数未达单机实例上限的第一个节点。
func firstFreeHost(hosts []targetHost, occupied []string, limit int) *targetHost {
if limit < 1 {
limit = 1
}
for i := range hosts {
if !taken[hosts[i].Name] {
if hostTaskCount(occupied, hosts[i].Name) < limit {
return &hosts[i]
}
}
return nil
}
func firstFreePort(occupied []int) int {
taken := make(map[int]bool, len(occupied))
for _, port := range occupied {
taken[port] = true
}
for port := 13306; port <= 13999; port++ {
if !taken[port] {
return port
func hostTaskCount(occupied []string, name string) int {
count := 0
for _, h := range occupied {
if h == name {
count++
}
}
return 0
return count
}
const (
mysqlPortPoolStart = 13306
mysqlPortPoolEnd = 13999
)
func allocatePort(requested int, used []int) (int, error) {
taken := make(map[int]bool, len(used))
for _, p := range used {
taken[p] = true
}
if requested != 0 {
if taken[requested] {
return 0, fmt.Errorf("mysql_port %d is already allocated on the target host", requested)
}
return requested, nil
}
for p := mysqlPortPoolStart; p <= mysqlPortPoolEnd; p++ {
if !taken[p] {
return p, nil
}
}
return 0, fmt.Errorf("mysql port pool %d-%d is exhausted on the target host", mysqlPortPoolStart, mysqlPortPoolEnd)
}
type DeliveryService struct {
@@ -228,8 +250,11 @@ func (s *DeliveryService) CreateTask(ctx context.Context, userID uint64, isAdmin
if idempotencyKey == "" || len(idempotencyKey) > 128 {
return nil, false, fmt.Errorf("Idempotency-Key header is required and must not exceed 128 characters")
}
if input.DataDisk == "" && len(s.cfg.DeliveryDataDisks) > 0 {
input.DataDisk = s.cfg.DeliveryDataDisks[0]
}
normalizeMySQLDeliveryInput(&input)
if err := validateDeliveryInput(input); err != nil {
if err := validateDeliveryInput(input, s.cfg.DeliveryDataDisks); err != nil {
return nil, false, err
}
@@ -290,9 +315,8 @@ func (s *DeliveryService) CreateTask(ctx context.Context, userID uint64, isAdmin
return &task, false, nil
}
var supportedMySQLVersions = map[string]bool{"8.0": true}
var supportedMySQLVersions = map[string]bool{"8.0": true, "8.4": true}
var supportedMySQLTopologies = map[string]bool{"standalone": true}
var supportedDataDisks = map[string]bool{"/data": true, "/disk1": true, "/mnt/vol-1": true}
var supportedParamTemplates = map[string]bool{"default": true, "high_performance": true, "high_safety": true}
var supportedCharacterSets = map[string]bool{"utf8mb4": true, "utf8": true, "gbk": true, "latin1": true}
var supportedCollations = map[string]bool{
@@ -301,6 +325,7 @@ var supportedCollations = map[string]bool{
}
var supportedRedoLogCapacity = map[string]bool{"auto": true, "128M": true, "256M": true, "512M": true, "1G": true}
var supportedMaxBinlogSize = map[string]bool{"128M": true, "256M": true, "512M": true, "1G": true}
var timezonePattern = regexp.MustCompile(`^([+-](0\d|1[0-4]):[0-5]\d|SYSTEM|[A-Za-z]+(?:/[A-Za-z0-9_+-]+)+)$`)
func normalizeMySQLDeliveryInput(input *MySQLDeliveryInput) {
if input.MySQLVersion == "" {
@@ -348,9 +373,15 @@ func normalizeMySQLDeliveryInput(input *MySQLDeliveryInput) {
if input.MaxBinlogSize == "" {
input.MaxBinlogSize = "256M"
}
input.CPUMilli = input.CPUCores * 1000
input.MemoryMi = input.MemoryGB * 1024
input.StorageGi = input.StorageGB
if input.CPUMilli == 0 && input.CPUCores != 0 {
input.CPUMilli = input.CPUCores * 1000
}
if input.MemoryMi == 0 && input.MemoryGB != 0 {
input.MemoryMi = input.MemoryGB * 1024
}
if input.StorageGi == 0 && input.StorageGB != 0 {
input.StorageGi = input.StorageGB
}
}
func defaultCollation(characterSet string) string {
@@ -366,38 +397,48 @@ func defaultCollation(characterSet string) string {
}
}
func validateDeliveryInput(input MySQLDeliveryInput) error {
func validateDeliveryInput(input MySQLDeliveryInput, dataDisks []string) error {
if len(input.Namespace) > 63 || !dnsLabelPattern.MatchString(input.Namespace) {
return fmt.Errorf("namespace must be a valid Kubernetes DNS label")
}
if len(input.InstanceName) > 63 || !dnsLabelPattern.MatchString(input.InstanceName) {
return fmt.Errorf("instance_name must be a valid Kubernetes DNS label")
}
if !oneOfInt64(input.CPUCores, []int64{1, 2, 4, 8, 16}) {
return fmt.Errorf("cpu_cores must be one of 1, 2, 4, 8, 16")
}
if !oneOfInt64(input.MemoryGB, []int64{2, 4, 8, 16, 32, 64}) {
return fmt.Errorf("memory_gb must be one of 2, 4, 8, 16, 32, 64")
}
if input.StorageGB < 20 || input.StorageGB > 2000 {
return fmt.Errorf("storage_gb must be between 20 and 2000")
if input.CPUMilli < 100 || input.CPUMilli > 64000 || input.MemoryMi < 2048 || input.MemoryMi > 65536 || input.StorageGi < 20 || input.StorageGi > 2000 {
return fmt.Errorf("requested resources are outside the supported range (cpu: 100-64000m, memory: 2048-65536 MiB, storage: 20-2000 GiB)")
}
if input.MySQLVersion != "" && !supportedMySQLVersions[input.MySQLVersion] {
return fmt.Errorf("unsupported mysql_version %q, supported: 8.0", input.MySQLVersion)
return fmt.Errorf("unsupported mysql_version %q, supported: 8.0, 8.4 (5.6/5.7 are EOL and have no Ubuntu 24.04 packages)", input.MySQLVersion)
}
if input.Topology != "" && !supportedMySQLTopologies[input.Topology] {
return fmt.Errorf("unsupported topology %q, supported: standalone", input.Topology)
}
if input.MySQLPort != 0 && (input.MySQLPort < 13306 || input.MySQLPort > 13999) {
return fmt.Errorf("mysql_port must be empty for auto assignment or between 13306 and 13999")
if input.MySQLPort != 0 && (input.MySQLPort < mysqlPortPoolStart || input.MySQLPort > mysqlPortPoolEnd) {
return fmt.Errorf("mysql_port must be empty for auto assignment or between %d and %d", mysqlPortPoolStart, mysqlPortPoolEnd)
}
if !supportedDataDisks[input.DataDisk] {
return fmt.Errorf("unsupported data_disk %q", input.DataDisk)
if input.DataDisk != "" {
allowed := dataDisks
if len(allowed) == 0 {
allowed = []string{"/data"}
}
found := false
for _, disk := range allowed {
if input.DataDisk == disk {
found = true
break
}
}
if !found {
return fmt.Errorf("data_disk %q is not in the allowed mount point list %v", input.DataDisk, allowed)
}
}
if input.TargetHost != "" && (len(input.TargetHost) > 253 || !hostNamePattern.MatchString(input.TargetHost)) {
return fmt.Errorf("target_host must be a valid inventory host name")
}
if !supportedParamTemplates[input.ParamTemplate] {
return fmt.Errorf("unsupported param_template %q", input.ParamTemplate)
}
if !validTimeZone(input.TimeZone) {
if !timezonePattern.MatchString(input.TimeZone) {
return fmt.Errorf("unsupported timezone %q", input.TimeZone)
}
if input.LowerCaseTableNames != 0 && input.LowerCaseTableNames != 1 {
@@ -593,20 +634,42 @@ func (s *DeliveryService) claimAndReserve(ctx context.Context) (*model.DeliveryT
if err := tx.Model(&model.DeliveryTask{}).Where("target_id = ? AND target_host <> ? AND status NOT IN ?", task.TargetID, "", occupiedExclude).Pluck("target_host", &occupied).Error; err != nil {
return err
}
host := firstFreeHost(meta.Hosts, occupied)
limit := s.cfg.DeliveryHostInstanceLimit
var host *targetHost
if payload.TargetHost != "" {
for i := range meta.Hosts {
if meta.Hosts[i].Name == payload.TargetHost {
host = &meta.Hosts[i]
break
}
}
if host == nil {
return s.failInTransaction(tx, &task, model.TaskValidationFailed, fmt.Sprintf("target_host %q is not in the candidate host pool", payload.TargetHost))
}
effectiveLimit := limit
if effectiveLimit < 1 {
effectiveLimit = 1
}
if hostTaskCount(occupied, host.Name) >= effectiveLimit {
return fmt.Errorf("defer: pinned host %s reached the per-host instance limit %d", host.Name, effectiveLimit)
}
} else {
host = firstFreeHost(meta.Hosts, occupied, limit)
}
if host == nil {
return fmt.Errorf("defer: no free host available on target")
}
mysqlPort := payload.MySQLPort
if mysqlPort == 0 {
var occupiedPorts []int
if err := tx.Model(&model.DeliveryTask{}).Where("target_id = ? AND target_host = ? AND mysql_port <> ? AND status NOT IN ?", task.TargetID, host.Name, 0, occupiedExclude).Pluck("mysql_port", &occupiedPorts).Error; err != nil {
return err
}
mysqlPort = firstFreePort(occupiedPorts)
if mysqlPort == 0 {
return fmt.Errorf("defer: no free MySQL port available on target host")
}
var usedPorts []int
if err := tx.Model(&model.DeliveryTask{}).Where("target_id = ? AND target_host = ? AND mysql_port <> ? AND status NOT IN ?", task.TargetID, host.Name, 0, occupiedExclude).Pluck("mysql_port", &usedPorts).Error; err != nil {
return err
}
var instancePorts []int
if err := tx.Model(&model.MySQLInstance{}).Where("target_id = ? AND node_name = ? AND status = ?", task.TargetID, host.Name, "active").Pluck("port", &instancePorts).Error; err != nil {
return err
}
mysqlPort, portErr := allocatePort(payload.MySQLPort, append(usedPorts, instancePorts...))
if portErr != nil {
return s.failInTransaction(tx, &task, model.TaskValidationFailed, portErr.Error())
}
reservation := model.ResourceReservation{TaskID: task.ID, BusinessLineID: task.BusinessLineID, TargetID: task.TargetID, CPUMilli: payload.CPUMilli, MemoryMi: payload.MemoryMi, StorageGi: payload.StorageGi, InstanceCount: 1, Status: "reserved", ExpiresAt: time.Now().Add(time.Duration(s.cfg.ReservationTTLMinutes) * time.Minute)}
if err := tx.Create(&reservation).Error; err != nil {
@@ -771,8 +834,10 @@ func (s *DeliveryService) CreateExecution(ctx context.Context, taskID, payloadHa
"instance_name": payload.InstanceName, "mysql_port": task.MySQLPort,
"data_disk": payload.DataDisk, "cpu_cores": payload.CPUCores,
"memory_gb": payload.MemoryGB, "storage_gb": payload.StorageGB,
"cpu_milli": payload.CPUMilli, "memory_mi": payload.MemoryMi, "storage_gi": payload.StorageGi,
"mysql_version": payload.MySQLVersion, "param_template": payload.ParamTemplate,
"timezone": payload.TimeZone, "lower_case_table_names": payload.LowerCaseTableNames,
"target_host": payload.TargetHost,
"timezone": payload.TimeZone, "lower_case_table_names": payload.LowerCaseTableNames,
"character_set": payload.CharacterSet, "collation": payload.Collation,
"max_connections": payload.MaxConnections, "innodb_redo_log_capacity": payload.InnoDBRedoLogCapacity,
"innodb_flush_log_at_trx_commit": payload.InnoDBFlushLogAtTrxCommit, "sync_binlog": payload.SyncBinlog,
+110 -46
View File
@@ -3,59 +3,123 @@ package service
import "testing"
func TestValidateDeliveryInput(t *testing.T) {
dataDisks := []string{"/data", "/disk1"}
valid := MySQLDeliveryInput{BusinessLineID: 1, TargetID: 1, Namespace: "team-a", InstanceName: "mysql-01", CPUCores: 2, MemoryGB: 4, StorageGB: 50}
normalizeMySQLDeliveryInput(&valid)
if err := validateDeliveryInput(valid); err != nil {
if err := validateDeliveryInput(valid, dataDisks); err != nil {
t.Fatalf("valid input rejected: %v", err)
}
withVersion := valid
withVersion.MySQLVersion = "8.0"
if err := validateDeliveryInput(withVersion); err != nil {
t.Fatalf("valid input with version 8.0 rejected: %v", err)
full := valid
full.MySQLVersion = "8.0"
full.Topology = "standalone"
full.MySQLPort = 13306
full.DataDisk = "/disk1"
full.TargetHost = "k8s-server-03"
full.CPUMilli = 2000
full.MemoryMi = 8192
full.StorageGi = 2000
full.TimeZone = "+08:00"
full.LowerCaseTableNames = 0
full.CharacterSet = "utf8mb4"
full.Collation = "utf8mb4_general_ci"
full.MaxConnections = "auto"
full.InnoDBRedoLogCapacity = "256M"
full.InnoDBFlushLogAtTrxCommit = 2
full.SyncBinlog = 0
full.InnoDBIOCapacity = 2000
full.LongQueryTime = 0.5
full.BinlogExpireLogsSeconds = 604800
full.MaxBinlogSize = "512M"
if err := validateDeliveryInput(full, dataDisks); err != nil {
t.Fatalf("valid full input rejected: %v", err)
}
for name, input := range map[string]MySQLDeliveryInput{
"uppercase namespace": valid,
"bad instance": valid,
"bad cpu cores": valid,
"too much memory": valid,
"too little storage": valid,
"too much storage": valid,
"unsupported version": valid,
"unsupported topology": valid,
"bad mysql port": valid,
"bad data disk": valid,
"unsupported charset": valid,
"bad collation": valid,
namedZone := valid
namedZone.TimeZone = "Asia/Shanghai"
if err := validateDeliveryInput(namedZone, dataDisks); err != nil {
t.Fatalf("named timezone rejected: %v", err)
}
lts := valid
lts.MySQLVersion = "8.4"
if err := validateDeliveryInput(lts, dataDisks); err != nil {
t.Fatalf("8.4 LTS rejected: %v", err)
}
for name, mutate := range map[string]func(*MySQLDeliveryInput){
"uppercase namespace": func(in *MySQLDeliveryInput) { in.Namespace = "Team-A" },
"bad instance": func(in *MySQLDeliveryInput) { in.InstanceName = "mysql_01" },
"too little cpu": func(in *MySQLDeliveryInput) { in.CPUMilli = 50 },
"too much cpu": func(in *MySQLDeliveryInput) { in.CPUMilli = 65000 },
"too little memory": func(in *MySQLDeliveryInput) { in.MemoryMi = 1024 },
"too much memory": func(in *MySQLDeliveryInput) { in.MemoryMi = 131072 },
"too little storage": func(in *MySQLDeliveryInput) { in.StorageGi = 10 },
"too much storage": func(in *MySQLDeliveryInput) { in.StorageGi = 4000 },
"unsupported version": func(in *MySQLDeliveryInput) { in.MySQLVersion = "5.7" },
"eol version": func(in *MySQLDeliveryInput) { in.MySQLVersion = "5.6" },
"unsupported topology": func(in *MySQLDeliveryInput) { in.Topology = "mgr_3" },
"port below pool": func(in *MySQLDeliveryInput) { in.MySQLPort = 3307 },
"port above pool": func(in *MySQLDeliveryInput) { in.MySQLPort = 14000 },
"data disk not in list": func(in *MySQLDeliveryInput) { in.DataDisk = "/mnt/other" },
"bad target host": func(in *MySQLDeliveryInput) { in.TargetHost = "-bad-host" },
"bad timezone": func(in *MySQLDeliveryInput) { in.TimeZone = "UTC+8" },
"bad lower case": func(in *MySQLDeliveryInput) { in.LowerCaseTableNames = 2 },
"bad charset": func(in *MySQLDeliveryInput) { in.CharacterSet = "big5" },
"collation mismatch": func(in *MySQLDeliveryInput) { in.CharacterSet = "gbk"; in.Collation = "utf8mb4_general_ci" },
"bad max connections": func(in *MySQLDeliveryInput) { in.MaxConnections = "300" },
"bad redo capacity": func(in *MySQLDeliveryInput) { in.InnoDBRedoLogCapacity = "2G" },
"bad flush log": func(in *MySQLDeliveryInput) { in.InnoDBFlushLogAtTrxCommit = 3 },
"bad sync binlog": func(in *MySQLDeliveryInput) { in.SyncBinlog = 2 },
"bad io capacity": func(in *MySQLDeliveryInput) { in.InnoDBIOCapacity = 500 },
"bad long query time": func(in *MySQLDeliveryInput) { in.LongQueryTime = 3 },
"bad binlog expire": func(in *MySQLDeliveryInput) { in.BinlogExpireLogsSeconds = 3600 },
"bad max binlog size": func(in *MySQLDeliveryInput) { in.MaxBinlogSize = "64M" },
} {
switch name {
case "uppercase namespace":
input.Namespace = "Team-A"
case "bad instance":
input.InstanceName = "mysql_01"
case "bad cpu cores":
input.CPUCores = 3
case "too much memory":
input.MemoryGB = 128
case "too little storage":
input.StorageGB = 10
case "too much storage":
input.StorageGB = 3000
case "unsupported version":
input.MySQLVersion = "5.7"
case "unsupported topology":
input.Topology = "mgr"
case "bad mysql port":
input.MySQLPort = 3306
case "bad data disk":
input.DataDisk = "/"
case "unsupported charset":
input.CharacterSet = "sjis"
case "bad collation":
input.CharacterSet = "utf8"
input.Collation = "utf8mb4_general_ci"
}
if err := validateDeliveryInput(input); err == nil {
input := valid
mutate(&input)
if err := validateDeliveryInput(input, dataDisks); err == nil {
t.Errorf("%s was accepted", name)
}
}
}
func TestFirstFreeHost(t *testing.T) {
hosts := []targetHost{{Name: "node-a"}, {Name: "node-b"}}
if h := firstFreeHost(hosts, nil, 1); h == nil || h.Name != "node-a" {
t.Fatalf("expected node-a on empty occupancy, got %+v", h)
}
if h := firstFreeHost(hosts, []string{"node-a"}, 1); h == nil || h.Name != "node-b" {
t.Fatalf("expected node-b when node-a is full at limit 1, got %+v", h)
}
if h := firstFreeHost(hosts, []string{"node-a", "node-b"}, 1); h != nil {
t.Fatalf("limit 1 with all hosts taken should return nil, got %+v", h)
}
if h := firstFreeHost(hosts, []string{"node-a", "node-b"}, 2); h == nil || h.Name != "node-a" {
t.Fatalf("expected node-a for second round at limit 2, got %+v", h)
}
if h := firstFreeHost(hosts, []string{"node-a", "node-a", "node-b", "node-b"}, 2); h != nil {
t.Fatalf("limit 2 with all hosts saturated should return nil, got %+v", h)
}
if h := firstFreeHost(hosts, []string{"node-a"}, 0); h == nil || h.Name != "node-b" {
t.Fatalf("limit 0 should degrade to 1, got %+v", h)
}
}
func TestAllocatePort(t *testing.T) {
if port, err := allocatePort(0, nil); err != nil || port != mysqlPortPoolStart {
t.Fatalf("expected first pool port %d, got %d err=%v", mysqlPortPoolStart, port, err)
}
if port, err := allocatePort(0, []int{13306, 13307}); err != nil || port != 13308 {
t.Fatalf("expected 13308 skipping occupied, got %d err=%v", port, err)
}
if port, err := allocatePort(13400, []int{13306}); err != nil || port != 13400 {
t.Fatalf("expected requested port 13400, got %d err=%v", port, err)
}
if _, err := allocatePort(13306, []int{13306}); err == nil {
t.Fatal("requested occupied port was accepted")
}
used := make([]int, 0, mysqlPortPoolEnd-mysqlPortPoolStart+1)
for p := mysqlPortPoolStart; p <= mysqlPortPoolEnd; p++ {
used = append(used, p)
}
if _, err := allocatePort(0, used); err == nil {
t.Fatal("exhausted pool still allocated a port")
}
}